fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
282
mobile/App.js
Normal file
282
mobile/App.js
Normal file
@@ -0,0 +1,282 @@
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
BackHandler,
|
||||
Linking,
|
||||
StatusBar,
|
||||
PermissionsAndroid,
|
||||
Platform,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { WebView } from 'react-native-webview';
|
||||
import { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import Pushy from 'pushy-react-native';
|
||||
|
||||
const APP_URL = 'https://iistwin.ru';
|
||||
const APP_ORIGIN = new URL(APP_URL).origin;
|
||||
|
||||
// Injected before every page load:
|
||||
// 1. sessionStorage → localStorage so session survives app restarts.
|
||||
// 2. Intercept setItem('accessToken') → login event for SPA login detection.
|
||||
// 3. Intercept removeItem('accessToken') → logout event.
|
||||
const BASE_INJECTED_JS = `
|
||||
(function() {
|
||||
try {
|
||||
Object.defineProperty(window, 'sessionStorage', {
|
||||
get: function() { return window.localStorage; },
|
||||
configurable: true
|
||||
});
|
||||
} catch(e) {}
|
||||
|
||||
try {
|
||||
var _origSet = localStorage.setItem.bind(localStorage);
|
||||
var _origRemove = localStorage.removeItem.bind(localStorage);
|
||||
|
||||
localStorage.setItem = function(key, value) {
|
||||
_origSet(key, value);
|
||||
if (key === 'accessToken' && value && window.ReactNativeWebView) {
|
||||
window.ReactNativeWebView.postMessage(
|
||||
JSON.stringify({ type: 'login', token: value })
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
localStorage.removeItem = function(key) {
|
||||
if (key === 'accessToken') {
|
||||
var tok = localStorage.getItem('accessToken');
|
||||
if (tok && window.ReactNativeWebView) {
|
||||
window.ReactNativeWebView.postMessage(
|
||||
JSON.stringify({ type: 'logout', token: tok })
|
||||
);
|
||||
}
|
||||
}
|
||||
return _origRemove(key);
|
||||
};
|
||||
} catch(e) {}
|
||||
})();
|
||||
true;
|
||||
`;
|
||||
|
||||
async function requestNotificationPermission() {
|
||||
if (Platform.OS !== 'android') return;
|
||||
if (Platform.Version >= 33) {
|
||||
await PermissionsAndroid.request(
|
||||
PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS,
|
||||
{
|
||||
title: 'Разрешение на уведомления',
|
||||
message: 'iistwin хочет отправлять вам уведомления о задачах и сообщениях.',
|
||||
buttonPositive: 'Разрешить',
|
||||
buttonNegative: 'Не сейчас',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const webViewRef = useRef(null);
|
||||
const [canGoBack, setCanGoBack] = useState(false);
|
||||
const [pushyToken, setPushyToken] = useState(null);
|
||||
// true once the WebView fires onLoadEnd at least once
|
||||
const [webViewReady, setWebViewReady] = useState(false);
|
||||
// Auth token received via login event before pushyToken was available (race guard)
|
||||
const [pendingAuthToken, setPendingAuthToken] = useState(null);
|
||||
// URL from notification tap while app was killed (cold-start)
|
||||
const [pendingUrl, setPendingUrl] = useState(null);
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Register using an auth token passed explicitly (login event / replay)
|
||||
const registerDevice = useCallback((deviceToken, authToken) => {
|
||||
if (!deviceToken || !authToken || !webViewRef.current) return;
|
||||
const dt = JSON.stringify(deviceToken);
|
||||
const at = JSON.stringify(authToken);
|
||||
webViewRef.current.injectJavaScript(`
|
||||
(function() {
|
||||
try {
|
||||
fetch('/api/devices/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + ${at} },
|
||||
body: JSON.stringify({ token: ${dt}, platform: 'android' })
|
||||
}).catch(function() {});
|
||||
} catch(e) {}
|
||||
})();
|
||||
true;
|
||||
`);
|
||||
}, []);
|
||||
|
||||
// Register by reading accessToken from localStorage (existing sessions / page reload)
|
||||
const registerFromStorage = useCallback((deviceToken) => {
|
||||
if (!deviceToken || !webViewRef.current) return;
|
||||
const dt = JSON.stringify(deviceToken);
|
||||
webViewRef.current.injectJavaScript(`
|
||||
(function() {
|
||||
try {
|
||||
var at = localStorage.getItem('accessToken');
|
||||
if (at) {
|
||||
fetch('/api/devices/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + at },
|
||||
body: JSON.stringify({ token: ${dt}, platform: 'android' })
|
||||
}).catch(function() {});
|
||||
}
|
||||
} catch(e) {}
|
||||
})();
|
||||
true;
|
||||
`);
|
||||
}, []);
|
||||
|
||||
const unregisterDevice = useCallback((deviceToken, authToken) => {
|
||||
if (!deviceToken || !authToken || !webViewRef.current) return;
|
||||
const dt = JSON.stringify(deviceToken);
|
||||
const at = JSON.stringify(authToken);
|
||||
webViewRef.current.injectJavaScript(`
|
||||
(function() {
|
||||
try {
|
||||
fetch('/api/devices/unregister', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + ${at} },
|
||||
body: JSON.stringify({ token: ${dt} })
|
||||
}).catch(function() {});
|
||||
} catch(e) {}
|
||||
})();
|
||||
true;
|
||||
`);
|
||||
}, []);
|
||||
|
||||
const navigateTo = useCallback((path) => {
|
||||
if (!path || !webViewRef.current) return;
|
||||
const url = path.startsWith('http') ? path : APP_URL + path;
|
||||
webViewRef.current.injectJavaScript(
|
||||
`window.location.href = ${JSON.stringify(url)}; true;`
|
||||
);
|
||||
}, []);
|
||||
|
||||
// ── Coordination effect: fires whenever pushyToken or webViewReady changes ──
|
||||
// Ensures registration always happens once BOTH sides are ready,
|
||||
// regardless of which arrives first.
|
||||
useEffect(() => {
|
||||
if (!pushyToken || !webViewReady) return;
|
||||
|
||||
if (pendingAuthToken) {
|
||||
// Login event arrived before pushyToken – replay it now
|
||||
registerDevice(pushyToken, pendingAuthToken);
|
||||
setPendingAuthToken(null);
|
||||
} else {
|
||||
// Normal start: read token from existing session in localStorage
|
||||
registerFromStorage(pushyToken);
|
||||
}
|
||||
}, [pushyToken, webViewReady]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── One-time effect: Pushy setup ─────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
requestNotificationPermission();
|
||||
|
||||
Pushy.register()
|
||||
.then((token) => {
|
||||
console.log('Pushy device token:', token);
|
||||
setPushyToken(token);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Pushy registration error:', err.message);
|
||||
});
|
||||
|
||||
Pushy.setNotificationHandler((data, callback) => {
|
||||
const title = data.title || 'iistwin';
|
||||
const body = data.body || '';
|
||||
if (body) {
|
||||
Alert.alert(title, body, [
|
||||
{ text: 'Открыть', onPress: () => { if (data.url) navigateTo(data.url); } },
|
||||
{ text: 'Закрыть', style: 'cancel' },
|
||||
]);
|
||||
}
|
||||
callback();
|
||||
});
|
||||
|
||||
Pushy.setNotificationClickListener((data) => {
|
||||
if (data.url) {
|
||||
if (webViewRef.current) {
|
||||
navigateTo(data.url);
|
||||
} else {
|
||||
setPendingUrl(data.url);
|
||||
}
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// ── Back button ───────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const onBackPress = () => {
|
||||
if (canGoBack && webViewRef.current) {
|
||||
webViewRef.current.goBack();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const sub = BackHandler.addEventListener('hardwareBackPress', onBackPress);
|
||||
return () => sub.remove();
|
||||
}, [canGoBack]);
|
||||
|
||||
// ── WebView callbacks ─────────────────────────────────────────────────────
|
||||
|
||||
const handleLoadEnd = () => {
|
||||
setWebViewReady(true); // triggers coordination effect above
|
||||
if (pendingUrl) {
|
||||
navigateTo(pendingUrl);
|
||||
setPendingUrl(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMessage = useCallback((event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.nativeEvent.data);
|
||||
|
||||
if (msg.type === 'login' && msg.token) {
|
||||
if (pushyToken) {
|
||||
// Both pieces ready — register immediately
|
||||
registerDevice(pushyToken, msg.token);
|
||||
} else {
|
||||
// pushyToken not yet available — cache for coordination effect
|
||||
setPendingAuthToken(msg.token);
|
||||
}
|
||||
} else if (msg.type === 'logout' && msg.token && pushyToken) {
|
||||
unregisterDevice(pushyToken, msg.token);
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore malformed messages
|
||||
}
|
||||
}, [pushyToken, registerDevice, unregisterDevice]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<StatusBar backgroundColor="#3b82f6" barStyle="light-content" translucent={false} />
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
source={{ uri: APP_URL }}
|
||||
style={styles.webview}
|
||||
javaScriptEnabled={true}
|
||||
domStorageEnabled={true}
|
||||
startInLoadingState={true}
|
||||
sharedCookiesEnabled={true}
|
||||
thirdPartyCookiesEnabled={true}
|
||||
injectedJavaScriptBeforeContentLoaded={BASE_INJECTED_JS}
|
||||
onLoadEnd={handleLoadEnd}
|
||||
onMessage={handleMessage}
|
||||
onNavigationStateChange={(navState) => setCanGoBack(navState.canGoBack)}
|
||||
onPermissionRequest={(request) => { request.grant(request.resources); }}
|
||||
onShouldStartLoadWithRequest={(request) => {
|
||||
try {
|
||||
if (new URL(request.url).origin === APP_ORIGIN) return true;
|
||||
} catch {}
|
||||
Linking.openURL(request.url);
|
||||
return false;
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#3b82f6' },
|
||||
webview: { flex: 1 },
|
||||
});
|
||||
66
mobile/README.md
Normal file
66
mobile/README.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# iistwin — Android APK (Expo EAS)
|
||||
|
||||
Мобильное приложение для Android, собранное через Expo EAS облачный сервис.
|
||||
Это WebView-обёртка над веб-версией — обновления функционала происходят автоматически при деплое.
|
||||
|
||||
**Production URL:** `https://iistwin.ru`
|
||||
**Expo проект:** https://expo.dev/accounts/iisultanov/projects/iistwin
|
||||
|
||||
## Настройка EXPO_TOKEN
|
||||
|
||||
1. Открой https://expo.dev/settings/access-tokens
|
||||
2. Нажми **Create token**, скопируй значение (~40 символов латиницы)
|
||||
3. В Replit: левая панель → 🔒 **Secrets** → добавь `EXPO_TOKEN` = твой токен
|
||||
|
||||
## Сборка APK (одна команда)
|
||||
|
||||
```bash
|
||||
cd mobile && npm run build
|
||||
```
|
||||
|
||||
Команда автоматически:
|
||||
1. Генерирует keystore и `credentials.json` если их нет
|
||||
2. Запускает облачную сборку на серверах Expo (10-20 мин)
|
||||
|
||||
APK доступен после сборки: https://expo.dev/accounts/iisultanov/projects/iistwin/builds
|
||||
|
||||
## Установка APK на Android
|
||||
|
||||
1. Скачай `.apk` с expo.dev → проект → Builds
|
||||
2. На Android: **Настройки → Безопасность → Установка из неизвестных источников**
|
||||
3. Установи APK
|
||||
|
||||
## Настройка паролей подписи (опционально)
|
||||
|
||||
По умолчанию keystore генерируется с паролями по умолчанию.
|
||||
Для кастомных паролей добавь секреты в Replit:
|
||||
- `ANDROID_KEYSTORE_PASSWORD` — пароль keystore
|
||||
- `ANDROID_KEY_PASSWORD` — пароль ключа
|
||||
- `ANDROID_KEY_ALIAS` — алиас ключа (по умолчанию: iistwin)
|
||||
|
||||
## Обновление приложения
|
||||
|
||||
- **Изменения UI, форм, функционала** — автоматически при деплое, APK переустанавливать не нужно
|
||||
- **Изменения нативного кода** (иконка, разрешения) — нужна новая сборка EAS
|
||||
|
||||
## Безопасность
|
||||
|
||||
- `mobile/credentials/` и `mobile/credentials.json` в `.gitignore` — не попадают в VCS
|
||||
- Файлы создаются динамически через `scripts/setup-android-creds.sh`
|
||||
- WebView доступен только для origin: `https://iistwin.ru`
|
||||
- Внешние ссылки открываются в браузере устройства
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
mobile/
|
||||
├── App.js # WebView — только production origin, кнопка "назад"
|
||||
├── app.json # projectId=18e993b2, owner=iisultanov
|
||||
├── eas.json # preview → buildType: apk
|
||||
├── credentials.json.example # Шаблон (не коммитится)
|
||||
├── package.json # npm run build = setup + EAS
|
||||
└── assets/icon.png
|
||||
scripts/
|
||||
├── setup-android-creds.sh # Генерирует keystore и credentials.json
|
||||
└── eas-build.exp # Expect-скрипт для EAS CLI
|
||||
```
|
||||
35
mobile/app.json
Normal file
35
mobile/app.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "iistwin",
|
||||
"slug": "iistwin",
|
||||
"version": "1.1.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"splash": {
|
||||
"backgroundColor": "#3b82f6"
|
||||
},
|
||||
"android": {
|
||||
"package": "com.iistwin.app",
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/icon.png",
|
||||
"backgroundColor": "#3b82f6"
|
||||
},
|
||||
"permissions": [
|
||||
"android.permission.POST_NOTIFICATIONS",
|
||||
"android.permission.RECEIVE_BOOT_COMPLETED",
|
||||
"android.permission.VIBRATE",
|
||||
"android.permission.WAKE_LOCK",
|
||||
"android.permission.INTERNET"
|
||||
]
|
||||
},
|
||||
"owner": "iisultanov",
|
||||
"plugins": [
|
||||
"./plugins/withPushy"
|
||||
],
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "18e993b2-1a80-47b2-b101-1ce02636d705"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
mobile/assets/icon.png
Normal file
BIN
mobile/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.6 KiB |
6
mobile/babel.config.js
Normal file
6
mobile/babel.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = function(api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
};
|
||||
};
|
||||
10
mobile/credentials.json.example
Normal file
10
mobile/credentials.json.example
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"android": {
|
||||
"keystore": {
|
||||
"keystorePath": "credentials/keystore.p12",
|
||||
"keystorePassword": "YOUR_KEYSTORE_PASSWORD",
|
||||
"keyAlias": "workflow",
|
||||
"keyPassword": "YOUR_KEY_PASSWORD"
|
||||
}
|
||||
}
|
||||
}
|
||||
16
mobile/eas.json
Normal file
16
mobile/eas.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 3.0.0",
|
||||
"appVersionSource": "local"
|
||||
},
|
||||
"build": {
|
||||
"preview": {
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"credentialsSource": "local",
|
||||
"image": "ubuntu-22.04-jdk-17-ndk-r21e"
|
||||
},
|
||||
"distribution": "internal"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
mobile/package.json
Normal file
23
mobile/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "iistwin-mobile",
|
||||
"version": "1.1.0",
|
||||
"main": "node_modules/expo/AppEntry.js",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"setup-creds": "bash ../scripts/setup-android-creds.sh",
|
||||
"build": "bash ../scripts/setup-android-creds.sh && EAS_NO_VCS=1 expect ../scripts/eas-build.exp"
|
||||
},
|
||||
"dependencies": {
|
||||
"expo": "~51.0.0",
|
||||
"expo-status-bar": "~1.12.1",
|
||||
"pushy-react-native": "1.0.61",
|
||||
"react": "18.2.0",
|
||||
"react-native": "0.74.5",
|
||||
"react-native-webview": "13.8.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.0"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
78
mobile/plugins/withPushy.js
Normal file
78
mobile/plugins/withPushy.js
Normal file
@@ -0,0 +1,78 @@
|
||||
let withMainApplication, withAndroidManifest;
|
||||
|
||||
try {
|
||||
const cp = require('@expo/config-plugins');
|
||||
withMainApplication = cp.withMainApplication;
|
||||
withAndroidManifest = cp.withAndroidManifest;
|
||||
} catch {
|
||||
// @expo/config-plugins not available locally (no mobile/node_modules).
|
||||
// EAS cloud servers install it; just pass through the config unchanged here.
|
||||
module.exports = function withPushy(config) { return config; };
|
||||
return;
|
||||
}
|
||||
|
||||
function withPushyMainApplication(config) {
|
||||
return withMainApplication(config, (config) => {
|
||||
const contents = config.modResults.contents;
|
||||
const filePath = config.modResults.path || '';
|
||||
const isKotlin = filePath.endsWith('.kt');
|
||||
|
||||
const importStatement = isKotlin
|
||||
? 'import me.pushy.sdk.Pushy'
|
||||
: 'import me.pushy.sdk.Pushy;';
|
||||
|
||||
let updated = contents;
|
||||
|
||||
// --- Import injection -----------------------------------------------
|
||||
if (!updated.includes('me.pushy.sdk.Pushy')) {
|
||||
// Match 'import android.app.Application' with or without semicolon
|
||||
const importRegex = /(import android\.app\.Application[;]?)/;
|
||||
if (importRegex.test(updated)) {
|
||||
updated = updated.replace(importRegex, `$1\n${importStatement}`);
|
||||
} else {
|
||||
console.warn('[withPushy] Could not find import anchor in MainApplication; Pushy import not injected.');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Pushy.listen() injection ----------------------------------------
|
||||
if (!updated.includes('Pushy.listen(this)')) {
|
||||
// Match 'super.onCreate()' or 'super.onCreate();' with leading whitespace.
|
||||
// Use [ \t]* (not \s*) to avoid consuming the newline after the call,
|
||||
// which would merge the next statement onto the same line and break Kotlin syntax.
|
||||
const onCreateRegex = /([ \t]*super\.onCreate\(\)[ \t]*;?)/;
|
||||
if (onCreateRegex.test(updated)) {
|
||||
const callStatement = isKotlin
|
||||
? '\n Pushy.listen(this)'
|
||||
: '\n Pushy.listen(this);';
|
||||
// Replace only the first occurrence (the one inside onCreate)
|
||||
updated = updated.replace(onCreateRegex, `$1${callStatement}`);
|
||||
} else {
|
||||
console.warn('[withPushy] Could not find super.onCreate() in MainApplication; Pushy.listen not injected.');
|
||||
}
|
||||
}
|
||||
|
||||
config.modResults.contents = updated;
|
||||
return config;
|
||||
});
|
||||
}
|
||||
|
||||
function withPushyManifest(config) {
|
||||
return withAndroidManifest(config, (config) => {
|
||||
const manifest = config.modResults.manifest;
|
||||
if (!manifest['uses-permission']) {
|
||||
manifest['uses-permission'] = [];
|
||||
}
|
||||
const perms = manifest['uses-permission'];
|
||||
const wakeLock = 'android.permission.WAKE_LOCK';
|
||||
if (!perms.some((p) => p.$?.['android:name'] === wakeLock)) {
|
||||
perms.push({ $: { 'android:name': wakeLock } });
|
||||
}
|
||||
return config;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = function withPushy(config) {
|
||||
config = withPushyMainApplication(config);
|
||||
config = withPushyManifest(config);
|
||||
return config;
|
||||
};
|
||||
Reference in New Issue
Block a user