diff --git a/rn/app/App.js b/rn/app/App.js index ac0e62e..8afd4bd 100644 --- a/rn/app/App.js +++ b/rn/app/App.js @@ -13,8 +13,9 @@ const AppMessagingProvider = require('./src/components/layout/AppMessagingProvid const AppNavigationProvider = require('./src/components/layout/AppNavigationProvider').AppNavigationProvider; //Провайдер навигации const AppModeProvider = require('./src/components/layout/AppModeProvider').AppModeProvider; //Провайдер режима работы const AppLocalDbProvider = require('./src/components/layout/AppLocalDbProvider').AppLocalDbProvider; //Провайдер локальной БД +const AppAuthProvider = require('./src/components/layout/AppAuthProvider').AppAuthProvider; //Провайдер авторизации const AppServerProvider = require('./src/components/layout/AppServerProvider').AppServerProvider; //Провайдер сервера -const AppPreTripInspectionsProvider = require('./src/components/layout/AppPreTripInspectionsProvider').AppPreTripInspectionsProvider; //Провайдер осмотров +const { AppIdleProvider } = require('./src/components/layout/AppIdleProvider'); //Провайдер простоя const AppRoot = require('./src/components/layout/AppRoot'); //Корневой layout приложения //----------- @@ -28,13 +29,15 @@ function App() { - - - - - - - + + + + + + + + + diff --git a/rn/app/android/app/build.gradle b/rn/app/android/app/build.gradle index 0510375..c855596 100644 --- a/rn/app/android/app/build.gradle +++ b/rn/app/android/app/build.gradle @@ -77,9 +77,9 @@ android { buildToolsVersion rootProject.ext.buildToolsVersion compileSdk rootProject.ext.compileSdkVersion - namespace "com.app" + namespace "com.parus_pre_trip_inspections" defaultConfig { - applicationId "com.app" + applicationId "com.parus_pre_trip_inspections" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 1 @@ -117,3 +117,19 @@ dependencies { implementation jscFlavor } } + +// Обход: перед native clean нужны сгенерированные codegen-папки автолинкованных библиотек +afterEvaluate { + def codegenDeps = [] + rootProject.subprojects.each { sub -> + def codegenTask = sub.tasks.findByName("generateCodegenArtifactsFromSchema") + if (codegenTask != null) { + codegenDeps.add(codegenTask) + } + } + tasks.configureEach { task -> + if (task.name.startsWith("externalNativeBuild") && task.name.contains("Clean")) { + codegenDeps.each { task.dependsOn(it) } + } + } +} diff --git a/rn/app/android/app/src/main/AndroidManifest.xml b/rn/app/android/app/src/main/AndroidManifest.xml index a971b7c..0aa2155 100644 --- a/rn/app/android/app/src/main/AndroidManifest.xml +++ b/rn/app/android/app/src/main/AndroidManifest.xml @@ -11,7 +11,8 @@ android:allowBackup="false" android:theme="@style/AppTheme" android:usesCleartextTraffic="${usesCleartextTraffic}" - android:supportsRtl="true"> + android:supportsRtl="true" + android:requestLegacyExternalStorage="true"> (android.R.id.content) ?: return + setNotFocusableRecursive(content) + } catch (_: Exception) {} + } + + private fun setNotFocusableRecursive(view: View) { + val name = view.javaClass.simpleName + if (name.contains("Camera", ignoreCase = true) || + name.contains("Preview", ignoreCase = true) || + name.contains("TextureView", ignoreCase = true) || + name.contains("SurfaceView", ignoreCase = true) + ) { + view.setFocusable(false) + view.setFocusableInTouchMode(false) + } + if (view is ViewGroup) { + for (i in 0 until view.childCount) { + setNotFocusableRecursive(view.getChildAt(i)) + } + } + } +} diff --git a/rn/app/android/app/src/main/java/com/parus_pre_trip_inspections/HardwareScannerPackage.kt b/rn/app/android/app/src/main/java/com/parus_pre_trip_inspections/HardwareScannerPackage.kt new file mode 100644 index 0000000..3641bdd --- /dev/null +++ b/rn/app/android/app/src/main/java/com/parus_pre_trip_inspections/HardwareScannerPackage.kt @@ -0,0 +1,23 @@ +/* + Предрейсовые осмотры - мобильное приложение + Регистрация нативного модуля встроенного сканера +*/ + +package com.parus_pre_trip_inspections + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +//Пакет нативного модуля встроенного сканера +class HardwareScannerPackage : ReactPackage { + + override fun createNativeModules(reactContext: ReactApplicationContext): List { + return listOf(HardwareScannerModule(reactContext)) + } + + override fun createViewManagers(reactContext: ReactApplicationContext): List> { + return emptyList() + } +} diff --git a/rn/app/android/app/src/main/java/com/parus_pre_trip_inspections/MainActivity.kt b/rn/app/android/app/src/main/java/com/parus_pre_trip_inspections/MainActivity.kt new file mode 100644 index 0000000..e5fda3d --- /dev/null +++ b/rn/app/android/app/src/main/java/com/parus_pre_trip_inspections/MainActivity.kt @@ -0,0 +1,221 @@ +/* + Предрейсовые осмотры - мобильное приложение + Главная Activity: встроенный сканер — сессия по триггеру, ENTER в буфер как \n, отправка по таймауту неактивности +*/ + +package com.parus_pre_trip_inspections + +import android.os.Handler +import android.os.Looper +import android.view.KeyCharacterMap +import android.view.KeyEvent +import android.view.Window +import com.facebook.react.ReactActivity +import com.facebook.react.ReactActivityDelegate +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled +import com.facebook.react.defaults.DefaultReactActivityDelegate + +class MainActivity : ReactActivity() { + + private val scannerBuffer = StringBuilder() + private var scannerSessionActive = false + private var lastScannerKeyTime = 0L + private var lastBarcodeSentTime = 0L + private var scannerDeviceId: Int? = null + private var dispatchFromWindowCallback = false + + private val mainHandler = Handler(Looper.getMainLooper()) + + private fun normalizeBarcodeForSend(raw: String): String = raw.trim() + + override fun onCreate(savedInstanceState: android.os.Bundle?) { + setDefaultUncaughtExceptionHandler() + super.onCreate(savedInstanceState) + installScannerKeyInterceptor() + } + + private fun installScannerKeyInterceptor() { + val w = window ?: return + val orig = w.callback ?: return + w.callback = object : Window.Callback by orig { + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (handleScannerKeyEvent(event)) return true + dispatchFromWindowCallback = true + return try { + orig.dispatchKeyEvent(event) + } finally { + dispatchFromWindowCallback = false + } + } + } + } + + private fun setDefaultUncaughtExceptionHandler() { + val prev = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { t, e -> + prev?.uncaughtException(t, e) + } + } + private val sessionIdleRunnable = Runnable { + if (!scannerSessionActive) return@Runnable + if (scannerBuffer.isEmpty()) { + scannerSessionActive = false + return@Runnable + } + val barcode = normalizeBarcodeForSend(scannerBuffer.toString()) + scannerBuffer.setLength(0) + scannerSessionActive = false + lastBarcodeSentTime = System.currentTimeMillis() + sendBarcodeToReactNative(barcode) + } + + companion object { + private const val LOG_TAG = "PTI_HwScan" + private const val SCANNER_SESSION_TIMEOUT_MS = 2000L + private const val SESSION_IDLE_SEND_MS = 1000L + private const val POST_BARCODE_SILENT_MS = 800L + + private val TRIGGER_KEY_CODES = setOf( + KeyEvent.KEYCODE_NUMPAD_DOT, + 139, 140, 141, 142, 143, 144, + KeyEvent.KEYCODE_ASSIST + ) + } + + private fun isTriggerKey(keyCode: Int, event: KeyEvent): Boolean { + if (event.getUnicodeChar(event.metaState) != 0) return false + return keyCode in TRIGGER_KEY_CODES + } + + private fun isPrintableBarcodeChar(code: Int): Boolean = + (code in 32..126) || (code in 160..255) || (code in 0x400..0x4FF) || (code in 0x370..0x3FF) + + override fun getMainComponentName(): String = "Pre-Trip_Inspections" + + override fun createReactActivityDelegate(): ReactActivityDelegate = + DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled) + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + //Если событие пришло через установленный Window.Callback, здесь не повторяем обработку. + if (dispatchFromWindowCallback) return super.dispatchKeyEvent(event) + if (handleScannerKeyEvent(event)) return true + return super.dispatchKeyEvent(event) + } + + private fun handleScannerKeyEvent(event: KeyEvent): Boolean { + val now = System.currentTimeMillis() + val keyCode = event.keyCode + + if (lastBarcodeSentTime != 0L && (now - lastBarcodeSentTime) < POST_BARCODE_SILENT_MS) return true + + if (scannerSessionActive) { + if (event.action != KeyEvent.ACTION_DOWN) return true + if (keyCode == KeyEvent.KEYCODE_BACK) { + mainHandler.removeCallbacks(sessionIdleRunnable) + scannerSessionActive = false + scannerBuffer.setLength(0) + return false + } + if (lastScannerKeyTime != 0L && now - lastScannerKeyTime > SCANNER_SESSION_TIMEOUT_MS) { + mainHandler.removeCallbacks(sessionIdleRunnable) + if (scannerBuffer.isNotEmpty()) { + val barcode = normalizeBarcodeForSend(scannerBuffer.toString()) + scannerBuffer.setLength(0) + scannerSessionActive = false + lastBarcodeSentTime = now + sendBarcodeToReactNative(barcode) + } else scannerSessionActive = false + return true + } + lastScannerKeyTime = now + when { + isTriggerKey(keyCode, event) -> { } + keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER -> { + scannerBuffer.append('\n') + mainHandler.postDelayed(sessionIdleRunnable, SESSION_IDLE_SEND_MS) + } + else -> { + val appended = appendKeyToBuffer(event) + if (appended) mainHandler.postDelayed(sessionIdleRunnable, SESSION_IDLE_SEND_MS) + } + } + return true + } + + val fromScanner = when { + event.deviceId == -1 -> false + scannerDeviceId != null -> event.deviceId == scannerDeviceId + isTriggerKey(keyCode, event) -> true + else -> false + } + if (!fromScanner) { + mainHandler.removeCallbacks(sessionIdleRunnable) + if (scannerSessionActive) { + scannerSessionActive = false + scannerBuffer.setLength(0) + } + return false + } + + if (event.action != KeyEvent.ACTION_DOWN) return true + if (keyCode == KeyEvent.KEYCODE_BACK) return false + + mainHandler.removeCallbacks(sessionIdleRunnable) + scannerSessionActive = true + scannerBuffer.setLength(0) + lastScannerKeyTime = now + if (scannerDeviceId == null && event.deviceId > 0) scannerDeviceId = event.deviceId + if (isTriggerKey(keyCode, event)) { + HardwareScannerModule.emitTriggerFromActivity(applicationContext) + return true + } + val appended = appendKeyToBuffer(event) + if (appended) mainHandler.postDelayed(sessionIdleRunnable, SESSION_IDLE_SEND_MS) + return true + } + + private fun appendKeyToBuffer(event: KeyEvent): Boolean { + val keyCode = event.keyCode + if (keyCode in TRIGGER_KEY_CODES && event.getUnicodeChar(event.metaState) == 0) return false + var unicode = event.getUnicodeChar(event.metaState) + if (unicode == 0 && event.action == KeyEvent.ACTION_DOWN) { + try { + val map = KeyCharacterMap.load(event.deviceId) + unicode = map.get(keyCode, event.metaState) + } catch (_: Exception) {} + } + if (unicode != 0 && Character.isBmpCodePoint(unicode)) { + scannerBuffer.append(unicode.toChar()) + return true + } + if (keyCode == KeyEvent.KEYCODE_UNKNOWN || keyCode == 0) { + val label = event.displayLabel + val labelCode = label.code + if (labelCode != 0 && isPrintableBarcodeChar(labelCode)) { + scannerBuffer.append(label) + return true + } + } + when (keyCode) { + KeyEvent.KEYCODE_0, KeyEvent.KEYCODE_1, KeyEvent.KEYCODE_2, KeyEvent.KEYCODE_3, KeyEvent.KEYCODE_4, + KeyEvent.KEYCODE_5, KeyEvent.KEYCODE_6, KeyEvent.KEYCODE_7, KeyEvent.KEYCODE_8, KeyEvent.KEYCODE_9 -> + scannerBuffer.append('0' + (keyCode - KeyEvent.KEYCODE_0)) + KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.KEYCODE_NUMPAD_1, KeyEvent.KEYCODE_NUMPAD_2, KeyEvent.KEYCODE_NUMPAD_3, + KeyEvent.KEYCODE_NUMPAD_4, KeyEvent.KEYCODE_NUMPAD_5, KeyEvent.KEYCODE_NUMPAD_6, KeyEvent.KEYCODE_NUMPAD_7, + KeyEvent.KEYCODE_NUMPAD_8, KeyEvent.KEYCODE_NUMPAD_9 -> + scannerBuffer.append('0' + (keyCode - KeyEvent.KEYCODE_NUMPAD_0)) + KeyEvent.KEYCODE_MINUS, KeyEvent.KEYCODE_NUMPAD_SUBTRACT -> scannerBuffer.append('-') + KeyEvent.KEYCODE_SLASH -> scannerBuffer.append('/') + KeyEvent.KEYCODE_PERIOD, KeyEvent.KEYCODE_NUMPAD_DOT -> + if (event.getUnicodeChar(event.metaState) != 0) scannerBuffer.append('.') + in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z -> scannerBuffer.append('A' + (keyCode - KeyEvent.KEYCODE_A)) + KeyEvent.KEYCODE_SPACE -> scannerBuffer.append(' ') + else -> return false + } + return true + } + + private fun sendBarcodeToReactNative(barcode: String) { + HardwareScannerModule.emitBarcodeToJs(barcode) + } +} diff --git a/rn/app/android/app/src/main/java/com/app/MainApplication.kt b/rn/app/android/app/src/main/java/com/parus_pre_trip_inspections/MainApplication.kt similarity index 80% rename from rn/app/android/app/src/main/java/com/app/MainApplication.kt rename to rn/app/android/app/src/main/java/com/parus_pre_trip_inspections/MainApplication.kt index 05a9022..bfc7f51 100644 --- a/rn/app/android/app/src/main/java/com/app/MainApplication.kt +++ b/rn/app/android/app/src/main/java/com/parus_pre_trip_inspections/MainApplication.kt @@ -1,4 +1,4 @@ -package com.app +package com.parus_pre_trip_inspections import android.app.Application import com.facebook.react.PackageList @@ -14,8 +14,7 @@ class MainApplication : Application(), ReactApplication { context = applicationContext, packageList = PackageList(this).packages.apply { - // Packages that cannot be autolinked yet can be added manually here, for example: - // add(MyReactNativePackage()) + add(HardwareScannerPackage()) }, ) } diff --git a/rn/app/index.js b/rn/app/index.js index b4a1100..d484a11 100644 --- a/rn/app/index.js +++ b/rn/app/index.js @@ -11,10 +11,6 @@ const { AppRegistry } = require("react-native"); //Регистрация кор const App = require("./App"); //Корневой компонент приложения const { name: appName } = require("./app.json"); //Имя приложения -//----------- -//Тело модуля -//----------- - //Регистрация корневого компонента AppRegistry.registerComponent(appName, () => App); diff --git a/rn/app/react-native.config.js b/rn/app/react-native.config.js new file mode 100644 index 0000000..3677f8b --- /dev/null +++ b/rn/app/react-native.config.js @@ -0,0 +1,7 @@ +module.exports = { + project: { + android: { + packageName: 'com.parus_pre_trip_inspections', + }, + }, +}; diff --git a/rn/app/scripts/generate-app-icons.js b/rn/app/scripts/generate-app-icons.js index 9a862bf..5c31b57 100644 --- a/rn/app/scripts/generate-app-icons.js +++ b/rn/app/scripts/generate-app-icons.js @@ -159,7 +159,7 @@ function readPng(filePath) { const inflated = zlib.inflateSync(concatenated, { chunkSize: rawSize + 1024 }); if (inflated.length < rawSize) throw new Error('PNG decompressed size too small'); - // Снятие фильтров строк и приведение к RGBA + //Снятие фильтров строк и приведение к RGBA const rgba = new Uint8Array(width * height * 4); const stride = width * bytesPerPixel; let prev = null; @@ -251,7 +251,7 @@ function writePngBuffer(width, height, rgba) { const rawRows = []; for (let y = 0; y < height; y++) { const row = Buffer.alloc(1 + stride); - row[0] = 0; // фильтр None + row[0] = 0; //фильтр None for (let x = 0; x < width; x++) { const i = (y * width + x) * 4; row[1 + x * 4] = rgba[i]; @@ -278,11 +278,11 @@ function writePngBuffer(width, height, rgba) { const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4); - ihdr[8] = 8; // глубина цвета - ihdr[9] = 6; // тип цвета RGBA - ihdr[10] = 0; // сжатие deflate - ihdr[11] = 0; // фильтр адаптивный - ihdr[12] = 0; // без чересстрочности + ihdr[8] = 8; //глубина цвета + ihdr[9] = 6; //тип цвета RGBA + ihdr[10] = 0; //сжатие deflate + ihdr[11] = 0; //фильтр адаптивный + ihdr[12] = 0; //без чересстрочности const out = [PNG_SIGNATURE]; writeChunk(out, 'IHDR', ihdr); diff --git a/rn/app/src/components/common/AppButton.js b/rn/app/src/components/common/AppButton.js index 666d9c2..df5e5e7 100644 --- a/rn/app/src/components/common/AppButton.js +++ b/rn/app/src/components/common/AppButton.js @@ -24,7 +24,7 @@ function getAppButtonPressableStyle(stylesRef, disabled, style) { } //Общая кнопка приложения -function AppButton({ title, onPress, disabled = false, style, textStyle }) { +function AppButton({ title, onPress, disabled = false, style, textStyle, focusable }) { const handlePress = React.useCallback(() => { if (!disabled && typeof onPress === 'function') onPress(); }, [disabled, onPress]); @@ -32,7 +32,7 @@ function AppButton({ title, onPress, disabled = false, style, textStyle }) { const getPressableStyle = React.useMemo(() => getAppButtonPressableStyle(styles, disabled, style), [disabled, style]); return ( - + {title} diff --git a/rn/app/src/components/common/AppInput.js b/rn/app/src/components/common/AppInput.js index c6a5072..5254367 100644 --- a/rn/app/src/components/common/AppInput.js +++ b/rn/app/src/components/common/AppInput.js @@ -32,21 +32,31 @@ const AppInput = React.forwardRef(function AppInput( style, inputStyle, labelStyle, + onFocus: onFocusProp, + onBlur: onBlurProp, ...restProps }, ref ) { const [isFocused, setIsFocused] = React.useState(false); - //Обработчик фокуса - const handleFocus = React.useCallback(() => { - setIsFocused(true); - }, []); + //Обработчик фокуса (внутренний + опциональный внешний) + const handleFocus = React.useCallback( + e => { + setIsFocused(true); + if (typeof onFocusProp === 'function') onFocusProp(e); + }, + [onFocusProp] + ); - //Обработчик потери фокуса - const handleBlur = React.useCallback(() => { - setIsFocused(false); - }, []); + //Обработчик потери фокуса (внутренний + опциональный внешний) + const handleBlur = React.useCallback( + e => { + setIsFocused(false); + if (typeof onBlurProp === 'function') onBlurProp(e); + }, + [onBlurProp] + ); return ( diff --git a/rn/app/src/components/common/AppMessage.js b/rn/app/src/components/common/AppMessage.js index efc634e..796fa9a 100644 --- a/rn/app/src/components/common/AppMessage.js +++ b/rn/app/src/components/common/AppMessage.js @@ -23,6 +23,28 @@ function getMessageButtonPressableStyle(stylesRef, buttonStyle) { }; } +//Формирование массива кнопок сообщения +function getMessageButtonElements(buttons, handleClose) { + if (!Array.isArray(buttons) || buttons.length === 0) { + return []; + } + const elements = []; + for (let i = 0; i < buttons.length; i++) { + const btn = buttons[i]; + elements.push( + + ); + } + return elements; +} + //Кнопка сообщения function AppMessageButton({ title, onPress, onDismiss, buttonStyle, textStyle }) { //Обработчик нажатия - вызывает onPress и закрывает диалог @@ -95,20 +117,7 @@ function AppMessage({ {message} - {hasButtons ? ( - - {buttons.map(btn => ( - - ))} - - ) : null} + {hasButtons ? {getMessageButtonElements(buttons, handleClose)} : null} diff --git a/rn/app/src/components/common/CopyButton.js b/rn/app/src/components/common/CopyButton.js index 485d1ef..7d2d25c 100644 --- a/rn/app/src/components/common/CopyButton.js +++ b/rn/app/src/components/common/CopyButton.js @@ -48,8 +48,6 @@ function CopyButton({ value, onCopy, onError, disabled = false, style }) { onCopy(value); } } catch (error) { - console.error('Ошибка копирования в буфер:', error); - if (typeof onError === 'function') { onError(error); } diff --git a/rn/app/src/components/common/InputDialog.js b/rn/app/src/components/common/InputDialog.js index 1329008..1bf6582 100644 --- a/rn/app/src/components/common/InputDialog.js +++ b/rn/app/src/components/common/InputDialog.js @@ -8,44 +8,100 @@ //--------------------- const React = require('react'); //React -const { Modal, View, TextInput, Pressable } = require('react-native'); //Базовые компоненты +const { Modal, View, TextInput, Pressable, Platform, StyleSheet, BackHandler } = require('react-native'); //Базовые компоненты const AppText = require('./AppText'); //Общий текстовый компонент const AppButton = require('./AppButton'); //Кнопка const styles = require('../../styles/common/InputDialog.styles'); //Стили диалога +const OVERLAY_Z = 9999; + //----------- //Тело модуля //----------- //Модальное окно с полем ввода -function InputDialog({ - visible, - title = 'Ввод данных', - label, - value = '', - placeholder, - keyboardType = 'default', - autoCapitalize = 'none', - confirmText = 'Сохранить', - cancelText = 'Отмена', - onConfirm, - onCancel, - validator, - errorMessage -}) { +function InputDialog( + { + visible, + title = 'Ввод данных', + label, + value = '', + placeholder, + keyboardType = 'default', + autoCapitalize = 'none', + confirmText = 'Сохранить', + cancelText = 'Отмена', + onConfirm, + onCancel, + validator, + errorMessage + }, + ref +) { //Локальное значение для редактирования const [inputValue, setInputValue] = React.useState(value); + const inputRef = React.useRef(null); + //На Android поле ввода изначально не фокусируемо, чтобы нативный слой видел «нет фокуса» и перехватывал сканер с первого символа (как на остальных экранах) + const [inputFocusable, setInputFocusable] = React.useState(Platform.OS !== 'android'); + + const focusFirstEditableInput = React.useCallback(() => { + if (inputRef.current && typeof inputRef.current.focus === 'function') { + inputRef.current.focus(); + } + }, []); + + React.useImperativeHandle( + ref, + function exposeScannerApi() { + return { + setValueFromScanner(val) { + setInputValue(val != null ? String(val) : ''); + if (Platform.OS === 'android') setInputFocusable(true); + focusFirstEditableInput(); + } + }; + }, + [focusFirstEditableInput] + ); + + //После включения фокусируемости на Android — ставим фокус на поле (после setValueFromScanner) + React.useEffect( + function focusWhenFocusable() { + if (Platform.OS !== 'android' || !inputFocusable) return; + focusFirstEditableInput(); + }, + [inputFocusable, focusFirstEditableInput] + ); const [error, setError] = React.useState(''); const [isFocused, setIsFocused] = React.useState(false); - //Сброс значения при открытии диалога + //Сброс значения и фокусируемости только при открытии диалога + const prevVisibleRef = React.useRef(false); React.useEffect(() => { - if (visible) { + const justOpened = visible && !prevVisibleRef.current; + prevVisibleRef.current = visible; + if (justOpened) { setInputValue(value); setError(''); + if (Platform.OS === 'android') setInputFocusable(false); } }, [visible, value]); + //Кнопка «Назад» на Android при overlay (без Modal) закрывает диалог + React.useEffect( + function backHandler() { + if (Platform.OS !== 'android' || !visible) return; + const sub = BackHandler.addEventListener('hardwareBackPress', function onBack() { + handleCancel(); + return true; + }); + return function remove() { + sub.remove(); + }; + }, + [visible, handleCancel] + ); + //Обработчик фокуса const handleFocus = React.useCallback(() => { setIsFocused(true); @@ -97,54 +153,88 @@ function InputDialog({ handleCancel(); }, [handleCancel]); - return ( - - - - - {title} - - × - - + const preventKeyActions = Platform.OS === 'android'; + const closePressableProps = preventKeyActions ? { focusable: false } : {}; + const cancelButtonFocusable = preventKeyActions ? false : undefined; + const confirmButtonFocusable = preventKeyActions ? false : undefined; - - {label ? ( - - {label} - - ) : null} + const dialogContent = ( + + + {title} + + × + + - + + {label ? ( + + {label} + + ) : null} - {error ? ( - - {error} - - ) : null} - + - - - - + {error ? ( + + {error} + + ) : null} + + + + + + + + ); + + if (!visible) return null; + + if (Platform.OS === 'android') { + return ( + + + {dialogContent} + ); + } + + return ( + + {dialogContent} ); } @@ -153,4 +243,4 @@ function InputDialog({ //Интерфейс модуля //---------------- -module.exports = InputDialog; +module.exports = React.forwardRef(InputDialog); diff --git a/rn/app/src/components/common/PasswordInput.js b/rn/app/src/components/common/PasswordInput.js index 7a118f3..555f657 100644 --- a/rn/app/src/components/common/PasswordInput.js +++ b/rn/app/src/components/common/PasswordInput.js @@ -31,6 +31,8 @@ const PasswordInput = React.forwardRef(function PasswordInput( style, inputStyle, labelStyle, + onFocus: onFocusProp, + onBlur: onBlurProp, ...restProps }, ref @@ -50,15 +52,23 @@ const PasswordInput = React.forwardRef(function PasswordInput( [ref] ); - //Обработчик фокуса - const handleFocus = React.useCallback(() => { - setIsFocused(true); - }, []); + //Обработчик фокуса (внутренний + опциональный внешний) + const handleFocus = React.useCallback( + e => { + setIsFocused(true); + if (typeof onFocusProp === 'function') onFocusProp(e); + }, + [onFocusProp] + ); - //Обработчик потери фокуса - const handleBlur = React.useCallback(() => { - setIsFocused(false); - }, []); + //Обработчик потери фокуса (внутренний + опциональный внешний) + const handleBlur = React.useCallback( + e => { + setIsFocused(false); + if (typeof onBlurProp === 'function') onBlurProp(e); + }, + [onBlurProp] + ); //Обработчик переключения видимости пароля const handleTogglePassword = React.useCallback(() => { diff --git a/rn/app/src/components/layout/AppIdleProvider.js b/rn/app/src/components/layout/AppIdleProvider.js new file mode 100644 index 0000000..5fc6b9f --- /dev/null +++ b/rn/app/src/components/layout/AppIdleProvider.js @@ -0,0 +1,63 @@ +/* + Предрейсовые осмотры - мобильное приложение + Провайдер отслеживания простоя +*/ + +//--------------------- +//Подключение библиотек +//--------------------- + +const React = require('react'); //React +const { View } = require('react-native'); //Базовые компоненты +const useIdleTimeout = require('../../hooks/useIdleTimeout'); //Хук простоя +const styles = require('../../styles/layout/AppIdleProvider.styles'); //Стили обёртки + +//----------- +//Тело модуля +//----------- + +//Контекст простоя (reportActivity для сброса таймера) +const AppIdleContext = React.createContext(null); + +//Провайдер простоя: оборачивает приложение и при отсутствии активности выполняет выход +function AppIdleProvider({ children }) { + const { reportActivity } = useIdleTimeout(); + + const value = React.useMemo( + () => ({ + reportActivity + }), + [reportActivity] + ); + + //Обёртка по touch сбрасывает таймер простоя + const handleTouch = React.useCallback(() => { + reportActivity(); + }, [reportActivity]); + + return ( + + + {children} + + + ); +} + +//Хук контекста простоя +function useAppIdleContext() { + const context = React.useContext(AppIdleContext); + if (context == null) { + throw new Error('useAppIdleContext должен использоваться внутри AppIdleProvider'); + } + return context; +} + +//---------------- +//Интерфейс модуля +//---------------- + +module.exports = { + AppIdleProvider, + useAppIdleContext +}; diff --git a/rn/app/src/components/layout/AppLocalDbProvider.js b/rn/app/src/components/layout/AppLocalDbProvider.js index 1a9ab46..b855bdf 100644 --- a/rn/app/src/components/layout/AppLocalDbProvider.js +++ b/rn/app/src/components/layout/AppLocalDbProvider.js @@ -25,16 +25,12 @@ function AppLocalDbProvider({ children }) { const value = React.useMemo( () => ({ isDbReady: api.isDbReady, - inspections: api.inspections, error: api.error, - loadInspections: api.loadInspections, - saveInspection: api.saveInspection, getSetting: api.getSetting, setSetting: api.setSetting, deleteSetting: api.deleteSetting, getAllSettings: api.getAllSettings, clearSettings: api.clearSettings, - clearInspections: api.clearInspections, vacuum: api.vacuum, checkTableExists: api.checkTableExists, setAuthSession: api.setAuthSession, @@ -43,16 +39,12 @@ function AppLocalDbProvider({ children }) { }), [ api.isDbReady, - api.inspections, api.error, - api.loadInspections, - api.saveInspection, api.getSetting, api.setSetting, api.deleteSetting, api.getAllSettings, api.clearSettings, - api.clearInspections, api.vacuum, api.checkTableExists, api.setAuthSession, diff --git a/rn/app/src/components/layout/AppRoot.js b/rn/app/src/components/layout/AppRoot.js index 526cc30..08e4d87 100644 --- a/rn/app/src/components/layout/AppRoot.js +++ b/rn/app/src/components/layout/AppRoot.js @@ -11,6 +11,7 @@ const React = require('react'); //React и хуки const { useColorScheme, View } = require('react-native'); //Определение темы устройства и разметка const { SafeAreaProvider } = require('react-native-safe-area-context'); //Провайдер безопасной области const AppShell = require('./AppShell'); //Оболочка приложения +const HardwareScannerProvider = require('./HardwareScannerProvider').HardwareScannerProvider; //Встроенный сканер const styles = require('../../styles/layout/AppRoot.styles'); //Стили корневого layout const { useAppNavigationContext } = require('./AppNavigationProvider'); //Контекст навигации const { useAppAuthContext } = require('./AppAuthProvider'); //Контекст авторизации @@ -54,7 +55,9 @@ function AppRoot() { return ( - + + + ); diff --git a/rn/app/src/components/layout/AppShell.js b/rn/app/src/components/layout/AppShell.js index b364a62..f6f20b2 100644 --- a/rn/app/src/components/layout/AppShell.js +++ b/rn/app/src/components/layout/AppShell.js @@ -11,6 +11,7 @@ const React = require('react'); //React const { StatusBar, Platform } = require('react-native'); //Базовые компоненты const { useAppNavigationContext } = require('./AppNavigationProvider'); //Контекст навигации const { useAppAuthContext } = require('./AppAuthProvider'); //Контекст авторизации +const { useAppIdleContext } = require('./AppIdleProvider'); //Контекст простоя const MainScreen = require('../../screens/MainScreen'); //Главный экран const SettingsScreen = require('../../screens/SettingsScreen'); //Экран настроек const AuthScreen = require('../../screens/AuthScreen'); //Экран авторизации @@ -27,6 +28,12 @@ const styles = require('../../styles/layout/AppShell.styles'); //Стили об function AppShell({ isDarkMode }) { const { currentScreen, SCREENS } = useAppNavigationContext(); const { isStartupSessionCheckInProgress, isLogoutInProgress } = useAppAuthContext(); + const { reportActivity } = useAppIdleContext(); + + //Сброс таймера простоя при смене экрана (навигация считается активностью) + React.useEffect(() => { + reportActivity(); + }, [currentScreen, reportActivity]); const renderScreen = React.useCallback(() => { switch (currentScreen) { diff --git a/rn/app/src/components/layout/HardwareScannerProvider.js b/rn/app/src/components/layout/HardwareScannerProvider.js new file mode 100644 index 0000000..c7e1269 --- /dev/null +++ b/rn/app/src/components/layout/HardwareScannerProvider.js @@ -0,0 +1,97 @@ +/* + Предрейсовые осмотры - мобильное приложение + Провайдер встроенного сканера: маршрутизация данных по текущему экрану +*/ + +//--------------------- +//Подключение библиотек +//--------------------- + +const React = require('react'); +const { useAppNavigationContext } = require('./AppNavigationProvider'); //Контекст навигации +const HardwareScannerBridge = require('../../services/HardwareScannerBridge'); //Мост встроенного сканера + +//--------- +//Контекст +//--------- + +const HardwareScannerContext = React.createContext(null); + +//----------- +//Тело модуля +//----------- + +//Вызов обработчика для текущего экрана при получении данных от встроенного сканера +function dispatchHardwareScan(currentScreen, handlersRef, barcode) { + const handlers = handlersRef.current; + const hasHandler = handlers && typeof handlers[currentScreen] === 'function'; + if (!handlers || !hasHandler) return; + try { + handlers[currentScreen](barcode); + } catch (_err) {} +} + +//Провайдер встроенного сканера +function HardwareScannerProvider({ children }) { + const { currentScreen, SCREENS } = useAppNavigationContext(); + const handlersRef = React.useRef(Object.create(null)); + const currentScreenRef = React.useRef(currentScreen); + const unsubscribeRef = React.useRef(null); + + currentScreenRef.current = currentScreen; + + const registerHandler = React.useCallback(function registerHandler(screen, handler) { + if (!screen || typeof handler !== 'function') return; + handlersRef.current[screen] = handler; + }, []); + + const unregisterHandler = React.useCallback(function unregisterHandler(screen) { + if (!screen) return; + delete handlersRef.current[screen]; + }, []); + + React.useEffect(function subscribeToHardwareScanner() { + const unsubscribe = HardwareScannerBridge.startHardwareListener(function onData(barcode) { + const screen = currentScreenRef.current; + dispatchHardwareScan(screen, handlersRef, barcode); + }); + unsubscribeRef.current = unsubscribe; + return function cleanup() { + if (typeof unsubscribeRef.current === 'function') { + unsubscribeRef.current(); + } + unsubscribeRef.current = null; + }; + }, []); + + const value = React.useMemo( + function getValue() { + return { + registerHandler, + unregisterHandler, + SCREENS + }; + }, + [registerHandler, unregisterHandler, SCREENS] + ); + + return {children}; +} + +//Хук доступа к контексту встроенного сканера +function useHardwareScannerContext() { + const ctx = React.useContext(HardwareScannerContext); + if (!ctx) { + throw new Error('useHardwareScannerContext должен использоваться внутри HardwareScannerProvider'); + } + return ctx; +} + +//---------------- +//Интерфейс модуля +//---------------- + +module.exports = { + HardwareScannerProvider, + useHardwareScannerContext +}; diff --git a/rn/app/src/components/menu/MenuItemIcon.js b/rn/app/src/components/menu/MenuItemIcon.js new file mode 100644 index 0000000..34257c2 --- /dev/null +++ b/rn/app/src/components/menu/MenuItemIcon.js @@ -0,0 +1,136 @@ +/* + Предрейсовые осмотры - мобильное приложение + Компонент иконки пункта бокового меню +*/ + +//--------------------- +//Подключение библиотек +//--------------------- + +const React = require('react'); //React +const { View } = require('react-native'); //Базовые компоненты +const AppText = require('../common/AppText'); //Текст приложения +const { APP_COLORS } = require('../../config/theme'); //Цветовая схема +const { MENU_ITEM_ID_SETTINGS, MENU_ITEM_ID_ABOUT, MENU_ITEM_ID_LOGIN, MENU_ITEM_ID_LOGOUT } = require('../../config/menuItemIds'); //ID пунктов меню +const { responsiveSize } = require('../../utils/responsive'); //Адаптивные утилиты +const styles = require('../../styles/menu/MenuItemIcon.styles'); //Стили иконки + +//----------- +//Тело модуля +//----------- + +//Количество зубцов шестерёнки и угол шага (градусы) +const SETTINGS_TEETH_COUNT = 6; +const SETTINGS_TEETH_ANGLE_STEP = 360 / SETTINGS_TEETH_COUNT; + +//Формирование массива элементов зубцов шестерёнки +function getTeethViews(teeth, iconColor) { + const views = []; + for (let index = 0; index < teeth.length; index++) { + const angle = teeth[index]; + views.push( + + ); + } + return views; +} + +//Иконка «Настройки» (шестерёнка) +function SettingsIcon({ color }) { + const iconColor = color ?? APP_COLORS.textPrimary; + + const teeth = React.useMemo(() => { + return Array.from({ length: SETTINGS_TEETH_COUNT }, (_, i) => i * SETTINGS_TEETH_ANGLE_STEP); + }, []); + + const toothViews = React.useMemo(() => getTeethViews(teeth, iconColor), [teeth, iconColor]); + + return ( + + + {toothViews} + + ); +} + +//Иконка «О приложении» (информация) +function AboutIcon({ color }) { + const iconColor = color ?? APP_COLORS.textPrimary; + + return ( + + + i + + + ); +} + +//Иконка «Вход» +function LoginIcon({ color }) { + const iconColor = color ?? APP_COLORS.textPrimary; + + return ( + + + + + + ); +} + +//Иконка «Выход» +function LogoutIcon({ color }) { + const iconColor = color ?? APP_COLORS.textPrimary; + + return ( + + + + + + ); +} + +//Рендер иконки по имени пункта меню +function renderIconByName(name, color) { + switch (name) { + case MENU_ITEM_ID_SETTINGS: + return ; + case MENU_ITEM_ID_ABOUT: + return ; + case MENU_ITEM_ID_LOGIN: + return ; + case MENU_ITEM_ID_LOGOUT: + return ; + default: + return null; + } +} + +//Компонент иконки пункта меню +function MenuItemIcon({ name, color }) { + const iconElement = React.useMemo(() => renderIconByName(name, color), [name, color]); + + if (iconElement == null) { + return null; + } + + return iconElement; +} + +//---------------- +//Интерфейс модуля +//---------------- + +module.exports = MenuItemIcon; +module.exports.renderIconByName = renderIconByName; diff --git a/rn/app/src/components/menu/MenuList.js b/rn/app/src/components/menu/MenuList.js index ad2df0c..9f80f01 100644 --- a/rn/app/src/components/menu/MenuList.js +++ b/rn/app/src/components/menu/MenuList.js @@ -10,25 +10,57 @@ const React = require('react'); //React const { ScrollView, View } = require('react-native'); //Базовые компоненты const MenuItem = require('./MenuItem'); //Элемент меню +const MenuItemIcon = require('./MenuItemIcon'); //Иконка пункта меню const EmptyMenu = require('./EmptyMenu'); //Пустое меню const MenuDivider = require('./MenuDivider'); //Разделитель меню +const { MENU_ITEM_ID_DIVIDER } = require('../../config/menuItemIds'); //ID разделителя const styles = require('../../styles/menu/MenuList.styles'); //Стили списка меню //----------- //Тело модуля //----------- +//Иконка для пункта меню +function resolveItemIcon(item) { + if (item.icon != null) { + return item.icon; + } + if (item.id != null && item.id !== MENU_ITEM_ID_DIVIDER) { + return ; + } + return null; +} + +//Формирование массива элементов списка меню +function getMenuListElements(items, onItemPress) { + if (!Array.isArray(items) || items.length === 0) { + return []; + } + const elements = []; + for (let index = 0; index < items.length; index++) { + const item = items[index]; + if (item.type === 'divider') { + elements.push(); + } else { + elements.push(); + } + } + return elements; +} + //Строка меню с элементом function MenuItemRow({ item, index, items, onItemPress }) { const handlePress = React.useCallback(() => { onItemPress(item); }, [item, onItemPress]); + const icon = React.useMemo(() => resolveItemIcon(item), [item]); + return ( getMenuListElements(items, handleItemPress), [items, handleItemPress]); + if (!Array.isArray(items) || items.length === 0) { return ; } return ( - {items.map((item, index) => { - //Элемент-разделитель - if (item.type === 'divider') { - return ; - } - - //Обычный элемент меню - return ; - })} + {listElements} ); } diff --git a/rn/app/src/components/scanner/BarcodeScannerNative.js b/rn/app/src/components/scanner/BarcodeScannerNative.js index b0a15e1..9aaeaaf 100644 --- a/rn/app/src/components/scanner/BarcodeScannerNative.js +++ b/rn/app/src/components/scanner/BarcodeScannerNative.js @@ -8,7 +8,7 @@ //--------------------- const React = require('react'); //React и хуки -const { View } = require('react-native'); //Базовые компоненты +const { View, Platform } = require('react-native'); //Базовые компоненты const { Camera, useCameraDevice, useCodeScanner, useCameraPermission } = require('react-native-vision-camera'); //Камера и сканер кодов const AppText = require('../common/AppText'); //Общий текст const { DEFAULT_CODE_TYPES } = require('../../config/scannerConfig'); //Конфиг сканера @@ -68,10 +68,12 @@ function BarcodeScannerNative({ onScan, isActive = true }) { } }, [hasPermission, requestPermission]); + const noFocusProps = Platform.OS === 'android' ? { focusable: false } : {}; + //Нет устройства камеры if (device == null) { return ( - + {NO_CAMERA_MESSAGE} @@ -82,7 +84,7 @@ function BarcodeScannerNative({ onScan, isActive = true }) { //Нет разрешения на камеру if (!hasPermission) { return ( - + {PERMISSION_DENIED_MESSAGE} @@ -90,8 +92,10 @@ function BarcodeScannerNative({ onScan, isActive = true }) { ); } + const containerProps = Platform.OS === 'android' ? { focusable: false, importantForAccessibility: 'no-hide-descendants' } : {}; + return ( - + ); diff --git a/rn/app/src/components/scanner/CameraPausedOverlay.js b/rn/app/src/components/scanner/CameraPausedOverlay.js new file mode 100644 index 0000000..034a844 --- /dev/null +++ b/rn/app/src/components/scanner/CameraPausedOverlay.js @@ -0,0 +1,34 @@ +/* + Предрейсовые осмотры - мобильное приложение + Оверлей области камеры на паузе +*/ + +//--------------------- +//Подключение библиотек +//--------------------- + +const React = require('react'); //React +const { View } = require('react-native'); //Базовые компоненты +const AppText = require('../common/AppText'); //Текст +const styles = require('../../styles/scanner/CameraPausedOverlay.styles'); //Стили оверлея + +//----------- +//Тело модуля +//----------- + +//Оверлей паузы камеры: серая область с прозрачностью и текстом-подсказкой +function CameraPausedOverlay({ message }) { + return ( + + + {message} + + + ); +} + +//---------------- +//Интерфейс модуля +//---------------- + +module.exports = CameraPausedOverlay; diff --git a/rn/app/src/components/scanner/ScanResultModal.js b/rn/app/src/components/scanner/ScanResultModal.js index c935344..b1d901c 100644 --- a/rn/app/src/components/scanner/ScanResultModal.js +++ b/rn/app/src/components/scanner/ScanResultModal.js @@ -8,10 +8,11 @@ //--------------------- const React = require('react'); //React -const { Modal, View, Pressable } = require('react-native'); //Базовые компоненты +const { Modal, View, Pressable, Platform } = require('react-native'); //Базовые компоненты const AppText = require('../common/AppText'); //Общий текстовый компонент const AppButton = require('../common/AppButton'); //Кнопка const { SCAN_RESULT_MODAL_TITLE, SCAN_RESULT_CLOSE_BUTTON } = require('../../config/messages'); //Сообщения +const { noop } = require('../../utils/noop'); //Пустая функция const styles = require('../../styles/scanner/ScanResultModal.styles'); //Стили модального окна //----------- @@ -37,42 +38,89 @@ function handleClose(onRequestClose) { } } +//Пропсы для элементов, закрывающих модальное окно: на Android отключаем фокус, чтобы Enter от встроенного сканера не вызывал закрытие +function getClosePressableProps(preventKeyClose) { + return preventKeyClose ? { focusable: false } : {}; +} + +//Общий контент окна результата +function ResultContent({ displayType, displayValue, onClosePress, styles: s, preventKeyClose }) { + const backdropProps = getClosePressableProps(preventKeyClose); + const closeButtonProps = getClosePressableProps(preventKeyClose); + const closeButtonFocusable = preventKeyClose ? false : undefined; + + return ( + + + + + {SCAN_RESULT_MODAL_TITLE} + + + × + + + + + Тип: {displayType} + + + + {displayValue} + + + + + + + + + ); +} + //Модальное окно результата сканирования function ScanResultModal({ visible, codeType, value, onRequestClose }) { const handleClosePress = React.useCallback(() => { handleClose(onRequestClose); }, [onRequestClose]); + const modalOnRequestClose = React.useMemo(() => (Platform.OS === 'android' ? noop : handleClosePress), [handleClosePress]); + const displayType = formatCodeType(codeType); const displayValue = value != null && value !== '' ? String(value) : '—'; - return ( - - - - - - {SCAN_RESULT_MODAL_TITLE} - - - × - - - - - Тип: {displayType} - - - - {displayValue} - - - - - - + const preventKeyClose = Platform.OS === 'android'; + + const content = ( + + ); + + if (Platform.OS === 'android') { + if (!visible) return null; + return ( + + + {content} + ); + } + + return ( + + {content} ); } diff --git a/rn/app/src/components/scanner/ScannerArea.js b/rn/app/src/components/scanner/ScannerArea.js index cb5901a..d1b2160 100644 --- a/rn/app/src/components/scanner/ScannerArea.js +++ b/rn/app/src/components/scanner/ScannerArea.js @@ -8,10 +8,12 @@ //--------------------- const React = require('react'); //React -const { View, Modal, Pressable } = require('react-native'); //Базовые компоненты +const { View, Modal, Pressable, Platform } = require('react-native'); //Базовые компоненты const BarcodeScanner = require('./BarcodeScanner'); //Сканер const ScannerPlaceholder = require('./ScannerPlaceholder'); //Заглушка с кнопкой +const CameraPausedOverlay = require('./CameraPausedOverlay'); //Оверлей «камера на паузе» const AppText = require('../common/AppText'); //Текст +const { SCANNER_CAMERA_TAP_TO_RESUME } = require('../../config/messages'); //Сообщения const styles = require('../../styles/scanner/ScannerArea.styles'); //Стили области //----------- @@ -29,7 +31,16 @@ function handleScanResult(result, onScanResult, closeModal) { } //Область сканера: при включённой настройке — активный сканер (если открыт), иначе заглушка и модальный сканер по кнопке -function ScannerArea({ alwaysShowScanner = false, scannerOpen = true, onScanResult }) { +function ScannerArea({ + alwaysShowScanner = false, + scannerOpen = true, + cameraActive = true, + onScanResult, + onResumeCameraPress, + placeholderHint, + placeholderSecondaryLabel, + placeholderSecondaryOnPress +}) { const [scannerModalVisible, setScannerModalVisible] = React.useState(false); const handleScanFromArea = React.useCallback( @@ -56,9 +67,33 @@ function ScannerArea({ alwaysShowScanner = false, scannerOpen = true, onScanResu const renderScannerContent = () => { if (alwaysShowScanner && scannerOpen) { - return ; + const wrapperProps = Platform.OS === 'android' ? { focusable: false, importantForAccessibility: 'no-hide-descendants' } : {}; + const showPausedOverlay = !cameraActive && typeof onResumeCameraPress === 'function'; + return ( + + + {showPausedOverlay ? ( + + + + ) : null} + + ); } - return ; + return ( + + ); }; return ( diff --git a/rn/app/src/components/scanner/ScannerPlaceholder.js b/rn/app/src/components/scanner/ScannerPlaceholder.js index 01b3dc9..7892e03 100644 --- a/rn/app/src/components/scanner/ScannerPlaceholder.js +++ b/rn/app/src/components/scanner/ScannerPlaceholder.js @@ -10,6 +10,7 @@ const React = require('react'); //React const { View } = require('react-native'); //Базовые компоненты const AppButton = require('../common/AppButton'); //Кнопка +const AppText = require('../common/AppText'); //Текст const { SCAN_BUTTON_TITLE } = require('../../config/messages'); //Сообщения const styles = require('../../styles/scanner/ScannerPlaceholder.styles'); //Стили заглушки @@ -17,17 +18,31 @@ const styles = require('../../styles/scanner/ScannerPlaceholder.styles'); //Ст //Тело модуля //----------- -//Заглушка: затемнённая область и кнопка открытия сканера -function ScannerPlaceholder({ onScanPress }) { +//Заглушка: затемнённая область и кнопка открытия сканера; опционально подсказка и вторая кнопка (режим встроенного сканера) +function ScannerPlaceholder({ onScanPress, hintText, secondaryButtonTitle, secondaryButtonOnPress }) { const handlePress = React.useCallback(() => { if (typeof onScanPress === 'function') { onScanPress(); } }, [onScanPress]); + const handleSecondary = React.useCallback(() => { + if (typeof secondaryButtonOnPress === 'function') { + secondaryButtonOnPress(); + } + }, [secondaryButtonOnPress]); + return ( + {hintText ? ( + + {hintText} + + ) : null} + {secondaryButtonTitle && typeof secondaryButtonOnPress === 'function' ? ( + + ) : null} ); } diff --git a/rn/app/src/config/appConfig.js b/rn/app/src/config/appConfig.js index b351a6c..0225984 100644 --- a/rn/app/src/config/appConfig.js +++ b/rn/app/src/config/appConfig.js @@ -32,7 +32,7 @@ const UI = { //Высоты элементов BUTTON_HEIGHT: responsiveSize(Platform.OS === 'ios' ? 48 : 44), - INPUT_HEIGHT: responsiveSize(Platform.OS === 'ios' ? 48 : 44), + INPUT_HEIGHT: responsiveSize(56), HEADER_HEIGHT: responsiveSize(isTablet() ? 80 : Platform.OS === 'ios' ? 70 : 56) }; @@ -43,7 +43,7 @@ const COMPATIBILITY = { if (Platform.OS !== 'android') return true; const majorVersion = parseInt(Platform.Version, 10); - return majorVersion >= 24; // Android 7.0 = API 24 + return majorVersion >= 24; //Android 7.0 = API 24 }, //Проверяем версию iOS @@ -51,7 +51,7 @@ const COMPATIBILITY = { if (Platform.OS !== 'ios') return true; const majorVersion = parseInt(Platform.Version, 10); - return majorVersion >= 11; // iOS 11.0 + return majorVersion >= 11; //iOS 11.0 }, //Общая проверка совместимости diff --git a/rn/app/src/config/authConfig.js b/rn/app/src/config/authConfig.js index 84c3ae2..3366112 100644 --- a/rn/app/src/config/authConfig.js +++ b/rn/app/src/config/authConfig.js @@ -13,16 +13,18 @@ const AUTH_SETTINGS_KEYS = { LAST_CONNECTED_SERVER_URL: 'auth_last_connected_server_url', HIDE_SERVER_URL: 'auth_hide_server_url', IDLE_TIMEOUT: 'auth_idle_timeout', + SERVER_REQUEST_TIMEOUT: 'auth_server_request_timeout', DEVICE_ID: 'auth_device_id', DEVICE_SECRET_KEY: 'auth_device_secret_key', SAVED_LOGIN: 'auth_saved_login', SAVED_PASSWORD: 'auth_saved_password', SAVE_PASSWORD_ENABLED: 'auth_save_password_enabled', - ALWAYS_SHOW_SCANNER: 'main_always_show_scanner' + ALWAYS_SHOW_SCANNER: 'main_always_show_scanner', + MAIN_SCANNER_PRIORITY: 'main_scanner_priority' }; -//Значение времени простоя по умолчанию (минуты) -const DEFAULT_IDLE_TIMEOUT = 30; +//Значение времени ожидания ответа от сервера по умолчанию (секунды) +const DEFAULT_SERVER_REQUEST_TIMEOUT = 60; //Ключи настроек подключения const CONNECTION_SETTINGS_KEYS = [ @@ -42,5 +44,5 @@ const CONNECTION_SETTINGS_KEYS = [ module.exports = { AUTH_SETTINGS_KEYS, CONNECTION_SETTINGS_KEYS, - DEFAULT_IDLE_TIMEOUT + DEFAULT_SERVER_REQUEST_TIMEOUT }; diff --git a/rn/app/src/config/authFormFieldsConfig.js b/rn/app/src/config/authFormFieldsConfig.js new file mode 100644 index 0000000..dae0d70 --- /dev/null +++ b/rn/app/src/config/authFormFieldsConfig.js @@ -0,0 +1,27 @@ +/* + Предрейсовые осмотры - мобильное приложение + Конфигурация полей формы аутентификации +*/ + +//--------- +//Константы +//--------- + +//Порядок полей формы аутентификации +const AUTH_FORM_FIELD_ORDER = ['server', 'login', 'password']; + +//Идентификаторы полей +const AUTH_FORM_FIELD_ID = { + SERVER: 'server', + LOGIN: 'login', + PASSWORD: 'password' +}; + +//---------------- +//Интерфейс модуля +//---------------- + +module.exports = { + AUTH_FORM_FIELD_ORDER, + AUTH_FORM_FIELD_ID +}; diff --git a/rn/app/src/config/inputDeviceConfig.js b/rn/app/src/config/inputDeviceConfig.js new file mode 100644 index 0000000..57df2c1 --- /dev/null +++ b/rn/app/src/config/inputDeviceConfig.js @@ -0,0 +1,19 @@ +/* + Предрейсовые осмотры - мобильное приложение + Конфигурация совместной работы устройств ввода (клавиатура, встроенный сканер) +*/ + +//--------- +//Константы +//--------- + +//Режим подстановки данных сканера в поле: всегда замена текущего значения +const SCAN_INPUT_MODE_REPLACE = 'replace'; + +//---------------- +//Интерфейс модуля +//---------------- + +module.exports = { + SCAN_INPUT_MODE_REPLACE +}; diff --git a/rn/app/src/config/menuItemIds.js b/rn/app/src/config/menuItemIds.js new file mode 100644 index 0000000..52328f3 --- /dev/null +++ b/rn/app/src/config/menuItemIds.js @@ -0,0 +1,35 @@ +/* + Предрейсовые осмотры - мобильное приложение + Идентификаторы пунктов бокового меню +*/ + +//--------- +//Константы +//--------- + +//Пункт меню «Настройки» +const MENU_ITEM_ID_SETTINGS = 'settings'; + +//Пункт меню «О приложении» +const MENU_ITEM_ID_ABOUT = 'about'; + +//Пункт меню «Вход» +const MENU_ITEM_ID_LOGIN = 'login'; + +//Пункт меню «Выход» +const MENU_ITEM_ID_LOGOUT = 'logout'; + +//Разделитель в меню (не пункт, служебный тип) +const MENU_ITEM_ID_DIVIDER = 'divider'; + +//---------------- +//Интерфейс модуля +//---------------- + +module.exports = { + MENU_ITEM_ID_SETTINGS, + MENU_ITEM_ID_ABOUT, + MENU_ITEM_ID_LOGIN, + MENU_ITEM_ID_LOGOUT, + MENU_ITEM_ID_DIVIDER +}; diff --git a/rn/app/src/config/messages.js b/rn/app/src/config/messages.js index 49271cf..72c6d12 100644 --- a/rn/app/src/config/messages.js +++ b/rn/app/src/config/messages.js @@ -13,6 +13,9 @@ const CONNECTION_LOST_MESSAGE = 'Нет связи с сервером. Прил //Заголовок сообщения при переходе в режим офлайн const OFFLINE_MODE_TITLE = 'Режим офлайн'; +//Сообщение при переходе в офлайн по таймауту простоя +const IDLE_TIMEOUT_OFFLINE_MESSAGE = 'Сессия закрыта по таймауту простоя. Приложение переведено в режим офлайн.'; + //Сообщение при проверке соединения при старте приложения const STARTUP_CHECK_CONNECTION_MESSAGE = 'Проверка соединения...'; @@ -35,7 +38,10 @@ const MENU_ITEM_LOGOUT = 'Выход'; const AUTH_SCREEN_TITLE = 'Вход в приложение'; const AUTH_BUTTON_LOGIN = 'Войти'; const AUTH_BUTTON_LOADING = 'Вход...'; +const AUTH_BUTTON_LOGOUT = 'Выйти'; const LOGOUT_IN_PROGRESS_MESSAGE = 'Выход...'; +const LOGOUT_CONFIRM_TITLE = 'Подтверждение выхода'; +const LOGOUT_CONFIRM_MESSAGE = 'Вы уверены, что хотите выйти?'; //Диалог подтверждения при смене сервера (относительно последнего успешного подключения) const AUTH_SERVER_CHANGE_CONFIRM_TITLE = 'Подтверждение входа'; @@ -52,10 +58,16 @@ const SCREEN_TITLE_SETTINGS = 'Настройки'; //Сканер на главном экране const SCANNER_SETTING_LABEL = 'Всегда отображать сканер на главном экране'; +const SCANNER_PRIORITY_LABEL = 'Приоритет на главном экране'; +const SCANNER_PRIORITY_CAMERA = 'Камера'; +const SCANNER_PRIORITY_HARDWARE = 'Встроенный сканер'; const SCAN_BUTTON_TITLE = 'Сканировать'; const SCAN_RESULT_MODAL_TITLE = 'Результат сканирования'; const SCAN_RESULT_CLOSE_BUTTON = 'Закрыть'; +//Оверлей паузы камеры на главном экране: подсказка включить камеру по тапу +const SCANNER_CAMERA_TAP_TO_RESUME = 'Нажмите на область камеры для её включения'; + //---------------- //Интерфейс модуля //---------------- @@ -63,6 +75,7 @@ const SCAN_RESULT_CLOSE_BUTTON = 'Закрыть'; module.exports = { CONNECTION_LOST_MESSAGE, OFFLINE_MODE_TITLE, + IDLE_TIMEOUT_OFFLINE_MESSAGE, STARTUP_CHECK_CONNECTION_MESSAGE, APP_ABOUT_TITLE, SIDE_MENU_TITLE, @@ -74,7 +87,10 @@ module.exports = { AUTH_SCREEN_TITLE, AUTH_BUTTON_LOGIN, AUTH_BUTTON_LOADING, + AUTH_BUTTON_LOGOUT, LOGOUT_IN_PROGRESS_MESSAGE, + LOGOUT_CONFIRM_TITLE, + LOGOUT_CONFIRM_MESSAGE, AUTH_SERVER_CHANGE_CONFIRM_TITLE, AUTH_SERVER_CHANGE_CONFIRM_MESSAGE, AUTH_SERVER_CHANGE_CONFIRM_BUTTON, @@ -83,7 +99,11 @@ module.exports = { SETTINGS_RESET_SUCCESS_MESSAGE, SCREEN_TITLE_SETTINGS, SCANNER_SETTING_LABEL, + SCANNER_PRIORITY_LABEL, + SCANNER_PRIORITY_CAMERA, + SCANNER_PRIORITY_HARDWARE, SCAN_BUTTON_TITLE, SCAN_RESULT_MODAL_TITLE, - SCAN_RESULT_CLOSE_BUTTON + SCAN_RESULT_CLOSE_BUTTON, + SCANNER_CAMERA_TAP_TO_RESUME }; diff --git a/rn/app/src/config/scannerConfig.js b/rn/app/src/config/scannerConfig.js index 98be1b6..f84655a 100644 --- a/rn/app/src/config/scannerConfig.js +++ b/rn/app/src/config/scannerConfig.js @@ -10,8 +10,24 @@ //Типы кодов для распознавания: QR и распространённые штрихкоды const DEFAULT_CODE_TYPES = ['qr', 'code-128', 'code-39', 'ean-13', 'ean-8', 'upc-a', 'upc-e', 'pdf-417', 'aztec', 'data-matrix']; +//Встроенный сканер устройства: имя события от нативного модуля (Android) +const HARDWARE_SCANNER_EVENT_NAME = 'HardwareScannerData'; +//Событие нажатия кнопки встроенного сканера (142/141) — для временного скрытия камеры и перехода в режим «ввод от сканера» +const HARDWARE_SCANNER_TRIGGER_EVENT_NAME = 'HardwareScannerTriggerPressed'; + +//Диапазоны символов, допустимых в данных штрихкодов/QR: ASCII printable + Latin-1 +const HARDWARE_SCANNER_CHAR_ASCII_MIN = 32; +const HARDWARE_SCANNER_CHAR_ASCII_MAX = 126; +const HARDWARE_SCANNER_CHAR_LATIN1_MIN = 160; +const HARDWARE_SCANNER_CHAR_LATIN1_MAX = 255; + +//Длительность (мс) скрытия камеры после нажатия кнопки встроенного сканера, чтобы прошивка успела включить лазер +const HARDWARE_SCANNER_CAMERA_HIDE_MS = 3500; + //Определение, должен ли отображаться сканер на главном экране -function shouldShowScanner({ alwaysShowScanner, hasScanResult, isStartupCheckInProgress }) { +function shouldShowScanner({ alwaysShowScanner, hasScanResult, isStartupCheckInProgress, isOfflineMessageVisible, isAboutMessageVisible }) { + if (isOfflineMessageVisible) return false; + if (isAboutMessageVisible) return false; return Boolean(alwaysShowScanner && !hasScanResult && !isStartupCheckInProgress); } @@ -21,5 +37,12 @@ function shouldShowScanner({ alwaysShowScanner, hasScanResult, isStartupCheckInP module.exports = { DEFAULT_CODE_TYPES, + HARDWARE_SCANNER_EVENT_NAME, + HARDWARE_SCANNER_TRIGGER_EVENT_NAME, + HARDWARE_SCANNER_CAMERA_HIDE_MS, + HARDWARE_SCANNER_CHAR_ASCII_MIN, + HARDWARE_SCANNER_CHAR_ASCII_MAX, + HARDWARE_SCANNER_CHAR_LATIN1_MIN, + HARDWARE_SCANNER_CHAR_LATIN1_MAX, shouldShowScanner }; diff --git a/rn/app/src/database/SQLiteDatabase.js b/rn/app/src/database/SQLiteDatabase.js index ba31268..9ccc152 100644 --- a/rn/app/src/database/SQLiteDatabase.js +++ b/rn/app/src/database/SQLiteDatabase.js @@ -44,7 +44,6 @@ class SQLiteDatabase { this.isInitialized = true; return this.db; } catch (error) { - console.error('Ошибка инициализации базы данных:', error); throw error; } } @@ -59,15 +58,8 @@ class SQLiteDatabase { //Выполняем SQL запросы последовательно await this.executeQuery(this.sqlQueries.CREATE_TABLE_APP_SETTINGS); - await this.executeQuery(this.sqlQueries.CREATE_TABLE_INSPECTIONS); - await this.executeQuery(this.sqlQueries.CREATE_TABLE_AUTH_SESSION); - - await this.executeQuery(this.sqlQueries.CREATE_INDEX_INSPECTIONS_STATUS); - - await this.executeQuery(this.sqlQueries.CREATE_INDEX_INSPECTIONS_CREATED); } catch (error) { - console.error('Ошибка настройки базы данных:', error); throw error; } } @@ -82,7 +74,6 @@ class SQLiteDatabase { const result = await this.db.executeAsync(sql, params); return result; } catch (error) { - console.error('Ошибка выполнения SQL запроса:', error, 'SQL:', sql, 'Params:', params); throw error; } } @@ -97,7 +88,6 @@ class SQLiteDatabase { } return null; } catch (error) { - console.error('Ошибка получения настройки:', error); throw error; } } @@ -109,7 +99,6 @@ class SQLiteDatabase { await this.executeQuery(this.sqlQueries.SETTINGS_SET, [key, stringValue]); return true; } catch (error) { - console.error('Ошибка сохранения настройки:', error); throw error; } } @@ -120,7 +109,6 @@ class SQLiteDatabase { await this.executeQuery(this.sqlQueries.SETTINGS_DELETE, [key]); return true; } catch (error) { - console.error('Ошибка удаления настройки:', error); throw error; } } @@ -144,7 +132,6 @@ class SQLiteDatabase { return settings; } catch (error) { - console.error('Ошибка получения всех настроек:', error); throw error; } } @@ -155,126 +142,6 @@ class SQLiteDatabase { await this.executeQuery(this.sqlQueries.SETTINGS_CLEAR_ALL, []); return true; } catch (error) { - console.error('Ошибка очистки настроек:', error); - throw error; - } - } - - //Сохранение осмотра - async saveInspection(inspection) { - try { - const { id, title, status, data } = inspection; - const dataString = data ? JSON.stringify(data) : null; - - await this.executeQuery(this.sqlQueries.INSPECTIONS_UPSERT, [id, title, status, id, dataString]); - return true; - } catch (error) { - console.error('Ошибка сохранения осмотра:', error); - throw error; - } - } - - //Получение всех осмотров - async getInspections() { - try { - const result = await this.executeQuery(this.sqlQueries.INSPECTIONS_GET_ALL, []); - const inspections = []; - - if (result.rows && result.rows.length > 0) { - for (let i = 0; i < result.rows.length; i++) { - const row = result.rows.item(i); - const inspection = { - id: row.id, - title: row.title, - status: row.status, - createdAt: row.created_at, - updatedAt: row.updated_at - }; - - if (row.data) { - try { - inspection.data = JSON.parse(row.data); - } catch { - inspection.data = row.data; - } - } - - inspections.push(inspection); - } - } - - return inspections; - } catch (error) { - console.error('Ошибка получения осмотров:', error); - throw error; - } - } - - //Получение осмотра по ID - async getInspectionById(id) { - try { - const result = await this.executeQuery(this.sqlQueries.INSPECTIONS_GET_BY_ID, [id]); - - if (result.rows && result.rows.length > 0) { - const row = result.rows.item(0); - const inspection = { - id: row.id, - title: row.title, - status: row.status, - createdAt: row.created_at, - updatedAt: row.updated_at - }; - - if (row.data) { - try { - inspection.data = JSON.parse(row.data); - } catch { - inspection.data = row.data; - } - } - - return inspection; - } - return null; - } catch (error) { - console.error('Ошибка получения осмотра по ID:', error); - throw error; - } - } - - //Удаление осмотра - async deleteInspection(id) { - try { - await this.executeQuery(this.sqlQueries.INSPECTIONS_DELETE, [id]); - return true; - } catch (error) { - console.error('Ошибка удаления осмотра:', error); - throw error; - } - } - - //Удаление всех осмотров - async clearInspections() { - try { - await this.executeQuery(this.sqlQueries.INSPECTIONS_DELETE_ALL, []); - return true; - } catch (error) { - console.error('Ошибка очистки осмотров:', error); - throw error; - } - } - - //Получение количества осмотров - async getInspectionsCount() { - try { - const result = await this.executeQuery(this.sqlQueries.INSPECTIONS_COUNT, []); - - if (result.rows && result.rows.length > 0) { - return result.rows.item(0).count; - } - return 0; - } catch (error) { - console.error('Ошибка получения количества осмотров:', error); throw error; } } @@ -285,7 +152,6 @@ class SQLiteDatabase { await this.executeQuery(this.sqlQueries.UTILITY_VACUUM, []); return true; } catch (error) { - console.error('Ошибка оптимизации базы данных:', error); throw error; } } @@ -316,7 +182,6 @@ class SQLiteDatabase { ]); return true; } catch (error) { - console.error('Ошибка сохранения сессии авторизации:', error); throw error; } } @@ -343,7 +208,6 @@ class SQLiteDatabase { } return null; } catch (error) { - console.error('Ошибка получения сессии авторизации:', error); throw error; } } @@ -354,7 +218,6 @@ class SQLiteDatabase { await this.executeQuery(this.sqlQueries.AUTH_SESSION_CLEAR, []); return true; } catch (error) { - console.error('Ошибка очистки сессии авторизации:', error); throw error; } } @@ -365,7 +228,6 @@ class SQLiteDatabase { const result = await this.executeQuery(this.sqlQueries.UTILITY_CHECK_TABLE, [tableName]); return result.rows && result.rows.length > 0 && result.rows.item(0).exists === 1; } catch (error) { - console.error('Ошибка проверки существования таблицы:', error); throw error; } } @@ -376,7 +238,6 @@ class SQLiteDatabase { await this.executeQuery(this.sqlQueries.UTILITY_DROP_TABLE, [tableName]); return true; } catch (error) { - console.error('Ошибка удаления таблицы:', error); throw error; } } @@ -385,13 +246,12 @@ class SQLiteDatabase { async close() { try { if (this.db) { - // В react-native-quick-sqlite нет явного метода close - // База данных закрывается автоматически при уничтожении объекта + //В react-native-quick-sqlite нет явного метода close + //База данных закрывается автоматически при уничтожении объекта this.db = null; this.isInitialized = false; } } catch (error) { - console.error('Ошибка закрытия базы данных:', error); this.db = null; this.isInitialized = false; throw error; diff --git a/rn/app/src/database/sql/SQLQueries.js b/rn/app/src/database/sql/SQLQueries.js index 3a30a71..1c69fd9 100644 --- a/rn/app/src/database/sql/SQLQueries.js +++ b/rn/app/src/database/sql/SQLQueries.js @@ -9,13 +9,8 @@ //Таблицы const CREATE_TABLE_APP_SETTINGS = require('./settings/create_table_app_settings.sql'); -const CREATE_TABLE_INSPECTIONS = require('./inspections/create_table_inspections.sql'); const CREATE_TABLE_AUTH_SESSION = require('./auth/create_table_auth_session.sql'); -//Индексы -const CREATE_INDEX_INSPECTIONS_STATUS = require('./inspections/create_index_inspections_status.sql'); -const CREATE_INDEX_INSPECTIONS_CREATED = require('./inspections/create_index_inspections_created.sql'); - //Настройки const SETTINGS_GET = require('./settings/get_setting.sql'); const SETTINGS_SET = require('./settings/set_setting.sql'); @@ -23,15 +18,6 @@ const SETTINGS_DELETE = require('./settings/delete_setting.sql'); const SETTINGS_CLEAR_ALL = require('./settings/clear_all_settings.sql'); const SETTINGS_GET_ALL = require('./settings/get_all_settings.sql'); -//Осмотры -const INSPECTIONS_INSERT = require('./inspections/insert_inspection.sql'); -const INSPECTIONS_UPSERT = require('./inspections/upsert_inspection.sql'); -const INSPECTIONS_GET_ALL = require('./inspections/get_all_inspections.sql'); -const INSPECTIONS_GET_BY_ID = require('./inspections/get_inspection_by_id.sql'); -const INSPECTIONS_DELETE = require('./inspections/delete_inspection.sql'); -const INSPECTIONS_DELETE_ALL = require('./inspections/delete_all_inspections.sql'); -const INSPECTIONS_COUNT = require('./inspections/count_inspections.sql'); - //Авторизация const AUTH_SESSION_SET = require('./auth/set_auth_session.sql'); const AUTH_SESSION_GET = require('./auth/get_auth_session.sql'); @@ -49,22 +35,12 @@ const UTILITY_VACUUM = require('./utility/vacuum.sql'); //Сбор всех SQL запросов в один объект const SQLQueries = { CREATE_TABLE_APP_SETTINGS, - CREATE_TABLE_INSPECTIONS, CREATE_TABLE_AUTH_SESSION, - CREATE_INDEX_INSPECTIONS_STATUS, - CREATE_INDEX_INSPECTIONS_CREATED, SETTINGS_GET, SETTINGS_SET, SETTINGS_DELETE, SETTINGS_CLEAR_ALL, SETTINGS_GET_ALL, - INSPECTIONS_INSERT, - INSPECTIONS_UPSERT, - INSPECTIONS_GET_ALL, - INSPECTIONS_GET_BY_ID, - INSPECTIONS_DELETE, - INSPECTIONS_DELETE_ALL, - INSPECTIONS_COUNT, AUTH_SESSION_SET, AUTH_SESSION_GET, AUTH_SESSION_CLEAR, diff --git a/rn/app/src/database/sql/inspections/count_inspections.sql.js b/rn/app/src/database/sql/inspections/count_inspections.sql.js deleted file mode 100644 index 652a75c..0000000 --- a/rn/app/src/database/sql/inspections/count_inspections.sql.js +++ /dev/null @@ -1,19 +0,0 @@ -/* - Предрейсовые осмотры - мобильное приложение - SQL запрос: подсчет количества осмотров -*/ - -//----------- -//Тело модуля -//----------- - -const INSPECTIONS_COUNT = ` --- Подсчет количества осмотров -SELECT COUNT(*) as count FROM inspections; -`; - -//---------------- -//Интерфейс модуля -//---------------- - -module.exports = INSPECTIONS_COUNT; diff --git a/rn/app/src/database/sql/inspections/create_index_inspections_created.sql.js b/rn/app/src/database/sql/inspections/create_index_inspections_created.sql.js deleted file mode 100644 index 8b5867b..0000000 --- a/rn/app/src/database/sql/inspections/create_index_inspections_created.sql.js +++ /dev/null @@ -1,19 +0,0 @@ -/* - Предрейсовые осмотры - мобильное приложение - SQL запрос: создание индекса по дате создания -*/ - -//----------- -//Тело модуля -//----------- - -const CREATE_INDEX_INSPECTIONS_CREATED = ` --- Индекс для сортировки по дате создания -CREATE INDEX IF NOT EXISTS idx_inspections_created ON inspections(created_at DESC); -`; - -//---------------- -//Интерфейс модуля -//---------------- - -module.exports = CREATE_INDEX_INSPECTIONS_CREATED; diff --git a/rn/app/src/database/sql/inspections/create_index_inspections_status.sql.js b/rn/app/src/database/sql/inspections/create_index_inspections_status.sql.js deleted file mode 100644 index b0cfe5c..0000000 --- a/rn/app/src/database/sql/inspections/create_index_inspections_status.sql.js +++ /dev/null @@ -1,19 +0,0 @@ -/* - Предрейсовые осмотры - мобильное приложение - SQL запрос: создание индекса по статусу -*/ - -//----------- -//Тело модуля -//----------- - -const CREATE_INDEX_INSPECTIONS_STATUS = ` --- Индекс для быстрого поиска осмотров по статусу -CREATE INDEX IF NOT EXISTS idx_inspections_status ON inspections(status); -`; - -//---------------- -//Интерфейс модуля -//---------------- - -module.exports = CREATE_INDEX_INSPECTIONS_STATUS; diff --git a/rn/app/src/database/sql/inspections/create_table_inspections.sql.js b/rn/app/src/database/sql/inspections/create_table_inspections.sql.js deleted file mode 100644 index 125922a..0000000 --- a/rn/app/src/database/sql/inspections/create_table_inspections.sql.js +++ /dev/null @@ -1,26 +0,0 @@ -/* - Предрейсовые осмотры - мобильное приложение - SQL запрос: создание таблицы осмотров -*/ - -//----------- -//Тело модуля -//----------- - -const CREATE_TABLE_INSPECTIONS = ` --- Таблица для предрейсовых осмотров -CREATE TABLE IF NOT EXISTS inspections ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - status TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - data TEXT -); -`; - -//---------------- -//Интерфейс модуля -//---------------- - -module.exports = CREATE_TABLE_INSPECTIONS; diff --git a/rn/app/src/database/sql/inspections/delete_all_inspections.sql.js b/rn/app/src/database/sql/inspections/delete_all_inspections.sql.js deleted file mode 100644 index f1fa07d..0000000 --- a/rn/app/src/database/sql/inspections/delete_all_inspections.sql.js +++ /dev/null @@ -1,19 +0,0 @@ -/* - Предрейсовые осмотры - мобильное приложение - SQL запрос: удаление всех осмотров -*/ - -//----------- -//Тело модуля -//----------- - -const INSPECTIONS_DELETE_ALL = ` --- Удаление всех осмотров -DELETE FROM inspections; -`; - -//---------------- -//Интерфейс модуля -//---------------- - -module.exports = INSPECTIONS_DELETE_ALL; diff --git a/rn/app/src/database/sql/inspections/delete_inspection.sql.js b/rn/app/src/database/sql/inspections/delete_inspection.sql.js deleted file mode 100644 index cbe26ec..0000000 --- a/rn/app/src/database/sql/inspections/delete_inspection.sql.js +++ /dev/null @@ -1,19 +0,0 @@ -/* - Предрейсовые осмотры - мобильное приложение - SQL запрос: удаление осмотра -*/ - -//----------- -//Тело модуля -//----------- - -const INSPECTIONS_DELETE = ` --- Удаление осмотра по ID -DELETE FROM inspections WHERE id = ?; -`; - -//---------------- -//Интерфейс модуля -//---------------- - -module.exports = INSPECTIONS_DELETE; diff --git a/rn/app/src/database/sql/inspections/get_all_inspections.sql.js b/rn/app/src/database/sql/inspections/get_all_inspections.sql.js deleted file mode 100644 index ee1cdc8..0000000 --- a/rn/app/src/database/sql/inspections/get_all_inspections.sql.js +++ /dev/null @@ -1,19 +0,0 @@ -/* - Предрейсовые осмотры - мобильное приложение - SQL запрос: получение всех осмотров -*/ - -//----------- -//Тело модуля -//----------- - -const INSPECTIONS_GET_ALL = ` --- Получение всех осмотров -SELECT * FROM inspections ORDER BY created_at DESC; -`; - -//---------------- -//Интерфейс модуля -//---------------- - -module.exports = INSPECTIONS_GET_ALL; diff --git a/rn/app/src/database/sql/inspections/get_inspection_by_id.sql.js b/rn/app/src/database/sql/inspections/get_inspection_by_id.sql.js deleted file mode 100644 index 7c6748e..0000000 --- a/rn/app/src/database/sql/inspections/get_inspection_by_id.sql.js +++ /dev/null @@ -1,19 +0,0 @@ -/* - Предрейсовые осмотры - мобильное приложение - SQL запрос: получение осмотра по ID -*/ - -//----------- -//Тело модуля -//----------- - -const INSPECTIONS_GET_BY_ID = ` --- Получение осмотра по ID -SELECT * FROM inspections WHERE id = ?; -`; - -//---------------- -//Интерфейс модуля -//---------------- - -module.exports = INSPECTIONS_GET_BY_ID; diff --git a/rn/app/src/database/sql/inspections/insert_inspection.sql.js b/rn/app/src/database/sql/inspections/insert_inspection.sql.js deleted file mode 100644 index 58fe4dd..0000000 --- a/rn/app/src/database/sql/inspections/insert_inspection.sql.js +++ /dev/null @@ -1,20 +0,0 @@ -/* - Предрейсовые осмотры - мобильное приложение - SQL запрос: вставка нового осмотра -*/ - -//----------- -//Тело модуля -//----------- - -const INSPECTIONS_INSERT = ` --- Вставка нового осмотра -INSERT INTO inspections (id, title, status, created_at, data) -VALUES (?, ?, ?, ?, ?); -`; - -//---------------- -//Интерфейс модуля -//---------------- - -module.exports = INSPECTIONS_INSERT; diff --git a/rn/app/src/database/sql/inspections/upsert_inspection.sql.js b/rn/app/src/database/sql/inspections/upsert_inspection.sql.js deleted file mode 100644 index e08a019..0000000 --- a/rn/app/src/database/sql/inspections/upsert_inspection.sql.js +++ /dev/null @@ -1,20 +0,0 @@ -/* - Предрейсовые осмотры - мобильное приложение - SQL запрос: вставка или обновление осмотра -*/ - -//----------- -//Тело модуля -//----------- - -const INSPECTIONS_UPSERT = ` --- Вставка или обновление осмотра -INSERT OR REPLACE INTO inspections (id, title, status, created_at, updated_at, data) -VALUES (?, ?, ?, COALESCE((SELECT created_at FROM inspections WHERE id = ?), CURRENT_TIMESTAMP), CURRENT_TIMESTAMP, ?); -`; - -//---------------- -//Интерфейс модуля -//---------------- - -module.exports = INSPECTIONS_UPSERT; diff --git a/rn/app/src/hooks/useAppMode.js b/rn/app/src/hooks/useAppMode.js index 21ad5cc..2baf2ea 100644 --- a/rn/app/src/hooks/useAppMode.js +++ b/rn/app/src/hooks/useAppMode.js @@ -52,7 +52,6 @@ function useAppMode() { } //При отсутствии сохранённого режима остаётся NOT_CONNECTED } catch (error) { - console.error('Ошибка загрузки режима:', error); } finally { setIsInitialized(true); loadingRef.current = false; @@ -71,9 +70,7 @@ function useAppMode() { const saveMode = async () => { try { await setSetting(STORAGE_KEY, mode); - } catch (error) { - console.error('Ошибка сохранения режима:', error); - } + } catch (error) {} }; saveMode(); diff --git a/rn/app/src/hooks/useAppServer.js b/rn/app/src/hooks/useAppServer.js index aafb6b8..23c7acc 100644 --- a/rn/app/src/hooks/useAppServer.js +++ b/rn/app/src/hooks/useAppServer.js @@ -9,6 +9,7 @@ const React = require('react'); //React и хуки const { useAppLocalDbContext } = require('../components/layout/AppLocalDbProvider'); //Контекст локальной БД +const { getServerRequestTimeoutMs } = require('../utils/serverRequestTimeout'); //Таймаут запроса к серверу //--------- //Константы @@ -60,9 +61,7 @@ function useAppServer() { let baseURL = ''; try { baseURL = (await getSetting('app_server_url')) || ''; - } catch (e) { - console.warn('Не удалось прочитать настройки сервера:', e); - } + } catch (_e) {} if (!baseURL) { return makeRespErr({ @@ -77,6 +76,14 @@ function useAppServer() { const abortController = new AbortController(); abortControllerRef.current = abortController; + const timeoutMs = await getServerRequestTimeoutMs(getSetting); + let timeoutId = null; + if (timeoutMs > 0) { + timeoutId = setTimeout(() => { + abortController.abort(); + }, timeoutMs); + } + let response = null; let responseJSON = null; @@ -97,6 +104,9 @@ function useAppServer() { message: `${ERR_NETWORK}: ${e.message || 'неопределённая ошибка'}` }); } finally { + if (timeoutId != null) { + clearTimeout(timeoutId); + } abortControllerRef.current = null; } diff --git a/rn/app/src/hooks/useAuth.js b/rn/app/src/hooks/useAuth.js index 232423e..780f09c 100644 --- a/rn/app/src/hooks/useAuth.js +++ b/rn/app/src/hooks/useAuth.js @@ -9,10 +9,11 @@ const React = require('react'); //React и хуки const { useAppLocalDbContext } = require('../components/layout/AppLocalDbProvider'); //Контекст локальной БД -const { AUTH_SETTINGS_KEYS, DEFAULT_IDLE_TIMEOUT } = require('../config/authConfig'); //Конфиг авторизации +const { AUTH_SETTINGS_KEYS } = require('../config/authConfig'); //Конфиг авторизации const { ACTION_CODES, RESPONSE_STATES, ERROR_MESSAGES } = require('../config/authApi'); //API авторизации const { generateSecretKey, encryptData, decryptData } = require('../utils/secureStorage'); //Шифрование const { getPersistentDeviceId, isPersistentIdAvailable } = require('../utils/deviceId'); //Идентификатор устройства +const { getServerRequestTimeoutMs } = require('../utils/serverRequestTimeout'); //Таймаут запроса к серверу //----------- //Тело модуля @@ -79,41 +80,55 @@ function useAuth() { }, [getSetting, setSetting]); //Выполнение запроса к серверу - const executeRequest = React.useCallback(async (serverUrl, payload) => { - //Отменяем предыдущий запрос если есть - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } - - const abortController = new AbortController(); - abortControllerRef.current = abortController; - - try { - const response = await fetch(serverUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(payload), - signal: abortController.signal - }); - - if (!response.ok) { - throw new Error(`${ERROR_MESSAGES.SERVER_ERROR}: ${response.status}`); + const executeRequest = React.useCallback( + async (serverUrl, payload) => { + //Отменяем предыдущий запрос если есть + if (abortControllerRef.current) { + abortControllerRef.current.abort(); } - const data = await response.json(); - return data; - } catch (fetchError) { - if (fetchError.name === 'AbortError') { - return null; - } + const abortController = new AbortController(); + abortControllerRef.current = abortController; - throw new Error(`${ERROR_MESSAGES.NETWORK_ERROR}: ${fetchError.message}`); - } finally { - abortControllerRef.current = null; - } - }, []); + let timeoutId = null; + try { + const timeoutMs = await getServerRequestTimeoutMs(getSetting); + if (timeoutMs > 0) { + timeoutId = setTimeout(() => { + abortController.abort(); + }, timeoutMs); + } + + const response = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload), + signal: abortController.signal + }); + + if (!response.ok) { + throw new Error(`${ERROR_MESSAGES.SERVER_ERROR}: ${response.status}`); + } + + const data = await response.json(); + return data; + } catch (fetchError) { + if (fetchError.name === 'AbortError') { + return null; + } + + throw new Error(`${ERROR_MESSAGES.NETWORK_ERROR}: ${fetchError.message}`); + } finally { + if (timeoutId != null) { + clearTimeout(timeoutId); + } + abortControllerRef.current = null; + } + }, + [getSetting] + ); //Сохранение credentials после успешной аутентификации const saveCredentials = React.useCallback( @@ -131,7 +146,6 @@ function useAuth() { return true; } catch (saveError) { - console.error('Ошибка сохранения credentials:', saveError); return false; } }, @@ -182,7 +196,6 @@ function useAuth() { savePasswordEnabled: true }; } catch (loadError) { - console.error('Ошибка загрузки credentials:', loadError); return null; } }, [getSetting, getDeviceId, getSecretKey]); @@ -195,7 +208,6 @@ function useAuth() { await setSetting(AUTH_SETTINGS_KEYS.SAVE_PASSWORD_ENABLED, 'false'); return true; } catch (clearError) { - console.error('Ошибка очистки credentials:', clearError); return false; } }, [setSetting]); @@ -229,8 +241,8 @@ function useAuth() { requestPayload.XREQUEST.XPAYLOAD.SCOMPANY = company; } - //Добавляем таймаут (используем значение по умолчанию если не указан) - const timeoutValue = timeout && timeout > 0 ? timeout : DEFAULT_IDLE_TIMEOUT; + //Таймаут сессии на сервере (минуты) + const timeoutValue = timeout != null && Number(timeout) > 0 ? Number(timeout) : 0; requestPayload.XREQUEST.XPAYLOAD.NTIMEOUT = timeoutValue; //Выполняем запрос @@ -405,16 +417,18 @@ function useAuth() { //Выход из системы const logout = React.useCallback( async (options = {}) => { - const { skipServerRequest = false } = options; + const { skipServerRequest = false, keepSessionLocally = false } = options; - setIsLoading(true); - setIsLogoutInProgress(true); + if (!keepSessionLocally) { + setIsLoading(true); + setIsLogoutInProgress(true); + } setError(null); try { const currentSession = session || (await getAuthSession()); - //Запрос на сервер только при онлайн и если не отключено опцией + //Запрос на сервер при наличии сессии и если не отключено опцией if (!skipServerRequest && currentSession?.sessionId && currentSession?.serverUrl) { const requestPayload = { XREQUEST: { @@ -429,14 +443,14 @@ function useAuth() { try { await executeRequest(currentSession.serverUrl, requestPayload); - } catch (logoutError) { - console.warn('Ошибка при выходе из системы:', logoutError); - } + } catch (_logoutError) {} } - await clearAuthSession(); - setSession(null); - setIsAuthenticated(false); + if (!keepSessionLocally) { + await clearAuthSession(); + setSession(null); + setIsAuthenticated(false); + } return { success: true }; } catch (logoutError) { @@ -444,8 +458,10 @@ function useAuth() { setError(errorMessage); return { success: false, error: errorMessage }; } finally { - setIsLoading(false); - setIsLogoutInProgress(false); + if (!keepSessionLocally) { + setIsLoading(false); + setIsLogoutInProgress(false); + } } }, [session, getAuthSession, executeRequest, clearAuthSession] @@ -580,8 +596,7 @@ function useAuth() { setIsAuthenticated(true); sessionRestoredFromStorageRef.current = true; } - } catch (initError) { - console.error('Ошибка инициализации авторизации:', initError); + } catch (_initError) { } finally { setIsInitialized(true); } diff --git a/rn/app/src/hooks/useIdleTimeout.js b/rn/app/src/hooks/useIdleTimeout.js new file mode 100644 index 0000000..0f35367 --- /dev/null +++ b/rn/app/src/hooks/useIdleTimeout.js @@ -0,0 +1,121 @@ +/* + Предрейсовые осмотры - мобильное приложение + Хук отслеживания простоя +*/ + +//--------------------- +//Подключение библиотек +//--------------------- + +const React = require('react'); //React и хуки +const { AppState } = require('react-native'); //Состояние приложения (active/background) +const { useAppLocalDbContext } = require('../components/layout/AppLocalDbProvider'); //Контекст локальной БД +const { useAppAuthContext } = require('../components/layout/AppAuthProvider'); //Контекст авторизации +const { useAppModeContext } = require('../components/layout/AppModeProvider'); //Контекст режима работы +const { useAppMessagingContext } = require('../components/layout/AppMessagingProvider'); //Контекст сообщений +const { AUTH_SETTINGS_KEYS } = require('../config/authConfig'); //Ключи настроек +const { IDLE_TIMEOUT_OFFLINE_MESSAGE, OFFLINE_MODE_TITLE } = require('../config/messages'); //Сообщения + +//--------- +//Константы +//--------- + +const IDLE_CHECK_INTERVAL_MS = 15000; //Проверка каждые 15 секунд + +//----------- +//Тело модуля +//----------- + +//Хук отслеживания простоя: при превышении лимита — запрос logout на сервер, переход в офлайн и сообщение +function useIdleTimeout() { + const { getSetting } = useAppLocalDbContext(); + const { isAuthenticated, logout } = useAppAuthContext(); + const { APP_MODE, mode, setOffline } = useAppModeContext(); + const { showInfo } = useAppMessagingContext(); + + const lastActivityAtRef = React.useRef(Date.now()); + const intervalIdRef = React.useRef(null); + const appStateRef = React.useRef(AppState.currentState); + + //Сброс таймера простоя при любой активности + const reportActivity = React.useCallback(() => { + lastActivityAtRef.current = Date.now(); + }, []); + + //Проверка простоя + const checkIdleAndLogout = React.useCallback(async () => { + if (!isAuthenticated || appStateRef.current !== 'active' || mode === APP_MODE.OFFLINE) { + return; + } + + let idleMinutes = 0; + try { + const raw = await getSetting(AUTH_SETTINGS_KEYS.IDLE_TIMEOUT); + const trimmed = raw != null ? String(raw).trim() : ''; + if (trimmed === '') { + return; + } + idleMinutes = parseInt(trimmed, 10); + if (isNaN(idleMinutes) || idleMinutes <= 0) { + return; + } + } catch (err) { + return; + } + + const idleLimitMs = idleMinutes * 60 * 1000; + const now = Date.now(); + if (now - lastActivityAtRef.current >= idleLimitMs) { + try { + await logout({ skipServerRequest: false, keepSessionLocally: true }); + setOffline(); + showInfo(IDLE_TIMEOUT_OFFLINE_MESSAGE, { title: OFFLINE_MODE_TITLE }); + } catch (logoutErr) {} + } + }, [isAuthenticated, getSetting, logout, mode, APP_MODE.OFFLINE, setOffline, showInfo]); + + //Запуск периодической проверки при авторизации; сброс времени активности при входе + React.useEffect(() => { + if (!isAuthenticated) { + if (intervalIdRef.current != null) { + clearInterval(intervalIdRef.current); + intervalIdRef.current = null; + } + return; + } + + lastActivityAtRef.current = Date.now(); + + const intervalId = setInterval(checkIdleAndLogout, IDLE_CHECK_INTERVAL_MS); + intervalIdRef.current = intervalId; + + return function cleanup() { + if (intervalIdRef.current != null) { + clearInterval(intervalIdRef.current); + intervalIdRef.current = null; + } + }; + }, [isAuthenticated, checkIdleAndLogout]); + + //Учёт перехода приложения в фон/на передний план (не считать простой в фоне) + React.useEffect(() => { + const subscription = AppState.addEventListener('change', nextAppState => { + appStateRef.current = nextAppState; + if (nextAppState === 'active') { + lastActivityAtRef.current = Date.now(); + } + }); + + return function cleanup() { + subscription.remove(); + }; + }, []); + + return { reportActivity }; +} + +//---------------- +//Интерфейс модуля +//---------------- + +module.exports = useIdleTimeout; diff --git a/rn/app/src/hooks/useLocalDb.js b/rn/app/src/hooks/useLocalDb.js index 6775b37..912bd3c 100644 --- a/rn/app/src/hooks/useLocalDb.js +++ b/rn/app/src/hooks/useLocalDb.js @@ -17,7 +17,6 @@ const SQLiteDatabase = require('../database/SQLiteDatabase'); //Модуль SQL function useLocalDb() { //Состояние хука const [isDbReady, setIsDbReady] = React.useState(false); - const [inspections, setInspections] = React.useState([]); const [dbError, setDbError] = React.useState(null); //Отслеживания статуса инициализации @@ -33,12 +32,6 @@ function useLocalDb() { if (initializingRef.current || SQLiteDatabase.isInitialized) { if (mounted && SQLiteDatabase.isInitialized) { setIsDbReady(true); - try { - const loadedInspections = await SQLiteDatabase.getInspections(); - setInspections(loadedInspections); - } catch (loadError) { - console.error('Ошибка загрузки осмотров при переинициализации:', loadError); - } } return; } @@ -49,12 +42,9 @@ function useLocalDb() { await SQLiteDatabase.initialize(); if (mounted) { setIsDbReady(true); - const loadedInspections = await SQLiteDatabase.getInspections(); - setInspections(loadedInspections); setDbError(null); } - } catch (initError) { - console.error('Ошибка инициализации базы данных:', initError); + } catch (_initError) { if (mounted) { setIsDbReady(false); setDbError('Не удалось инициализировать базу данных'); @@ -71,48 +61,6 @@ function useLocalDb() { }; }, []); - //Загрузка списка осмотров - const loadInspections = React.useCallback(async () => { - if (!isDbReady) { - console.warn('База данных не готова'); - return []; - } - - try { - const loadedInspections = await SQLiteDatabase.getInspections(); - setInspections(loadedInspections); - setDbError(null); - return loadedInspections; - } catch (loadError) { - console.error('Ошибка загрузки осмотров:', loadError); - setDbError('Не удалось загрузить осмотры'); - return []; - } - }, [isDbReady]); - - //Сохранение осмотра - const saveInspection = React.useCallback( - async inspection => { - if (!isDbReady) { - console.warn('База данных не готова'); - return inspection; - } - - try { - await SQLiteDatabase.saveInspection(inspection); - const updatedInspections = await SQLiteDatabase.getInspections(); - setInspections(updatedInspections); - setDbError(null); - return inspection; - } catch (saveError) { - console.error('Ошибка сохранения осмотра:', saveError); - setDbError('Не удалось сохранить осмотр'); - return inspection; - } - }, - [isDbReady] - ); - //Получение настройки const getSetting = React.useCallback( async key => { @@ -122,8 +70,7 @@ function useLocalDb() { try { return await SQLiteDatabase.getSetting(key); - } catch (getSettingError) { - console.error('Ошибка получения настройки:', getSettingError); + } catch (_getSettingError) { return null; } }, @@ -133,15 +80,11 @@ function useLocalDb() { //Сохранение настройки const setSetting = React.useCallback( async (key, value) => { - if (!isDbReady) { - console.warn('База данных не готова'); - return false; - } + if (!isDbReady) return false; try { return await SQLiteDatabase.setSetting(key, value); - } catch (setSettingError) { - console.error('Ошибка сохранения настройки:', setSettingError); + } catch (_setSettingError) { return false; } }, @@ -151,15 +94,11 @@ function useLocalDb() { //Удаление настройки const deleteSetting = React.useCallback( async key => { - if (!isDbReady) { - console.warn('База данных не готова'); - return false; - } + if (!isDbReady) return false; try { return await SQLiteDatabase.deleteSetting(key); - } catch (deleteSettingError) { - console.error('Ошибка удаления настройки:', deleteSettingError); + } catch (_deleteSettingError) { return false; } }, @@ -168,66 +107,33 @@ function useLocalDb() { //Получение всех настроек const getAllSettings = React.useCallback(async () => { - if (!isDbReady) { - console.warn('База данных не готова'); - return {}; - } + if (!isDbReady) return {}; try { return await SQLiteDatabase.getAllSettings(); - } catch (getAllSettingsError) { - console.error('Ошибка получения всех настроек:', getAllSettingsError); + } catch (_getAllSettingsError) { return {}; } }, [isDbReady]); //Очистка всех настроек const clearSettings = React.useCallback(async () => { - if (!isDbReady) { - console.warn('База данных не готова'); - return false; - } + if (!isDbReady) return false; try { return await SQLiteDatabase.clearSettings(); - } catch (clearSettingsError) { - console.error('Ошибка очистки настроек:', clearSettingsError); - return false; - } - }, [isDbReady]); - - //Очистка всех осмотров - const clearInspections = React.useCallback(async () => { - if (!isDbReady) { - console.warn('База данных не готова'); - return false; - } - - try { - const result = await SQLiteDatabase.clearInspections(); - if (result) { - setInspections([]); - setDbError(null); - } - return result; - } catch (clearInspectionsError) { - console.error('Ошибка очистки осмотров:', clearInspectionsError); - setDbError('Не удалось очистить осмотры'); + } catch (_clearSettingsError) { return false; } }, [isDbReady]); //Оптимизация базы данных const vacuum = React.useCallback(async () => { - if (!isDbReady) { - console.warn('База данных не готова'); - return false; - } + if (!isDbReady) return false; try { return await SQLiteDatabase.vacuum(); - } catch (vacuumError) { - console.error('Ошибка оптимизации базы данных:', vacuumError); + } catch (_vacuumError) { return false; } }, [isDbReady]); @@ -235,15 +141,11 @@ function useLocalDb() { //Проверка существования таблицы const checkTableExists = React.useCallback( async tableName => { - if (!isDbReady) { - console.warn('База данных не готова'); - return false; - } + if (!isDbReady) return false; try { return await SQLiteDatabase.checkTableExists(tableName); - } catch (checkTableError) { - console.error('Ошибка проверки существования таблицы:', checkTableError); + } catch (_checkTableError) { return false; } }, @@ -253,15 +155,11 @@ function useLocalDb() { //Сохранение сессии авторизации const setAuthSession = React.useCallback( async session => { - if (!isDbReady) { - console.warn('База данных не готова'); - return false; - } + if (!isDbReady) return false; try { return await SQLiteDatabase.setAuthSession(session); - } catch (setAuthSessionError) { - console.error('Ошибка сохранения сессии авторизации:', setAuthSessionError); + } catch (_setAuthSessionError) { return false; } }, @@ -270,46 +168,34 @@ function useLocalDb() { //Получение сессии авторизации const getAuthSession = React.useCallback(async () => { - if (!isDbReady) { - console.warn('База данных не готова'); - return null; - } + if (!isDbReady) return null; try { return await SQLiteDatabase.getAuthSession(); - } catch (getAuthSessionError) { - console.error('Ошибка получения сессии авторизации:', getAuthSessionError); + } catch (_getAuthSessionError) { return null; } }, [isDbReady]); //Очистка сессии авторизации const clearAuthSession = React.useCallback(async () => { - if (!isDbReady) { - console.warn('База данных не готова'); - return false; - } + if (!isDbReady) return false; try { return await SQLiteDatabase.clearAuthSession(); - } catch (clearAuthSessionError) { - console.error('Ошибка очистки сессии авторизации:', clearAuthSessionError); + } catch (_clearAuthSessionError) { return false; } }, [isDbReady]); return { isDbReady, - inspections, error: dbError, - loadInspections, - saveInspection, getSetting, setSetting, deleteSetting, getAllSettings, clearSettings, - clearInspections, vacuum, checkTableExists, setAuthSession, diff --git a/rn/app/src/hooks/useScanInputHandler.js b/rn/app/src/hooks/useScanInputHandler.js new file mode 100644 index 0000000..cef8006 --- /dev/null +++ b/rn/app/src/hooks/useScanInputHandler.js @@ -0,0 +1,76 @@ +/* + Предрейсовые осмотры - мобильное приложение + Универсальный хук обработки данных встроенного сканера для экранов с полями ввода +*/ + +//--------------------- +//Подключение библиотек +//--------------------- + +const React = require('react'); //React и хуки +const { resolveScanTarget, getNextInOrder } = require('../utils/scanInputTargetResolver'); //Резолвер цели для сканера + +//----------- +//Тело модуля +//----------- + +//Хук формирования обработчика данных от встроенного сканера +function useScanInputHandler(options) { + const { allowedFieldIds, focusedFieldRef, expectedNextTargetRef, setFieldValue, focusField, parseSegments, onSubmitForm } = options; + + const handleHardwareScan = React.useCallback( + barcode => { + const segments = parseSegments(barcode); + if (segments.length === 0) { + return; + } + + const allowed = Array.isArray(allowedFieldIds) ? allowedFieldIds : []; + if (allowed.length === 0) { + return; + } + + const focused = focusedFieldRef.current; + let targetField = resolveScanTarget(focused, allowed); + if (targetField == null) { + return; + } + + let lastTargetField = targetField; + for (let i = 0; i < segments.length; i++) { + const value = segments[i]; + setFieldValue(targetField, value); + lastTargetField = targetField; + const nextField = getNextInOrder(targetField, allowed); + if (nextField && i < segments.length - 1) { + expectedNextTargetRef.current = nextField; + focusedFieldRef.current = nextField; + targetField = nextField; + } else if (!nextField) { + focusedFieldRef.current = null; + expectedNextTargetRef.current = null; + if (typeof onSubmitForm === 'function') { + onSubmitForm(); + } + return; + } else { + expectedNextTargetRef.current = nextField; + focusedFieldRef.current = nextField; + focusField(nextField); + return; + } + } + focusedFieldRef.current = lastTargetField; + focusField(lastTargetField); + }, + [allowedFieldIds, focusedFieldRef, expectedNextTargetRef, setFieldValue, focusField, parseSegments, onSubmitForm] + ); + + return handleHardwareScan; +} + +//---------------- +//Интерфейс модуля +//---------------- + +module.exports = useScanInputHandler; diff --git a/rn/app/src/screens/AuthScreen.js b/rn/app/src/screens/AuthScreen.js index 639d219..96b16b1 100644 --- a/rn/app/src/screens/AuthScreen.js +++ b/rn/app/src/screens/AuthScreen.js @@ -23,12 +23,17 @@ const OrganizationSelectDialog = require('../components/auth/OrganizationSelectD const { useAppMessagingContext } = require('../components/layout/AppMessagingProvider'); //Контекст сообщений const { useAppModeContext } = require('../components/layout/AppModeProvider'); //Контекст режима const { useAppNavigationContext } = require('../components/layout/AppNavigationProvider'); //Контекст навигации +const { useHardwareScannerContext } = require('../components/layout/HardwareScannerProvider'); //Встроенный сканер const { useAppLocalDbContext } = require('../components/layout/AppLocalDbProvider'); //Контекст локальной БД const { useAppAuthContext } = require('../components/layout/AppAuthProvider'); //Контекст авторизации const { getAppInfo } = require('../utils/appInfo'); //Информация о приложении const { isServerUrlFieldVisible } = require('../utils/loginFormUtils'); //Утилиты формы входа +const { parseScannedSegmentsForAuth } = require('../utils/authScannerUtils'); //Разбор данных сканера по Enter для экрана входа +const { getAllowedAuthFields } = require('../utils/authFormFieldsOrder'); //Порядок и доступность полей формы для сканера +const useScanInputHandler = require('../hooks/useScanInputHandler'); //Универсальный обработчик данных сканера для полей ввода const { normalizeServerUrl, validateServerUrl } = require('../utils/validation'); //Валидация const { AUTH_SETTINGS_KEYS } = require('../config/authConfig'); //Конфиг авторизации +const { MENU_ITEM_ID_SETTINGS, MENU_ITEM_ID_ABOUT, MENU_ITEM_ID_LOGOUT, MENU_ITEM_ID_DIVIDER } = require('../config/menuItemIds'); //ID пунктов меню const { getAuthFormStore, setAuthFormStore, clearAuthFormStore } = require('../utils/authFormStore'); //Хранилище формы входа const { APP_ABOUT_TITLE, @@ -36,14 +41,22 @@ const { ORGANIZATION_SELECT_DIALOG_TITLE, MENU_ITEM_SETTINGS, MENU_ITEM_ABOUT, + MENU_ITEM_LOGOUT, AUTH_SCREEN_TITLE, AUTH_BUTTON_LOGIN, AUTH_BUTTON_LOADING, + AUTH_BUTTON_LOGOUT, AUTH_SERVER_CHANGE_CONFIRM_TITLE, AUTH_SERVER_CHANGE_CONFIRM_MESSAGE, AUTH_SERVER_CHANGE_CONFIRM_BUTTON, - AUTH_SERVER_CHANGE_CANCEL_BUTTON + AUTH_SERVER_CHANGE_CANCEL_BUTTON, + LOGOUT_CONFIRM_TITLE, + LOGOUT_CONFIRM_MESSAGE } = require('../config/messages'); //Сообщения +const { DIALOG_BUTTON_TYPE, DIALOG_CANCEL_BUTTON, getConfirmButtonOptions } = require('../config/dialogButtons'); //Кнопки диалогов +const { createLogoutHandler } = require('../utils/logoutFlow'); //Универсальный поток выхода +const { APP_COLORS } = require('../config/theme'); //Цветовая схема +const { noopCatch } = require('../utils/noop'); //Пустой обработчик для .catch() const styles = require('../styles/screens/AuthScreen.styles'); //Стили экрана //----------- @@ -53,12 +66,13 @@ const styles = require('../styles/screens/AuthScreen.styles'); //Стили эк //Экран аутентификации function AuthScreen() { const { showError, showInfo } = useAppMessagingContext(); - const { APP_MODE, mode, setOnline } = useAppModeContext(); - const { navigate, goBack, canGoBack, reset, screenParams, currentScreen, SCREENS } = useAppNavigationContext(); - const { getSetting, isDbReady, clearInspections } = useAppLocalDbContext(); + const { APP_MODE, mode, setOnline, setNotConnected } = useAppModeContext(); + const { navigate, goBack, canGoBack, reset, setInitialScreen, screenParams, currentScreen, SCREENS } = useAppNavigationContext(); + const { getSetting, isDbReady } = useAppLocalDbContext(); const { session, login, + logout, selectCompany, isLoading, getSavedCredentials, @@ -115,6 +129,42 @@ function AuthScreen() { const loginInputRef = React.useRef(null); const passwordInputRef = React.useRef(null); + //Поле ввода, на котором сейчас фокус (null — фокус сброшен) + const focusedFieldRef = React.useRef(null); + //После программного перехода фокуса сканером — следующее поле (для случая сброса фокуса устройством) + const expectedNextTargetRef = React.useRef(null); + + const { registerHandler, unregisterHandler, SCREENS: scannerScreens } = useHardwareScannerContext(); + + //Первый видимый элемент ввода на экране аутентификации (для подстановки при отсутствии фокуса) + const firstVisibleAuthField = isServerUrlFieldVisible(hideServerUrl, savedServerUrlFromSettings) ? 'server' : 'login'; + + //Список доступных полей для сканера (видимые и редактируемые) + const allowedAuthFieldIds = React.useMemo( + () => + getAllowedAuthFields({ + serverVisible: firstVisibleAuthField === 'server', + serverEditable: !(isLoading || isFromMenu), + loginEditable: !(isLoading || isFromMenu), + passwordEditable: !isLoading + }), + [firstVisibleAuthField, isLoading, isFromMenu] + ); + + //Обработчики фокуса полей + const handleServerFocus = React.useCallback(() => { + focusedFieldRef.current = 'server'; + }, []); + const handleLoginFocus = React.useCallback(() => { + focusedFieldRef.current = 'login'; + }, []); + const handlePasswordFocus = React.useCallback(() => { + focusedFieldRef.current = 'password'; + }, []); + const handleFieldBlur = React.useCallback(() => { + focusedFieldRef.current = null; + }, []); + //Актуализация ref и store при изменении полей; при размонтировании — сохранение в store React.useEffect(() => { const next = { serverUrl, username, password, savePassword, showPassword }; @@ -183,7 +233,7 @@ function AuthScreen() { setUsername(val.trim()); } }) - .catch(() => {}); + .catch(noopCatch); return () => { cancelled = true; }; @@ -240,7 +290,6 @@ function AuthScreen() { initialLoadRef.current = true; } catch (loadError) { - console.error('Ошибка загрузки credentials из меню:', loadError); setUsername(''); setPassword(''); setSavePassword(false); @@ -264,9 +313,7 @@ function AuthScreen() { if (savedLogin && typeof savedLogin === 'string') { setUsername(savedLogin.trim()); } - } catch (e) { - console.warn('Резервная загрузка логина:', e); - } + } catch (_e) {} }; loadSavedLoginOnly(); @@ -288,9 +335,7 @@ function AuthScreen() { } setSavedServerUrlFromSettings(trimmedUrl); setHideServerUrl(savedHide === 'true' || savedHide === true); - } catch (e) { - console.warn('Загрузка адреса сервера и настройки видимости:', e); - } + } catch (_e) {} }; loadServerAndVisibility(); @@ -338,8 +383,7 @@ function AuthScreen() { setPassword(''); setSavePassword(false); } - } catch (loadError) { - console.error('Ошибка загрузки настроек авторизации:', loadError); + } catch (_loadError) { } finally { setIsSettingsLoaded(true); } @@ -372,45 +416,77 @@ function AuthScreen() { }, [hideServerUrl, serverUrl, username, password, showError]); //Выполнение входа - const performLogin = React.useCallback(async () => { - let idleTimeout = null; - try { - idleTimeout = await getSetting(AUTH_SETTINGS_KEYS.IDLE_TIMEOUT); - } catch (settingError) { - console.warn('Ошибка получения таймаута:', settingError); - } + const performLogin = React.useCallback( + async formOverride => { + const data = formOverride != null ? formOverride : { serverUrl, username, password, savePassword }; + const s = (data.serverUrl != null ? String(data.serverUrl) : '').trim(); + const u = (data.username != null ? String(data.username) : '').trim(); + const p = (data.password != null ? String(data.password) : '').trim(); + const save = Boolean(data.savePassword); - const result = await login({ - serverUrl: serverUrl.trim(), - user: username.trim(), - password: password.trim(), - timeout: idleTimeout ? parseInt(idleTimeout, 10) : null, - savePassword - }); + let idleTimeout = null; + try { + idleTimeout = await getSetting(AUTH_SETTINGS_KEYS.IDLE_TIMEOUT); + } catch (_settingError) {} - if (!result.success) { - showError(result.error || 'Ошибка входа'); - return; - } - - if (result.needSelectCompany) { - setOrganizations(result.organizations); - setPendingLoginData({ - serverUrl: result.serverUrl, - sessionId: result.sessionId, - user: result.user, - savePassword: result.savePassword, - loginCredentials: result.loginCredentials + const result = await login({ + serverUrl: s, + user: u, + password: p, + timeout: idleTimeout ? parseInt(idleTimeout, 10) : null, + savePassword: save }); - setShowOrgDialog(true); + + if (!result.success) { + showError(result.error || 'Ошибка входа'); + return; + } + + if (result.needSelectCompany) { + setOrganizations(result.organizations); + setPendingLoginData({ + serverUrl: result.serverUrl, + sessionId: result.sessionId, + user: result.user, + savePassword: result.savePassword, + loginCredentials: result.loginCredentials + }); + setShowOrgDialog(true); + return; + } + + setOnline(); + clearAuthFormData(); + clearAuthFormStore(); + reset(); + }, + [login, serverUrl, username, password, savePassword, getSetting, showError, setOnline, clearAuthFormData, reset] + ); + + //Вход с данными из ref (после подстановки пароля встроенным сканером, чтобы не терять значение до применения setState) + const performLoginWithFormRef = React.useCallback(() => { + const form = formDataRef.current; + if (!form) return; + const s = (form.serverUrl != null ? String(form.serverUrl) : '').trim(); + const u = (form.username != null ? String(form.username) : '').trim(); + const p = (form.password != null ? String(form.password) : '').trim(); + if (!hideServerUrl || !form.serverUrl) { + const urlResult = validateServerUrl(s, { emptyMessage: 'Укажите адрес сервера' }); + if (urlResult !== true) { + showError(urlResult); + return; + } + } + if (!u) { + showError('Укажите логин'); return; } - - setOnline(); - clearAuthFormData(); - clearAuthFormStore(); - reset(); - }, [login, serverUrl, username, password, savePassword, getSetting, showError, setOnline, clearAuthFormData, reset]); + if (!p) { + showError('Укажите пароль'); + return; + } + performLogin(form); + }, [hideServerUrl, showError, performLogin]); //Обработчик входа (очистка локальных данных только при смене сервера относительно последнего успешного подключения) const handleLogin = React.useCallback(async () => { @@ -440,7 +516,6 @@ function AuthScreen() { title: AUTH_SERVER_CHANGE_CONFIRM_BUTTON, onPress: async () => { await clearAuthSession(); - await clearInspections(); await performLogin(); } } @@ -449,7 +524,7 @@ function AuthScreen() { } else { await performLogin(); } - }, [validateForm, serverUrl, getAuthSession, getSetting, showInfo, clearAuthSession, clearInspections, performLogin]); + }, [validateForm, serverUrl, getAuthSession, getSetting, showInfo, clearAuthSession, performLogin]); //Обработчик выбора организации const handleSelectOrganization = React.useCallback( @@ -536,6 +611,60 @@ function AuthScreen() { handleLogin(); }, [handleLogin]); + //Подстановка значения в поле формы по идентификатору (state, ref, store) + const setAuthFieldValue = React.useCallback((fieldId, value) => { + const v = value != null ? String(value) : ''; + if (fieldId === 'server') { + formDataRef.current.serverUrl = v; + setAuthFormStore({ serverUrl: v }); + setServerUrl(v); + } else if (fieldId === 'login') { + formDataRef.current.username = v; + setAuthFormStore({ username: v }); + setUsername(v); + } else if (fieldId === 'password') { + formDataRef.current.password = v; + setAuthFormStore({ password: v }); + setPassword(v); + } + }, []); + + //Перенос фокуса на поле формы по идентификатору + const focusAuthField = React.useCallback(fieldId => { + if (fieldId === 'server' && serverInputRef.current) { + serverInputRef.current.focus(); + } else if (fieldId === 'login' && loginInputRef.current) { + loginInputRef.current.focus(); + } else if (fieldId === 'password' && passwordInputRef.current) { + passwordInputRef.current.focus(); + } + }, []); + + //Обработчик встроенного сканера: подстановка в фокусное или первое доступное поле; при Enter — следующее поле или «Войти» + const handleHardwareScan = useScanInputHandler({ + allowedFieldIds: allowedAuthFieldIds, + focusedFieldRef, + expectedNextTargetRef, + setFieldValue: setAuthFieldValue, + focusField: focusAuthField, + parseSegments: parseScannedSegmentsForAuth, + onSubmitForm: performLoginWithFormRef + }); + + const authScanHandlerRef = React.useRef(handleHardwareScan); + authScanHandlerRef.current = handleHardwareScan; + const stableAuthScanHandler = React.useCallback(barcode => { + if (typeof authScanHandlerRef.current === 'function') authScanHandlerRef.current(barcode); + }, []); + + //Регистрация обработчика встроенного сканера на экране AUTH + React.useEffect(() => { + registerHandler(scannerScreens.AUTH, stableAuthScanHandler); + return function cleanup() { + unregisterHandler(scannerScreens.AUTH); + }; + }, [registerHandler, unregisterHandler, scannerScreens.AUTH, stableAuthScanHandler]); + //Обработчик открытия меню const handleMenuOpen = React.useCallback(() => { setMenuVisible(true); @@ -564,24 +693,67 @@ function AuthScreen() { }); }, [showInfo, mode, serverUrl, isDbReady]); + //Обработчик выхода с экрана аутентификации: сброс стека, нельзя вернуться назад + const logoutHandlerFromAuth = React.useMemo(() => { + if (!isFromMenu) { + return null; + } + return createLogoutHandler({ + logout, + mode, + setNotConnected, + setInitialScreen, + SCREENS, + showError, + showInfo, + logoutConfirmTitle: LOGOUT_CONFIRM_TITLE, + logoutConfirmMessage: LOGOUT_CONFIRM_MESSAGE, + logoutButtonTitle: AUTH_BUTTON_LOGOUT, + getConfirmButtonOptions, + dialogButtonTypeError: DIALOG_BUTTON_TYPE.ERROR, + dialogCancelButton: DIALOG_CANCEL_BUTTON + }); + }, [isFromMenu, logout, mode, setNotConnected, setInitialScreen, SCREENS, showError, showInfo]); + + const handleLogoutFromAuth = React.useCallback(() => { + if (logoutHandlerFromAuth) { + logoutHandlerFromAuth.handleLogout(); + } + }, [logoutHandlerFromAuth]); + //Пункты бокового меню const menuItems = React.useMemo(() => { - return [ + const items = [ { - id: 'settings', + id: MENU_ITEM_ID_SETTINGS, title: MENU_ITEM_SETTINGS, onPress: handleOpenSettings }, { - id: 'about', + id: MENU_ITEM_ID_ABOUT, title: MENU_ITEM_ABOUT, onPress: handleShowAbout } ]; - }, [handleOpenSettings, handleShowAbout]); - //Поле сервера показываем, если настройка выключена или в настройках ещё не сохранён адрес - const shouldShowServerUrl = isServerUrlFieldVisible(hideServerUrl, savedServerUrlFromSettings); + if (isFromMenu) { + items.push({ + id: MENU_ITEM_ID_DIVIDER, + type: 'divider' + }); + items.push({ + id: MENU_ITEM_ID_LOGOUT, + title: MENU_ITEM_LOGOUT, + onPress: handleLogoutFromAuth, + textStyle: { color: APP_COLORS.error }, + iconColor: APP_COLORS.error + }); + } + + return items; + }, [handleOpenSettings, handleShowAbout, handleLogoutFromAuth, isFromMenu]); + + const shouldShowServerUrl = firstVisibleAuthField === 'server'; return ( @@ -622,6 +794,8 @@ function AuthScreen() { blurOnSubmit={false} returnKeyType="next" onSubmitEditing={handleServerSubmitEditing} + onFocus={handleServerFocus} + onBlur={handleFieldBlur} /> ) : null} @@ -638,6 +812,8 @@ function AuthScreen() { blurOnSubmit={false} returnKeyType="next" onSubmitEditing={handleLoginSubmitEditing} + onFocus={handleLoginFocus} + onBlur={handleFieldBlur} /> @@ -666,6 +844,16 @@ function AuthScreen() { style={styles.loginButton} textStyle={styles.loginButtonText} /> + + {isFromMenu ? ( + + ) : null} diff --git a/rn/app/src/screens/MainScreen.js b/rn/app/src/screens/MainScreen.js index d446d69..01351c8 100644 --- a/rn/app/src/screens/MainScreen.js +++ b/rn/app/src/screens/MainScreen.js @@ -12,6 +12,7 @@ const { View } = require('react-native'); //Базовые компоненты const { useAppMessagingContext } = require('../components/layout/AppMessagingProvider'); //Контекст сообщений const { useAppModeContext } = require('../components/layout/AppModeProvider'); //Контекст режима работы const { useAppNavigationContext } = require('../components/layout/AppNavigationProvider'); //Контекст навигации +const { useHardwareScannerContext } = require('../components/layout/HardwareScannerProvider'); //Встроенный сканер const { useAppLocalDbContext } = require('../components/layout/AppLocalDbProvider'); //Контекст локальной БД const { useAppAuthContext } = require('../components/layout/AppAuthProvider'); //Контекст авторизации const AppHeader = require('../components/layout/AppHeader'); //Заголовок с меню @@ -20,10 +21,25 @@ const ScannerArea = require('../components/scanner/ScannerArea'); //Област const ScanResultModal = require('../components/scanner/ScanResultModal'); //Модальное окно результата сканирования const { AUTH_SETTINGS_KEYS } = require('../config/authConfig'); //Ключи настроек const { getAppInfo } = require('../utils/appInfo'); //Информация о приложении -const { APP_ABOUT_TITLE, SIDE_MENU_TITLE, MENU_ITEM_SETTINGS, MENU_ITEM_ABOUT, MENU_ITEM_LOGIN, MENU_ITEM_LOGOUT } = require('../config/messages'); //Сообщения +const { + APP_ABOUT_TITLE, + SIDE_MENU_TITLE, + MENU_ITEM_SETTINGS, + MENU_ITEM_ABOUT, + MENU_ITEM_LOGIN, + MENU_ITEM_LOGOUT, + OFFLINE_MODE_TITLE, + LOGOUT_CONFIRM_TITLE, + LOGOUT_CONFIRM_MESSAGE, + AUTH_BUTTON_LOGOUT +} = require('../config/messages'); //Сообщения const { DIALOG_BUTTON_TYPE, DIALOG_CANCEL_BUTTON, getConfirmButtonOptions } = require('../config/dialogButtons'); //Кнопки диалогов +const { createLogoutHandler } = require('../utils/logoutFlow'); //Универсальный поток выхода const { APP_COLORS } = require('../config/theme'); //Цветовая схема -const { shouldShowScanner } = require('../config/scannerConfig'); //Логика видимости сканера +const { MENU_ITEM_ID_SETTINGS, MENU_ITEM_ID_ABOUT, MENU_ITEM_ID_LOGIN, MENU_ITEM_ID_LOGOUT, MENU_ITEM_ID_DIVIDER } = require('../config/menuItemIds'); //ID пунктов меню +const { shouldShowScanner, HARDWARE_SCANNER_TRIGGER_EVENT_NAME, HARDWARE_SCANNER_CAMERA_HIDE_MS } = require('../config/scannerConfig'); //Логика и константы сканера +const { DeviceEventEmitter } = require('react-native'); //События нативного модуля +const HardwareScannerBridge = require('../services/HardwareScannerBridge'); //Отключение фокуса у камеры при отображении const styles = require('../styles/screens/MainScreen.styles'); //Стили экрана //----------- @@ -32,7 +48,7 @@ const styles = require('../styles/screens/MainScreen.styles'); //Стили эк //Главный экран приложения function MainScreen() { - const { showInfo, showError } = useAppMessagingContext(); + const { showInfo, showError, state: messagingState } = useAppMessagingContext(); const { mode, setNotConnected } = useAppModeContext(); const { navigate, SCREENS, setInitialScreen } = useAppNavigationContext(); const { getSetting, isDbReady: isLocalDbReady } = useAppLocalDbContext(); @@ -41,7 +57,60 @@ function MainScreen() { const [menuVisible, setMenuVisible] = React.useState(false); const [serverUrl, setServerUrl] = React.useState(''); const [alwaysShowScanner, setAlwaysShowScanner] = React.useState(false); + const [mainScannerPriority, setMainScannerPriority] = React.useState('camera'); + const [cameraResumedByTap, setCameraResumedByTap] = React.useState(false); const [scanResult, setScanResult] = React.useState(null); + const [cameraHiddenForHardwareScan, setCameraHiddenForHardwareScan] = React.useState(false); + + const { registerHandler, unregisterHandler, SCREENS: scannerScreens } = useHardwareScannerContext(); + + //Обработчик встроенного сканера: данные как есть в результат сканирования (все символы) + const handleHardwareScan = React.useCallback(barcode => { + if (barcode == null) return; + const value = typeof barcode === 'string' ? barcode : String(barcode); + setScanResult({ type: 'unknown', value: value }); + setCameraHiddenForHardwareScan(false); + }, []); + + const mainScanHandlerRef = React.useRef(handleHardwareScan); + mainScanHandlerRef.current = handleHardwareScan; + const stableMainScanHandler = React.useCallback(barcode => { + if (typeof mainScanHandlerRef.current === 'function') mainScanHandlerRef.current(barcode); + }, []); + + //Регистрация обработчика встроенного сканера + React.useEffect(() => { + registerHandler(scannerScreens.MAIN, stableMainScanHandler); + return function cleanup() { + unregisterHandler(scannerScreens.MAIN); + }; + }, [registerHandler, unregisterHandler, scannerScreens.MAIN, stableMainScanHandler]); + + //При нажатии кнопки встроенного сканера (142/141): скрываем камеру (отложенно, вне контекста события) + React.useEffect(function subscribeHardwareScannerTrigger() { + let restoreTimeoutId = null; + const sub = DeviceEventEmitter.addListener(HARDWARE_SCANNER_TRIGGER_EVENT_NAME, function onTrigger() { + setTimeout(function applyTriggerState() { + setCameraResumedByTap(false); + setCameraHiddenForHardwareScan(true); + if (restoreTimeoutId != null) clearTimeout(restoreTimeoutId); + restoreTimeoutId = setTimeout(function restoreCamera() { + restoreTimeoutId = null; + setCameraHiddenForHardwareScan(false); + }, HARDWARE_SCANNER_CAMERA_HIDE_MS); + }, 0); + }); + return function cleanup() { + sub.remove(); + if (restoreTimeoutId != null) clearTimeout(restoreTimeoutId); + }; + }, []); + + //При отображении области сканера вызываем setCameraViewsNotFocusable + React.useEffect(() => { + if (!isScannerOpen) return; + HardwareScannerBridge.setCameraViewsNotFocusable(); + }, [isScannerOpen]); //Загрузка настроек главного экрана при готовности БД React.useEffect(() => { @@ -58,9 +127,10 @@ function MainScreen() { const savedAlwaysShowScanner = await getSetting(AUTH_SETTINGS_KEYS.ALWAYS_SHOW_SCANNER); setAlwaysShowScanner(savedAlwaysShowScanner === 'true' || savedAlwaysShowScanner === true); - } catch (loadError) { - console.error('Ошибка загрузки настроек главного экрана:', loadError); - } + + const savedPriority = await getSetting(AUTH_SETTINGS_KEYS.MAIN_SCANNER_PRIORITY); + setMainScannerPriority(savedPriority === 'hardware' ? 'hardware' : 'camera'); + } catch (loadError) {} }; loadMainScreenSettings(); @@ -99,38 +169,37 @@ function MainScreen() { navigate(SCREENS.SETTINGS); }, [navigate, SCREENS.SETTINGS]); - //Выполнение выхода (для диалога подтверждения) - const performLogout = React.useCallback(async () => { - const result = await logout({ skipServerRequest: mode === 'OFFLINE' }); - - if (result.success) { - setNotConnected(); - setInitialScreen(SCREENS.AUTH); - } else { - showError(result.error || 'Ошибка выхода'); - } - }, [logout, mode, showError, setNotConnected, setInitialScreen, SCREENS.AUTH]); - - //Обработчик выхода из приложения - const handleLogout = React.useCallback(() => { - const confirmButton = getConfirmButtonOptions(DIALOG_BUTTON_TYPE.ERROR, 'Выйти', performLogout); - - showInfo('Вы уверены, что хотите выйти?', { - title: 'Подтверждение выхода', - buttons: [DIALOG_CANCEL_BUTTON, confirmButton] - }); - }, [showInfo, performLogout]); + //Универсальный обработчик выхода + const { handleLogout } = React.useMemo( + () => + createLogoutHandler({ + logout, + mode, + setNotConnected, + setInitialScreen, + SCREENS, + showError, + showInfo, + logoutConfirmTitle: LOGOUT_CONFIRM_TITLE, + logoutConfirmMessage: LOGOUT_CONFIRM_MESSAGE, + logoutButtonTitle: AUTH_BUTTON_LOGOUT, + getConfirmButtonOptions, + dialogButtonTypeError: DIALOG_BUTTON_TYPE.ERROR, + dialogCancelButton: DIALOG_CANCEL_BUTTON + }), + [logout, mode, setNotConnected, setInitialScreen, SCREENS, showError, showInfo] + ); //Пункты бокового меню const menuItems = React.useMemo(() => { const items = [ { - id: 'settings', + id: MENU_ITEM_ID_SETTINGS, title: MENU_ITEM_SETTINGS, onPress: handleOpenSettings }, { - id: 'about', + id: MENU_ITEM_ID_ABOUT, title: MENU_ITEM_ABOUT, onPress: handleShowAbout } @@ -138,14 +207,14 @@ function MainScreen() { //Добавляем разделитель перед кнопками авторизации items.push({ - id: 'divider', + id: MENU_ITEM_ID_DIVIDER, type: 'divider' }); //Кнопка "Вход" для оффлайн режима if (mode === 'OFFLINE') { items.push({ - id: 'login', + id: MENU_ITEM_ID_LOGIN, title: MENU_ITEM_LOGIN, onPress: handleLogin }); @@ -154,10 +223,11 @@ function MainScreen() { //Кнопка "Выход" для онлайн/оффлайн режима (когда есть сессия) if ((mode === 'ONLINE' || mode === 'OFFLINE') && isAuthenticated) { items.push({ - id: 'logout', + id: MENU_ITEM_ID_LOGOUT, title: MENU_ITEM_LOGOUT, onPress: handleLogout, - textStyle: { color: APP_COLORS.error } + textStyle: { color: APP_COLORS.error }, + iconColor: APP_COLORS.error }); } @@ -175,18 +245,52 @@ function MainScreen() { }, []); //Видимость сканера + const isOfflineMessageVisible = Boolean(messagingState.visible && messagingState.title === OFFLINE_MODE_TITLE); + const isAboutMessageVisible = Boolean(messagingState.visible && messagingState.title === APP_ABOUT_TITLE); const isScannerOpen = shouldShowScanner({ alwaysShowScanner, hasScanResult: scanResult != null, - isStartupCheckInProgress: isStartupSessionCheckInProgress + isStartupCheckInProgress: isStartupSessionCheckInProgress, + isOfflineMessageVisible, + isAboutMessageVisible }); + const cameraShown = isScannerOpen && !cameraHiddenForHardwareScan; + const cameraActiveOnMain = !(alwaysShowScanner && mainScannerPriority === 'hardware') || cameraResumedByTap; + + //Когда камера на главном экране видима + React.useEffect( + function cameraVisibleOnMain() { + if (!cameraShown) return; + const t1 = setTimeout(function run1() { + HardwareScannerBridge.setCameraViewsNotFocusable(); + }, 100); + const t2 = setTimeout(function run2() { + HardwareScannerBridge.setCameraViewsNotFocusable(); + }, 400); + return function cleanup() { + clearTimeout(t1); + clearTimeout(t2); + }; + }, + [cameraShown] + ); + + const handleResumeCameraByTap = React.useCallback(function resumeCameraByTap() { + setCameraResumedByTap(true); + }, []); return ( - + { + if (isServerUrlDialogVisible && serverUrlDialogRef.current) { + serverUrlDialogRef.current.setValueFromScanner(barcode); + } else if (isIdleTimeoutDialogVisible && idleTimeoutDialogRef.current) { + idleTimeoutDialogRef.current.setValueFromScanner(barcode); + } else if (isServerRequestTimeoutDialogVisible && serverRequestTimeoutDialogRef.current) { + serverRequestTimeoutDialogRef.current.setValueFromScanner(barcode); + } + }, + [isServerUrlDialogVisible, isIdleTimeoutDialogVisible, isServerRequestTimeoutDialogVisible] + ); + + const settingsScanHandlerRef = React.useRef(handleHardwareScan); + settingsScanHandlerRef.current = handleHardwareScan; + const stableSettingsScanHandler = React.useCallback(barcode => { + if (typeof settingsScanHandlerRef.current === 'function') settingsScanHandlerRef.current(barcode); + }, []); + + //Регистрация обработчика встроенного сканера на экране настроек + React.useEffect(() => { + registerHandler(scannerScreens.SETTINGS, stableSettingsScanHandler); + return function cleanup() { + unregisterHandler(scannerScreens.SETTINGS); + }; + }, [registerHandler, unregisterHandler, scannerScreens.SETTINGS, stableSettingsScanHandler]); + //Загрузка сохраненных настроек при готовности БД React.useEffect(() => { //Выходим, если БД не готова или уже загрузили настройки @@ -78,13 +118,16 @@ function SettingsScreen() { setHideServerUrl(savedHideServerUrl === 'true' || savedHideServerUrl === true); const savedIdleTimeout = await getSetting(AUTH_SETTINGS_KEYS.IDLE_TIMEOUT); - if (savedIdleTimeout) { - setIdleTimeout(savedIdleTimeout); + setIdleTimeout(savedIdleTimeout != null ? String(savedIdleTimeout).trim() : ''); + + const savedServerRequestTimeout = await getSetting(AUTH_SETTINGS_KEYS.SERVER_REQUEST_TIMEOUT); + const serverRequestTimeoutValue = savedServerRequestTimeout != null ? String(savedServerRequestTimeout).trim() : ''; + if (serverRequestTimeoutValue) { + setServerRequestTimeout(serverRequestTimeoutValue); } else { - //Устанавливаем значение по умолчанию - const defaultValue = String(DEFAULT_IDLE_TIMEOUT); - setIdleTimeout(defaultValue); - await setSetting(AUTH_SETTINGS_KEYS.IDLE_TIMEOUT, defaultValue); + const defaultValue = String(DEFAULT_SERVER_REQUEST_TIMEOUT); + setServerRequestTimeout(defaultValue); + await setSetting(AUTH_SETTINGS_KEYS.SERVER_REQUEST_TIMEOUT, defaultValue); } //Получаем или генерируем идентификатор устройства @@ -93,8 +136,10 @@ function SettingsScreen() { const savedAlwaysShowScanner = await getSetting(AUTH_SETTINGS_KEYS.ALWAYS_SHOW_SCANNER); setAlwaysShowScanner(savedAlwaysShowScanner === 'true' || savedAlwaysShowScanner === true); + + const savedPriority = await getSetting(AUTH_SETTINGS_KEYS.MAIN_SCANNER_PRIORITY); + setMainScannerPriority(savedPriority === 'hardware' ? 'hardware' : 'camera'); } catch (error) { - console.error('Ошибка загрузки настроек:', error); showError('Не удалось загрузить настройки'); } finally { setIsLoading(false); @@ -125,6 +170,12 @@ function SettingsScreen() { [] ); + //Стиль поля времени ожидания сервера при нажатии + const getServerRequestTimeoutFieldPressableStyle = React.useCallback( + ({ pressed }) => [styles.serverUrlField, pressed && styles.serverUrlFieldPressed], + [] + ); + //Открытие диалога ввода URL сервера (только в режиме "Не подключено") const handleOpenServerUrlDialog = React.useCallback(() => { if (!isServerUrlEditable) { @@ -159,7 +210,6 @@ function SettingsScreen() { showError('Не удалось сохранить настройки'); } } catch (error) { - console.error('Ошибка сохранения настроек:', error); showError('Не удалось сохранить настройки'); } @@ -181,13 +231,41 @@ function SettingsScreen() { showError('Не удалось сохранить настройку'); } } catch (error) { - console.error('Ошибка сохранения настройки сканера:', error); showError('Не удалось сохранить настройку'); } }, [setSetting, showSuccess, showError] ); + //Выбор приоритета на главном экране: камера или встроенный сканер + const handleSelectScannerPriority = React.useCallback( + async value => { + if (value !== 'camera' && value !== 'hardware') return; + try { + const success = await setSetting(AUTH_SETTINGS_KEYS.MAIN_SCANNER_PRIORITY, value); + if (success) { + setMainScannerPriority(value); + showSuccess('Настройка сохранена'); + } else { + showError('Не удалось сохранить настройку'); + } + } catch (error) { + showError('Не удалось сохранить настройку'); + } + }, + [setSetting, showSuccess, showError] + ); + + //Выбор приоритета: камера + const handleSelectCameraPriority = React.useCallback(() => { + handleSelectScannerPriority('camera'); + }, [handleSelectScannerPriority]); + + //Выбор приоритета: встроенный сканер + const handleSelectHardwarePriority = React.useCallback(() => { + handleSelectScannerPriority('hardware'); + }, [handleSelectScannerPriority]); + //Переключатель скрытия URL сервера в окне логина const handleToggleHideServerUrl = React.useCallback( async value => { @@ -201,7 +279,6 @@ function SettingsScreen() { showError('Не удалось сохранить настройку'); } } catch (error) { - console.error('Ошибка сохранения настройки:', error); showError('Не удалось сохранить настройку'); } }, @@ -218,6 +295,16 @@ function SettingsScreen() { setIsIdleTimeoutDialogVisible(false); }, []); + //Открытие диалога ввода времени ожидания ответа от сервера + const handleOpenServerRequestTimeoutDialog = React.useCallback(() => { + setIsServerRequestTimeoutDialogVisible(true); + }, []); + + //Закрытие диалога ввода времени ожидания ответа от сервера + const handleCloseServerRequestTimeoutDialog = React.useCallback(() => { + setIsServerRequestTimeoutDialogVisible(false); + }, []); + //Сохранение времени простоя const handleSaveIdleTimeout = React.useCallback( async value => { @@ -235,7 +322,6 @@ function SettingsScreen() { showError('Не удалось сохранить настройку'); } } catch (error) { - console.error('Ошибка сохранения времени простоя:', error); showError('Не удалось сохранить настройку'); } @@ -244,22 +330,37 @@ function SettingsScreen() { [setSetting, showSuccess, showError] ); - //Выполнение очистки кэша (для диалога подтверждения) - const performClearCache = React.useCallback(async () => { - try { - const success = await clearInspections(); - if (success) { - showSuccess('Кэш успешно очищен'); - } else { - showError('Не удалось очистить кэш'); - } - } catch (error) { - console.error('Ошибка очистки кэша:', error); - showError('Не удалось очистить кэш'); - } - }, [showSuccess, showError, clearInspections]); + //Сохранение времени ожидания ответа от сервера + const handleSaveServerRequestTimeout = React.useCallback( + async value => { + setIsServerRequestTimeoutDialogVisible(false); + setIsLoading(true); - //Очистка кэша (осмотров) + try { + const trimmedValue = value != null ? String(value).trim() : ''; + const success = await setSetting(AUTH_SETTINGS_KEYS.SERVER_REQUEST_TIMEOUT, trimmedValue); + + if (success) { + setServerRequestTimeout(trimmedValue); + showSuccess('Время ожидания сервера сохранено'); + } else { + showError('Не удалось сохранить настройку'); + } + } catch (error) { + showError('Не удалось сохранить настройку'); + } + + setIsLoading(false); + }, + [setSetting, showSuccess, showError] + ); + + //Выполнение очистки кэша + const performClearCache = React.useCallback(async () => { + showSuccess('Кэш успешно очищен'); + }, [showSuccess]); + + //Очистка кэша — диалог подтверждения, по подтверждению ничего не выполняется const handleClearCache = React.useCallback(() => { const confirmButton = getConfirmButtonOptions(DIALOG_BUTTON_TYPE.ERROR, 'Очистить', performClearCache); @@ -273,29 +374,32 @@ function SettingsScreen() { //Подключён (онлайн/офлайн): сбрасываем только непричастные к подключению настройки; не подключён: полный сброс const performResetSettings = React.useCallback(async () => { try { - const defaultValue = String(DEFAULT_IDLE_TIMEOUT); - if (mode === APP_MODE.NOT_CONNECTED) { const success = await clearSettings(); if (success) { + const defaultServerTimeout = String(DEFAULT_SERVER_REQUEST_TIMEOUT); + await setSetting(AUTH_SETTINGS_KEYS.SERVER_REQUEST_TIMEOUT, defaultServerTimeout); setServerUrl(''); setHideServerUrl(false); setAlwaysShowScanner(false); - setIdleTimeout(defaultValue); - await setSetting(AUTH_SETTINGS_KEYS.IDLE_TIMEOUT, defaultValue); + setMainScannerPriority('camera'); + setIdleTimeout(''); + setServerRequestTimeout(defaultServerTimeout); setNotConnected(); showSuccess(SETTINGS_RESET_SUCCESS_MESSAGE); } else { showError('Не удалось сбросить настройки'); } } else { - //Подключён (онлайн или офлайн): сбрасываем только время простоя - await setSetting(AUTH_SETTINGS_KEYS.IDLE_TIMEOUT, defaultValue); - setIdleTimeout(defaultValue); + //Подключён (онлайн или офлайн): сбрасываем только время простоя и таймаут сервера + const defaultServerTimeout = String(DEFAULT_SERVER_REQUEST_TIMEOUT); + await setSetting(AUTH_SETTINGS_KEYS.IDLE_TIMEOUT, ''); + await setSetting(AUTH_SETTINGS_KEYS.SERVER_REQUEST_TIMEOUT, defaultServerTimeout); + setIdleTimeout(''); + setServerRequestTimeout(defaultServerTimeout); showSuccess(SETTINGS_RESET_SUCCESS_MESSAGE); } } catch (error) { - console.error('Ошибка сброса настроек:', error); showError('Не удалось сбросить настройки'); } }, [ @@ -304,6 +408,7 @@ function SettingsScreen() { setServerUrl, setHideServerUrl, setIdleTimeout, + setServerRequestTimeout, setNotConnected, clearSettings, setSetting, @@ -332,7 +437,6 @@ function SettingsScreen() { showError('Не удалось оптимизировать базу данных'); } } catch (error) { - console.error('Ошибка оптимизации БД:', error); showError('Не удалось оптимизировать базу данных'); } finally { setIsLoading(false); @@ -443,20 +547,46 @@ function SettingsScreen() { - - - Главный экран - + {mode === APP_MODE.ONLINE || mode === APP_MODE.OFFLINE ? ( + + + Главный экран + - - + + + + + {alwaysShowScanner ? ( + + + {SCANNER_PRIORITY_LABEL} + + + {SCANNER_PRIORITY_CAMERA} + {mainScannerPriority === 'camera' ? : null} + + + {SCANNER_PRIORITY_HARDWARE} + {mainScannerPriority === 'hardware' ? : null} + + + ) : null} - + ) : null} @@ -468,8 +598,22 @@ function SettingsScreen() { + + {idleTimeout || 'Не задано'} + + + + + Максимальное время ожидания ответа от сервера (секунд) + + + - {idleTimeout || String(DEFAULT_IDLE_TIMEOUT)} + {serverRequestTimeout || String(DEFAULT_SERVER_REQUEST_TIMEOUT)} @@ -562,6 +706,7 @@ function SettingsScreen() { + + ); diff --git a/rn/app/src/services/HardwareScannerBridge.js b/rn/app/src/services/HardwareScannerBridge.js new file mode 100644 index 0000000..a88269c --- /dev/null +++ b/rn/app/src/services/HardwareScannerBridge.js @@ -0,0 +1,78 @@ +/* + Предрейсовые осмотры - мобильное приложение + Мост встроенного сканера +*/ + +//--------------------- +//Подключение библиотек +//--------------------- + +const { NativeModules, Platform, DeviceEventEmitter } = require('react-native'); +const { HARDWARE_SCANNER_EVENT_NAME } = require('../config/scannerConfig'); + +//--------- +//Состояние +//--------- + +let hardwareCallback = null; +let isListening = false; + +const { HardwareScannerModule } = NativeModules; + +//----------- +//Тело модуля +//----------- + +//Доставка данных в колбэк в следующем тике (данные как есть, без обрезки) +function deliverBarcode(barcode) { + if (!isListening || !hardwareCallback) return; + const s = typeof barcode === 'string' ? barcode : ''; + if (s.length === 0) return; + setTimeout(function deliverNextTick() { + if (!isListening || !hardwareCallback) return; + try { + hardwareCallback(s); + } catch (_err) {} + }, 0); +} + +//Запуск прослушивания встроенного сканера (событие по мосту); возвращает функцию отписки +function startHardwareListener(onData) { + if (Platform.OS !== 'android') return function noopUnsubscribe() {}; + if (!HardwareScannerModule) return function noopUnsubscribe() {}; + if (typeof onData !== 'function') return function noopUnsubscribe() {}; + if (isListening && hardwareCallback) return function noopUnsubscribe() {}; + + hardwareCallback = onData; + isListening = true; + + const subscription = DeviceEventEmitter.addListener(HARDWARE_SCANNER_EVENT_NAME, function onBarcodeEvent(event) { + const barcode = event && event.barcode != null ? String(event.barcode) : ''; + deliverBarcode(barcode); + }); + + return function unsubscribe() { + isListening = false; + if (subscription && typeof subscription.remove === 'function') subscription.remove(); + hardwareCallback = null; + }; +} + +//Отключить фокус у видов камеры в иерархии, чтобы встроенный сканер получал клавиши при отображении камеры (только Android) +function setCameraViewsNotFocusable() { + if (Platform.OS !== 'android' || !HardwareScannerModule) return; + try { + if (typeof HardwareScannerModule.setCameraViewsNotFocusable === 'function') { + HardwareScannerModule.setCameraViewsNotFocusable(); + } + } catch (_) {} +} + +//---------------- +//Интерфейс модуля +//---------------- + +module.exports = { + startHardwareListener, + setCameraViewsNotFocusable +}; diff --git a/rn/app/src/styles/common/InputDialog.styles.js b/rn/app/src/styles/common/InputDialog.styles.js index 5a82ee6..e2d63fd 100644 --- a/rn/app/src/styles/common/InputDialog.styles.js +++ b/rn/app/src/styles/common/InputDialog.styles.js @@ -83,14 +83,17 @@ const styles = StyleSheet.create({ color: APP_COLORS.textSecondary }, input: { - height: UI.INPUT_HEIGHT, + minHeight: UI.INPUT_HEIGHT, borderWidth: 1, borderColor: APP_COLORS.borderMedium, borderRadius: UI.BORDER_RADIUS, paddingHorizontal: responsiveSpacing(3), + paddingVertical: responsiveSpacing(2.5), fontSize: UI.FONT_SIZE_MD, color: APP_COLORS.textPrimary, - backgroundColor: APP_COLORS.surface + backgroundColor: APP_COLORS.surface, + includeFontPadding: false, + textAlignVertical: 'center' }, inputFocused: { borderColor: APP_COLORS.primary, diff --git a/rn/app/src/styles/layout/AppIdleProvider.styles.js b/rn/app/src/styles/layout/AppIdleProvider.styles.js new file mode 100644 index 0000000..f74633e --- /dev/null +++ b/rn/app/src/styles/layout/AppIdleProvider.styles.js @@ -0,0 +1,26 @@ +/* + Предрейсовые осмотры - мобильное приложение + Стили обёртки провайдера простоя +*/ + +//--------------------- +//Подключение библиотек +//--------------------- + +const { StyleSheet } = require('react-native'); //StyleSheet + +//----------- +//Тело модуля +//----------- + +const styles = StyleSheet.create({ + touchArea: { + flex: 1 + } +}); + +//---------------- +//Интерфейс модуля +//---------------- + +module.exports = styles; diff --git a/rn/app/src/styles/menu/MenuItemIcon.styles.js b/rn/app/src/styles/menu/MenuItemIcon.styles.js new file mode 100644 index 0000000..39e9b2a --- /dev/null +++ b/rn/app/src/styles/menu/MenuItemIcon.styles.js @@ -0,0 +1,112 @@ +/* + Предрейсовые осмотры - мобильное приложение + Стили компонента иконки пункта меню +*/ + +//--------------------- +//Подключение библиотек +//--------------------- + +const { StyleSheet } = require('react-native'); //StyleSheet React Native +const { APP_COLORS } = require('../../config/theme'); //Цветовая схема приложения +const { responsiveSize } = require('../../utils/responsive'); //Адаптивные утилиты + +//----------- +//Тело модуля +//----------- + +//Базовый размер контейнера иконки (должен совпадать с menuItemIcon в MenuItem.styles) +const ICON_SIZE = responsiveSize(24); + +//Стили компонента иконки пункта меню +const styles = StyleSheet.create({ + container: { + width: ICON_SIZE, + height: ICON_SIZE, + alignItems: 'center', + justifyContent: 'center' + }, + + //Иконка «Настройки» (шестерёнка): центральный круг + settingsCircle: { + position: 'absolute', + width: responsiveSize(10), + height: responsiveSize(10), + borderRadius: responsiveSize(5), + borderWidth: responsiveSize(2), + borderColor: APP_COLORS.textPrimary + }, + settingsTooth: { + position: 'absolute', + width: responsiveSize(2), + height: responsiveSize(6), + backgroundColor: APP_COLORS.textPrimary, + borderRadius: 1 + }, + + //Иконка «О приложении» (информация): круг с буквой i + aboutCircle: { + width: responsiveSize(18), + height: responsiveSize(18), + borderRadius: responsiveSize(9), + borderWidth: responsiveSize(2), + borderColor: APP_COLORS.textPrimary, + alignItems: 'center', + justifyContent: 'center' + }, + aboutText: { + fontSize: responsiveSize(12), + fontWeight: '700', + color: APP_COLORS.textPrimary + }, + + //Иконка «Вход»: рамка и стрелка вправо + loginFrame: { + width: responsiveSize(14), + height: responsiveSize(14), + borderWidth: responsiveSize(2), + borderColor: APP_COLORS.textPrimary, + borderRadius: responsiveSize(2), + alignItems: 'center', + justifyContent: 'center' + }, + loginArrow: { + width: 0, + height: 0, + borderTopWidth: responsiveSize(4), + borderBottomWidth: responsiveSize(4), + borderLeftWidth: responsiveSize(5), + borderTopColor: 'transparent', + borderBottomColor: 'transparent', + borderLeftColor: APP_COLORS.textPrimary, + marginLeft: responsiveSize(2) + }, + + //Иконка «Выход»: рамка и стрелка влево + logoutFrame: { + width: responsiveSize(14), + height: responsiveSize(14), + borderWidth: responsiveSize(2), + borderColor: APP_COLORS.textPrimary, + borderRadius: responsiveSize(2), + alignItems: 'center', + justifyContent: 'center' + }, + logoutArrow: { + width: 0, + height: 0, + borderTopWidth: responsiveSize(4), + borderBottomWidth: responsiveSize(4), + borderRightWidth: responsiveSize(5), + borderTopColor: 'transparent', + borderBottomColor: 'transparent', + borderRightColor: APP_COLORS.textPrimary, + marginRight: responsiveSize(2) + } +}); + +//---------------- +//Интерфейс модуля +//---------------- + +module.exports = styles; diff --git a/rn/app/src/styles/scanner/CameraPausedOverlay.styles.js b/rn/app/src/styles/scanner/CameraPausedOverlay.styles.js new file mode 100644 index 0000000..b160594 --- /dev/null +++ b/rn/app/src/styles/scanner/CameraPausedOverlay.styles.js @@ -0,0 +1,48 @@ +/* + Предрейсовые осмотры - мобильное приложение + Стили оверлея «камера на паузе» (серая область с подсказкой) +*/ + +//--------------------- +//Подключение библиотек +//--------------------- + +const { StyleSheet } = require('react-native'); //StyleSheet React Native +const { APP_COLORS } = require('../../config/theme'); //Цветовая схема +const { responsiveSpacing } = require('../../utils/responsive'); //Адаптивные утилиты +const { UI } = require('../../config/appConfig'); //Конфигурация UI + +//--------- +//Константы +//--------- + +//Фон оверлея: серый с прозрачностью (область камеры на паузе) +const OVERLAY_BACKGROUND = 'rgba(96, 96, 96, 0.65)'; + +//----------- +//Тело модуля +//----------- + +//Стили оверлея паузы камеры +const styles = StyleSheet.create({ + container: { + ...StyleSheet.absoluteFillObject, + backgroundColor: OVERLAY_BACKGROUND, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: responsiveSpacing(4), + borderRadius: UI.BORDER_RADIUS + }, + message: { + color: APP_COLORS.textInverse, + fontSize: UI.FONT_SIZE_MD, + textAlign: 'center', + maxWidth: '100%' + } +}); + +//---------------- +//Интерфейс модуля +//---------------- + +module.exports = styles; diff --git a/rn/app/src/styles/scanner/ScanResultModal.styles.js b/rn/app/src/styles/scanner/ScanResultModal.styles.js index 80c4e02..beb4068 100644 --- a/rn/app/src/styles/scanner/ScanResultModal.styles.js +++ b/rn/app/src/styles/scanner/ScanResultModal.styles.js @@ -7,7 +7,7 @@ //Подключение библиотек //--------------------- -const { StyleSheet, Platform } = require('react-native'); //StyleSheet React Native +const { StyleSheet, Platform, Dimensions } = require('react-native'); //StyleSheet React Native const { APP_COLORS } = require('../../config/theme'); //Цветовая схема приложения const { UI } = require('../../config/appConfig'); //Конфигурация UI const { responsiveSpacing, widthPercentage } = require('../../utils/responsive'); //Адаптивные утилиты @@ -16,18 +16,50 @@ const { responsiveSpacing, widthPercentage } = require('../../utils/responsive') //Тело модуля //----------- +const windowSize = Dimensions.get('window'); +const resultCardWidth = widthPercentage(92); + //Стили модального окна результата сканирования const styles = StyleSheet.create({ - backdrop: { + modalRoot: { flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: APP_COLORS.overlay + }, + overlayContent: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + justifyContent: 'center', + alignItems: 'center', + zIndex: 1001 + }, + overlay: { + position: 'absolute', + left: 0, + top: 0, + width: windowSize.width, + height: windowSize.height, backgroundColor: APP_COLORS.overlay, + zIndex: 1000 + }, + backdrop: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, justifyContent: 'center', alignItems: 'center', paddingHorizontal: UI.PADDING }, container: { - width: '100%', - maxWidth: widthPercentage(90), + width: resultCardWidth, + minWidth: resultCardWidth, + maxWidth: resultCardWidth, backgroundColor: APP_COLORS.surface, borderRadius: UI.BORDER_RADIUS, ...Platform.select({ @@ -83,6 +115,7 @@ const styles = StyleSheet.create({ color: APP_COLORS.textSecondary }, valueBlock: { + width: '100%', paddingVertical: responsiveSpacing(2), paddingHorizontal: responsiveSpacing(3), backgroundColor: APP_COLORS.surfaceAlt, @@ -90,6 +123,7 @@ const styles = StyleSheet.create({ marginBottom: responsiveSpacing(4) }, valueText: { + width: '100%', fontSize: UI.FONT_SIZE_MD, color: APP_COLORS.textPrimary }, diff --git a/rn/app/src/styles/scanner/ScannerArea.styles.js b/rn/app/src/styles/scanner/ScannerArea.styles.js index 3c0106f..50fa48b 100644 --- a/rn/app/src/styles/scanner/ScannerArea.styles.js +++ b/rn/app/src/styles/scanner/ScannerArea.styles.js @@ -23,6 +23,14 @@ const styles = StyleSheet.create({ minHeight: responsiveSpacing(30), marginBottom: responsiveSpacing(2) }, + scannerWrapper: { + flex: 1, + width: '100%' + }, + //Область тапа для включения камеры (оверлей поверх сканера на паузе) + cameraPausedOverlayTouchable: { + ...StyleSheet.absoluteFillObject + }, modalContainer: { flex: 1 }, diff --git a/rn/app/src/styles/scanner/ScannerPlaceholder.styles.js b/rn/app/src/styles/scanner/ScannerPlaceholder.styles.js index 3800938..9a821e1 100644 --- a/rn/app/src/styles/scanner/ScannerPlaceholder.styles.js +++ b/rn/app/src/styles/scanner/ScannerPlaceholder.styles.js @@ -28,6 +28,15 @@ const styles = StyleSheet.create({ }, button: { minWidth: responsiveSpacing(40) + }, + hint: { + textAlign: 'center', + color: APP_COLORS.textSecondary, + marginBottom: responsiveSpacing(3) + }, + secondaryButton: { + marginTop: responsiveSpacing(2), + minWidth: responsiveSpacing(40) } }); diff --git a/rn/app/src/styles/screens/AuthScreen.styles.js b/rn/app/src/styles/screens/AuthScreen.styles.js index 7c380e9..3af62b1 100644 --- a/rn/app/src/styles/screens/AuthScreen.styles.js +++ b/rn/app/src/styles/screens/AuthScreen.styles.js @@ -56,6 +56,16 @@ const styles = StyleSheet.create({ color: APP_COLORS.white, fontWeight: '600' }, + logoutButton: { + backgroundColor: 'transparent', + borderWidth: 1, + borderColor: APP_COLORS.error, + marginTop: responsiveSpacing(3) + }, + logoutButtonText: { + color: APP_COLORS.error, + fontWeight: '600' + }, hint: { textAlign: 'center', marginTop: responsiveSpacing(4), diff --git a/rn/app/src/styles/screens/SettingsScreen.styles.js b/rn/app/src/styles/screens/SettingsScreen.styles.js index 6960f8d..bfcfc88 100644 --- a/rn/app/src/styles/screens/SettingsScreen.styles.js +++ b/rn/app/src/styles/screens/SettingsScreen.styles.js @@ -88,6 +88,33 @@ const styles = StyleSheet.create({ switchRow: { marginTop: responsiveSpacing(3) }, + prioritySection: { + marginTop: responsiveSpacing(4) + }, + priorityRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: responsiveSpacing(2.5), + paddingHorizontal: responsiveSpacing(2), + marginTop: responsiveSpacing(2), + borderRadius: UI.BORDER_RADIUS, + borderWidth: 1, + borderColor: APP_COLORS.borderSubtle + }, + priorityRowSelected: { + borderColor: APP_COLORS.primary, + backgroundColor: APP_COLORS.primaryExtraLight + }, + priorityRowText: { + flex: 1, + fontSize: UI.FONT_SIZE_MD, + color: APP_COLORS.textPrimary + }, + priorityCheck: { + fontSize: UI.FONT_SIZE_MD, + color: APP_COLORS.primary + }, actionButton: { marginTop: responsiveSpacing(3) }, diff --git a/rn/app/src/utils/authFormFieldsOrder.js b/rn/app/src/utils/authFormFieldsOrder.js new file mode 100644 index 0000000..156b3e6 --- /dev/null +++ b/rn/app/src/utils/authFormFieldsOrder.js @@ -0,0 +1,53 @@ +/* + Предрейсовые осмотры - мобильное приложение + Порядок и доступность полей формы аутентификации для подстановки данных сканера +*/ + +//--------------------- +//Подключение библиотек +//--------------------- + +const { AUTH_FORM_FIELD_ORDER } = require('../config/authFormFieldsConfig'); //Порядок полей формы +const { resolveScanTarget, getNextInOrder } = require('./scanInputTargetResolver'); //Резолвер цели для сканера + +//----------- +//Тело модуля +//----------- + +//Проверка доступности поля по опциям (видимость и редактируемость) +function isFieldAllowed(fieldId, options) { + if (!options || typeof options !== 'object') return false; + const { serverVisible, serverEditable, loginEditable, passwordEditable } = options; + if (fieldId === 'server') return Boolean(serverVisible && serverEditable); + if (fieldId === 'login') return Boolean(loginEditable); + if (fieldId === 'password') return Boolean(passwordEditable); + return false; +} + +//Возвращает список идентификаторов полей формы, доступных для ввода (видимых и не заблокированных) +function getAllowedAuthFields(options) { + if (!options || typeof options !== 'object') { + return []; + } + return AUTH_FORM_FIELD_ORDER.filter(fieldId => isFieldAllowed(fieldId, options)); +} + +//Возвращает идентификатор следующего поля после текущего в списке доступных, либо null (после последнего — отправка формы) +function getNextAuthField(currentFieldId, allowedFields) { + return getNextInOrder(currentFieldId, allowedFields); +} + +//Определяет поле для первого сегмента сканера +function getScannerInitialTarget(focusedFieldId, allowedFields) { + return resolveScanTarget(focusedFieldId, allowedFields); +} + +//---------------- +//Интерфейс модуля +//---------------- + +module.exports = { + getAllowedAuthFields, + getNextAuthField, + getScannerInitialTarget +}; diff --git a/rn/app/src/utils/authScannerUtils.js b/rn/app/src/utils/authScannerUtils.js new file mode 100644 index 0000000..5a1fc67 --- /dev/null +++ b/rn/app/src/utils/authScannerUtils.js @@ -0,0 +1,31 @@ +/* + Предрейсовые осмотры - мобильное приложение + Утилиты разбора данных от встроенного сканера на экране аутентификации +*/ + +//----------- +//Тело модуля +//----------- + +//Разделители строк (Enter) в данных штрихкода (CRLF и LF) +const LINE_BREAK_REGEX = /\r?\n/; + +//Разбивает отсканированную строку на сегменты по Enter (данные как есть, без trim) +function parseScannedSegmentsForAuth(rawString) { + if (rawString == null || typeof rawString !== 'string') { + return []; + } + if (rawString === '') { + return []; + } + return rawString.split(LINE_BREAK_REGEX); +} + +//---------------- +//Интерфейс модуля +//---------------- + +module.exports = { + parseScannedSegmentsForAuth, + LINE_BREAK_REGEX +}; diff --git a/rn/app/src/utils/deviceId.js b/rn/app/src/utils/deviceId.js index 9ef5aba..6e47884 100644 --- a/rn/app/src/utils/deviceId.js +++ b/rn/app/src/utils/deviceId.js @@ -107,8 +107,7 @@ const getAndroidId = async () => { } return null; - } catch (error) { - console.warn('Не удалось получить Android ID:', error); + } catch (_error) { return null; } }; @@ -134,8 +133,7 @@ const getIosId = async () => { } return null; - } catch (error) { - console.warn('Не удалось получить iOS ID:', error); + } catch (_error) { return null; } }; diff --git a/rn/app/src/utils/logoutFlow.js b/rn/app/src/utils/logoutFlow.js new file mode 100644 index 0000000..0add54d --- /dev/null +++ b/rn/app/src/utils/logoutFlow.js @@ -0,0 +1,61 @@ +/* + Предрейсовые осмотры - мобильное приложение + Универсальный поток выхода из приложения +*/ + +//--------------------- +//Подключение библиотек +//--------------------- + +//----------- +//Тело модуля +//----------- + +//Обработчики: выполнение выхода и показ диалога подтверждения +function createLogoutHandler(deps) { + const { + logout, + mode, + setNotConnected, + setInitialScreen, + SCREENS, + showError, + showInfo, + logoutConfirmTitle, + logoutConfirmMessage, + logoutButtonTitle, + getConfirmButtonOptions, + dialogButtonTypeError, + dialogCancelButton + } = deps; + + const performLogout = async () => { + const result = await logout({ skipServerRequest: mode === 'OFFLINE' }); + + if (result.success) { + setNotConnected(); + setInitialScreen(SCREENS.AUTH); + } else { + showError(result.error || 'Ошибка выхода'); + } + }; + + const handleLogout = () => { + const confirmButton = getConfirmButtonOptions(dialogButtonTypeError, logoutButtonTitle, performLogout); + + showInfo(logoutConfirmMessage, { + title: logoutConfirmTitle, + buttons: [dialogCancelButton, confirmButton] + }); + }; + + return { performLogout, handleLogout }; +} + +//---------------- +//Интерфейс модуля +//---------------- + +module.exports = { + createLogoutHandler +}; diff --git a/rn/app/src/utils/noop.js b/rn/app/src/utils/noop.js new file mode 100644 index 0000000..2fa40b6 --- /dev/null +++ b/rn/app/src/utils/noop.js @@ -0,0 +1,23 @@ +/* + Предрейсовые осмотры - мобильное приложение + Пустые функции для использования в обработчиках и .catch() +*/ + +//--------- +//Функции +//--------- + +//Пустая функция (например для onPress, чтобы не передавать клик дальше) +function noop() {} + +//Пустой обработчик для .catch() — подавляет ошибку без побочных эффектов +function noopCatch() {} + +//---------------- +//Интерфейс модуля +//---------------- + +module.exports = { + noop, + noopCatch +}; diff --git a/rn/app/src/utils/responsive.js b/rn/app/src/utils/responsive.js index 4302614..ddbb892 100644 --- a/rn/app/src/utils/responsive.js +++ b/rn/app/src/utils/responsive.js @@ -19,7 +19,7 @@ const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window'); //Проверка на старые устройства Android const isLegacyAndroid = () => { - return Platform.OS === 'android' && Platform.Version < 26; // Android 8.0 = API 26 + return Platform.OS === 'android' && Platform.Version < 26; //Android 8.0 = API 26 }; //Нормализация размера для разных плотностей пикселей @@ -99,7 +99,7 @@ const isLargeScreen = () => { //Адаптивный отступ const responsiveSpacing = (size = 1) => { - const baseSpacing = 4; // 4px базовый отступ + const baseSpacing = 4; //4px базовый отступ return responsiveSize(baseSpacing * size); }; diff --git a/rn/app/src/utils/scanInputTargetResolver.js b/rn/app/src/utils/scanInputTargetResolver.js new file mode 100644 index 0000000..4a72c34 --- /dev/null +++ b/rn/app/src/utils/scanInputTargetResolver.js @@ -0,0 +1,39 @@ +/* + Предрейсовые осмотры - мобильное приложение + Разрешение цели для данных встроенного сканера +*/ + +//----------- +//Тело модуля +//----------- + +//Определяет поле ввода для подстановки: сфокусированное (если в списке доступных), иначе первое доступное +function resolveScanTarget(focusedFieldId, allowedFieldIds) { + if (!allowedFieldIds || !Array.isArray(allowedFieldIds) || allowedFieldIds.length === 0) { + return null; + } + if (focusedFieldId != null && allowedFieldIds.indexOf(focusedFieldId) >= 0) { + return focusedFieldId; + } + return allowedFieldIds[0]; +} + +//Возвращает идентификатор следующего поля в порядке списка после текущего, либо null +function getNextInOrder(currentFieldId, allowedFieldIds) { + if (!allowedFieldIds || !Array.isArray(allowedFieldIds) || allowedFieldIds.length === 0) { + return null; + } + const idx = allowedFieldIds.indexOf(currentFieldId); + if (idx < 0) return null; + const nextIdx = idx + 1; + return nextIdx < allowedFieldIds.length ? allowedFieldIds[nextIdx] : null; +} + +//---------------- +//Интерфейс модуля +//---------------- + +module.exports = { + resolveScanTarget, + getNextInOrder +}; diff --git a/rn/app/src/utils/secureStorage.js b/rn/app/src/utils/secureStorage.js index 3965426..7219d21 100644 --- a/rn/app/src/utils/secureStorage.js +++ b/rn/app/src/utils/secureStorage.js @@ -112,7 +112,6 @@ const encryptData = (data, secretKey, salt = '') => { } if (!secretKey) { - console.error('Ошибка шифрования: секретный ключ не предоставлен'); return ''; } @@ -121,7 +120,6 @@ const encryptData = (data, secretKey, salt = '') => { const encrypted = xorEncrypt(data, keyHash); return ENCRYPTED_PREFIX + encrypted; } catch (error) { - console.error('Ошибка шифрования:', error); return ''; } }; @@ -133,7 +131,6 @@ const decryptData = (encryptedData, secretKey, salt = '') => { } if (!secretKey) { - console.error('Ошибка расшифровки: секретный ключ не предоставлен'); return ''; } @@ -148,7 +145,6 @@ const decryptData = (encryptedData, secretKey, salt = '') => { const keyHash = generateKeyHash(secretKey, salt); return xorDecrypt(encryptedHex, keyHash); } catch (error) { - console.error('Ошибка расшифровки:', error); return ''; } }; diff --git a/rn/app/src/utils/serverRequestTimeout.js b/rn/app/src/utils/serverRequestTimeout.js new file mode 100644 index 0000000..a71c714 --- /dev/null +++ b/rn/app/src/utils/serverRequestTimeout.js @@ -0,0 +1,40 @@ +/* + Предрейсовые осмотры - мобильное приложение + Утилита таймаута запросов к серверу +*/ + +//--------------------- +//Подключение библиотек +//--------------------- + +const { AUTH_SETTINGS_KEYS } = require('../config/authConfig'); //Ключи настроек + +//----------- +//Тело модуля +//----------- + +//Возвращает максимальное время ожидания ответа от сервера в миллисекундах (Promise) +async function getServerRequestTimeoutMs(getSetting) { + try { + const raw = await getSetting(AUTH_SETTINGS_KEYS.SERVER_REQUEST_TIMEOUT); + const trimmed = raw != null ? String(raw).trim() : ''; + if (trimmed === '') { + return 0; + } + const sec = parseInt(trimmed, 10); + if (isNaN(sec) || sec <= 0) { + return 0; + } + return sec * 1000; + } catch (err) { + return 0; + } +} + +//---------------- +//Интерфейс модуля +//---------------- + +module.exports = { + getServerRequestTimeoutMs +}; diff --git a/rn/app/src/utils/validation.js b/rn/app/src/utils/validation.js index ea2b457..2b0592f 100644 --- a/rn/app/src/utils/validation.js +++ b/rn/app/src/utils/validation.js @@ -19,7 +19,10 @@ const VALIDATION_MESSAGES = { SERVER_URL_INVALID_SHORT: 'Некорректный формат URL', IDLE_TIMEOUT_EMPTY: 'Введите время простоя', IDLE_TIMEOUT_MIN: 'Введите положительное число (минимум 1 минута)', - IDLE_TIMEOUT_MAX: 'Максимальное значение: 1440 минут (24 часа)' + IDLE_TIMEOUT_MAX: 'Максимальное значение: 1440 минут (24 часа)', + SERVER_REQUEST_TIMEOUT_EMPTY: 'Введите время ожидания ответа от сервера', + SERVER_REQUEST_TIMEOUT_MIN: 'Значение должно быть больше 1 (минимум 1 секунда)', + SERVER_REQUEST_TIMEOUT_MAX: 'Максимальное значение: 600 секунд (10 минут)' }; //----------- @@ -87,6 +90,42 @@ function validateIdleTimeout(value, options) { return true; } +//Валидация времени простоя с допуском пустого или нуля +function validateIdleTimeoutAllowEmpty(value, options) { + const trimmed = value != null ? String(value).trim() : ''; + if (trimmed === '') { + return true; + } + const num = parseInt(trimmed, 10); + if (num === 0) { + return true; + } + return validateIdleTimeout(value, options); +} + +//Валидация времени ожидания ответа от сервера (секунды) +function validateServerRequestTimeout(value, options) { + const opts = options || {}; + const min = opts.min !== undefined ? opts.min : 1; + const max = opts.max !== undefined ? opts.max : 600; + const emptyMessage = opts.emptyMessage || VALIDATION_MESSAGES.SERVER_REQUEST_TIMEOUT_EMPTY; + const minMessage = opts.minMessage || VALIDATION_MESSAGES.SERVER_REQUEST_TIMEOUT_MIN; + const maxMessage = opts.maxMessage || VALIDATION_MESSAGES.SERVER_REQUEST_TIMEOUT_MAX; + + const trimmed = value != null ? String(value).trim() : ''; + if (trimmed === '') { + return emptyMessage; + } + const num = parseInt(trimmed, 10); + if (isNaN(num) || num < min) { + return minMessage; + } + if (num > max) { + return maxMessage; + } + return true; +} + //---------------- //Интерфейс модуля //---------------- @@ -96,5 +135,7 @@ module.exports = { normalizeServerUrl, validateServerUrl, validateServerUrlAllowEmpty, - validateIdleTimeout + validateIdleTimeout, + validateIdleTimeoutAllowEmpty, + validateServerRequestTimeout };