Рефакторинг. Добавление иконок для пунктов меню. Изменение папки файлов приложения (с app на parus_pre_trip_inspections). Доработка работы встроенного сканера устройства.

This commit is contained in:
boa604 2026-03-10 17:51:23 +03:00
parent 7bc9ddb898
commit 5449f61c6e
77 changed files with 2807 additions and 927 deletions

View File

@ -13,8 +13,9 @@ const AppMessagingProvider = require('./src/components/layout/AppMessagingProvid
const AppNavigationProvider = require('./src/components/layout/AppNavigationProvider').AppNavigationProvider; //Провайдер навигации const AppNavigationProvider = require('./src/components/layout/AppNavigationProvider').AppNavigationProvider; //Провайдер навигации
const AppModeProvider = require('./src/components/layout/AppModeProvider').AppModeProvider; //Провайдер режима работы const AppModeProvider = require('./src/components/layout/AppModeProvider').AppModeProvider; //Провайдер режима работы
const AppLocalDbProvider = require('./src/components/layout/AppLocalDbProvider').AppLocalDbProvider; //Провайдер локальной БД 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 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 приложения const AppRoot = require('./src/components/layout/AppRoot'); //Корневой layout приложения
//----------- //-----------
@ -28,13 +29,15 @@ function App() {
<AppMessagingProvider> <AppMessagingProvider>
<AppNavigationProvider> <AppNavigationProvider>
<AppLocalDbProvider> <AppLocalDbProvider>
<AppAuthProvider>
<AppModeProvider> <AppModeProvider>
<AppServerProvider> <AppServerProvider>
<AppPreTripInspectionsProvider> <AppIdleProvider>
<AppRoot /> <AppRoot />
</AppPreTripInspectionsProvider> </AppIdleProvider>
</AppServerProvider> </AppServerProvider>
</AppModeProvider> </AppModeProvider>
</AppAuthProvider>
</AppLocalDbProvider> </AppLocalDbProvider>
</AppNavigationProvider> </AppNavigationProvider>
</AppMessagingProvider> </AppMessagingProvider>

View File

@ -77,9 +77,9 @@ android {
buildToolsVersion rootProject.ext.buildToolsVersion buildToolsVersion rootProject.ext.buildToolsVersion
compileSdk rootProject.ext.compileSdkVersion compileSdk rootProject.ext.compileSdkVersion
namespace "com.app" namespace "com.parus_pre_trip_inspections"
defaultConfig { defaultConfig {
applicationId "com.app" applicationId "com.parus_pre_trip_inspections"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1 versionCode 1
@ -117,3 +117,19 @@ dependencies {
implementation jscFlavor 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) }
}
}
}

View File

@ -11,7 +11,8 @@
android:allowBackup="false" android:allowBackup="false"
android:theme="@style/AppTheme" android:theme="@style/AppTheme"
android:usesCleartextTraffic="${usesCleartextTraffic}" android:usesCleartextTraffic="${usesCleartextTraffic}"
android:supportsRtl="true"> android:supportsRtl="true"
android:requestLegacyExternalStorage="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:label="@string/app_name" android:label="@string/app_name"

View File

@ -1,22 +0,0 @@
package com.app
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() {
/**
* Returns the name of the main component registered from JavaScript. This is used to schedule
* rendering of the component.
*/
override fun getMainComponentName(): String = "Pre-Trip_Inspections"
/**
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
*/
override fun createReactActivityDelegate(): ReactActivityDelegate =
DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
}

View File

@ -0,0 +1,101 @@
/*
Предрейсовые осмотры - мобильное приложение
Нативный модуль встроенного сканера: доставка штрихкода в JS через событие по мосту.
*/
package com.parus_pre_trip_inspections
import android.view.View
import android.view.ViewGroup
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.modules.core.DeviceEventManagerModule
import com.facebook.react.bridge.Arguments
class HardwareScannerModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
init {
sharedReactContext = reactApplicationContext
}
companion object {
private const val MODULE_NAME = "HardwareScannerModule"
private const val LOG_TAG = "PTI_HwScan"
const val EVENT_HARDWARE_SCANNER_DATA = "HardwareScannerData"
private const val EVENT_HARDWARE_SCANNER_TRIGGER = "HardwareScannerTriggerPressed"
private const val TRIGGER_DEBOUNCE_MS = 600L
@Volatile
var sharedReactContext: ReactApplicationContext? = null
private set
@Volatile
private var lastTriggerEmitTime = 0L
fun emitBarcodeToJs(barcode: String?) {
if (barcode.isNullOrEmpty()) return
val ctx = sharedReactContext ?: return
val payload = barcode
try {
if (!ctx.hasActiveCatalystInstance()) return
ctx.runOnUiQueueThread {
try {
if (!ctx.hasActiveCatalystInstance()) return@runOnUiQueueThread
val map = Arguments.createMap()
map.putString("barcode", payload)
ctx.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
?.emit(EVENT_HARDWARE_SCANNER_DATA, map)
} catch (_: Exception) {}
}
} catch (_: Exception) {}
}
//Эмит триггера только с UiQueueThread
fun emitTriggerFromActivity(appContext: android.content.Context?): Boolean {
val now = System.currentTimeMillis()
if (now - lastTriggerEmitTime < TRIGGER_DEBOUNCE_MS) return false
lastTriggerEmitTime = now
val ctx = sharedReactContext ?: return true
try {
if (!ctx.hasActiveCatalystInstance()) return true
ctx.runOnUiQueueThread {
try {
if (!ctx.hasActiveCatalystInstance()) return@runOnUiQueueThread
ctx.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
?.emit(EVENT_HARDWARE_SCANNER_TRIGGER, null)
} catch (_: Exception) {}
}
} catch (_: Exception) {}
return true
}
}
override fun getName(): String = MODULE_NAME
@ReactMethod
fun setCameraViewsNotFocusable() {
try {
val activity = reactApplicationContext.currentActivity ?: return
val content = activity.window?.decorView?.findViewById<View>(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))
}
}
}
}

View File

@ -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<NativeModule> {
return listOf(HardwareScannerModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}

View File

@ -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)
}
}

View File

@ -1,4 +1,4 @@
package com.app package com.parus_pre_trip_inspections
import android.app.Application import android.app.Application
import com.facebook.react.PackageList import com.facebook.react.PackageList
@ -14,8 +14,7 @@ class MainApplication : Application(), ReactApplication {
context = applicationContext, context = applicationContext,
packageList = packageList =
PackageList(this).packages.apply { PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example: add(HardwareScannerPackage())
// add(MyReactNativePackage())
}, },
) )
} }

View File

@ -11,10 +11,6 @@ const { AppRegistry } = require("react-native"); //Регистрация кор
const App = require("./App"); //Корневой компонент приложения const App = require("./App"); //Корневой компонент приложения
const { name: appName } = require("./app.json"); //Имя приложения const { name: appName } = require("./app.json"); //Имя приложения
//-----------
//Тело модуля
//-----------
//Регистрация корневого компонента //Регистрация корневого компонента
AppRegistry.registerComponent(appName, () => App); AppRegistry.registerComponent(appName, () => App);

View File

@ -0,0 +1,7 @@
module.exports = {
project: {
android: {
packageName: 'com.parus_pre_trip_inspections',
},
},
};

View File

@ -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(() => { const handlePress = React.useCallback(() => {
if (!disabled && typeof onPress === 'function') onPress(); if (!disabled && typeof onPress === 'function') onPress();
}, [disabled, 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]); const getPressableStyle = React.useMemo(() => getAppButtonPressableStyle(styles, disabled, style), [disabled, style]);
return ( return (
<Pressable onPress={handlePress} disabled={disabled} style={getPressableStyle}> <Pressable onPress={handlePress} disabled={disabled} style={getPressableStyle} focusable={focusable}>
<View style={styles.content}> <View style={styles.content}>
<AppText style={[styles.text, textStyle]}>{title}</AppText> <AppText style={[styles.text, textStyle]}>{title}</AppText>
</View> </View>

View File

@ -32,21 +32,31 @@ const AppInput = React.forwardRef(function AppInput(
style, style,
inputStyle, inputStyle,
labelStyle, labelStyle,
onFocus: onFocusProp,
onBlur: onBlurProp,
...restProps ...restProps
}, },
ref ref
) { ) {
const [isFocused, setIsFocused] = React.useState(false); const [isFocused, setIsFocused] = React.useState(false);
//Обработчик фокуса //Обработчик фокуса (внутренний + опциональный внешний)
const handleFocus = React.useCallback(() => { const handleFocus = React.useCallback(
e => {
setIsFocused(true); setIsFocused(true);
}, []); if (typeof onFocusProp === 'function') onFocusProp(e);
},
[onFocusProp]
);
//Обработчик потери фокуса //Обработчик потери фокуса (внутренний + опциональный внешний)
const handleBlur = React.useCallback(() => { const handleBlur = React.useCallback(
e => {
setIsFocused(false); setIsFocused(false);
}, []); if (typeof onBlurProp === 'function') onBlurProp(e);
},
[onBlurProp]
);
return ( return (
<View style={[styles.container, style]}> <View style={[styles.container, style]}>

View File

@ -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(
<AppMessageButton
key={btn.id || btn.title}
title={btn.title}
onPress={btn.onPress}
onDismiss={handleClose}
buttonStyle={btn.buttonStyle}
textStyle={btn.textStyle}
/>
);
}
return elements;
}
//Кнопка сообщения //Кнопка сообщения
function AppMessageButton({ title, onPress, onDismiss, buttonStyle, textStyle }) { function AppMessageButton({ title, onPress, onDismiss, buttonStyle, textStyle }) {
//Обработчик нажатия - вызывает onPress и закрывает диалог //Обработчик нажатия - вызывает onPress и закрывает диалог
@ -95,20 +117,7 @@ function AppMessage({
<View style={[styles.content, contentStyle]}> <View style={[styles.content, contentStyle]}>
<Text style={[styles.message, messageStyle]}>{message}</Text> <Text style={[styles.message, messageStyle]}>{message}</Text>
</View> </View>
{hasButtons ? ( {hasButtons ? <View style={styles.buttonsRow}>{getMessageButtonElements(buttons, handleClose)}</View> : null}
<View style={styles.buttonsRow}>
{buttons.map(btn => (
<AppMessageButton
key={btn.id || btn.title}
title={btn.title}
onPress={btn.onPress}
onDismiss={handleClose}
buttonStyle={btn.buttonStyle}
textStyle={btn.textStyle}
/>
))}
</View>
) : null}
</View> </View>
</View> </View>
</Modal> </Modal>

View File

@ -48,8 +48,6 @@ function CopyButton({ value, onCopy, onError, disabled = false, style }) {
onCopy(value); onCopy(value);
} }
} catch (error) { } catch (error) {
console.error('Ошибка копирования в буфер:', error);
if (typeof onError === 'function') { if (typeof onError === 'function') {
onError(error); onError(error);
} }

View File

@ -8,17 +8,20 @@
//--------------------- //---------------------
const React = require('react'); //React 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 AppText = require('./AppText'); //Общий текстовый компонент
const AppButton = require('./AppButton'); //Кнопка const AppButton = require('./AppButton'); //Кнопка
const styles = require('../../styles/common/InputDialog.styles'); //Стили диалога const styles = require('../../styles/common/InputDialog.styles'); //Стили диалога
const OVERLAY_Z = 9999;
//----------- //-----------
//Тело модуля //Тело модуля
//----------- //-----------
//Модальное окно с полем ввода //Модальное окно с полем ввода
function InputDialog({ function InputDialog(
{
visible, visible,
title = 'Ввод данных', title = 'Ввод данных',
label, label,
@ -32,20 +35,73 @@ function InputDialog({
onCancel, onCancel,
validator, validator,
errorMessage errorMessage
}) { },
ref
) {
//Локальное значение для редактирования //Локальное значение для редактирования
const [inputValue, setInputValue] = React.useState(value); 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 [error, setError] = React.useState('');
const [isFocused, setIsFocused] = React.useState(false); const [isFocused, setIsFocused] = React.useState(false);
//Сброс значения при открытии диалога //Сброс значения и фокусируемости только при открытии диалога
const prevVisibleRef = React.useRef(false);
React.useEffect(() => { React.useEffect(() => {
if (visible) { const justOpened = visible && !prevVisibleRef.current;
prevVisibleRef.current = visible;
if (justOpened) {
setInputValue(value); setInputValue(value);
setError(''); setError('');
if (Platform.OS === 'android') setInputFocusable(false);
} }
}, [visible, value]); }, [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(() => { const handleFocus = React.useCallback(() => {
setIsFocused(true); setIsFocused(true);
@ -97,13 +153,22 @@ function InputDialog({
handleCancel(); handleCancel();
}, [handleCancel]); }, [handleCancel]);
return ( const preventKeyActions = Platform.OS === 'android';
<Modal visible={visible} transparent={true} animationType="fade" statusBarTranslucent={true} onRequestClose={handleRequestClose}> const closePressableProps = preventKeyActions ? { focusable: false } : {};
<View style={styles.backdrop}> const cancelButtonFocusable = preventKeyActions ? false : undefined;
const confirmButtonFocusable = preventKeyActions ? false : undefined;
const dialogContent = (
<View style={styles.container}> <View style={styles.container}>
<View style={styles.header}> <View style={styles.header}>
<AppText style={styles.title}>{title}</AppText> <AppText style={styles.title}>{title}</AppText>
<Pressable accessibilityRole="button" accessibilityLabel="Закрыть" onPress={handleCancel} style={styles.closeButton}> <Pressable
accessibilityRole="button"
accessibilityLabel="Закрыть"
onPress={handleCancel}
style={styles.closeButton}
{...closePressableProps}
>
<AppText style={styles.closeButtonText}>×</AppText> <AppText style={styles.closeButtonText}>×</AppText>
</Pressable> </Pressable>
</View> </View>
@ -116,6 +181,7 @@ function InputDialog({
) : null} ) : null}
<TextInput <TextInput
ref={inputRef}
style={[styles.input, isFocused && styles.inputFocused, error && styles.inputError]} style={[styles.input, isFocused && styles.inputFocused, error && styles.inputError]}
value={inputValue} value={inputValue}
onChangeText={handleChangeText} onChangeText={handleChangeText}
@ -123,9 +189,11 @@ function InputDialog({
placeholderTextColor={styles.placeholder.color} placeholderTextColor={styles.placeholder.color}
keyboardType={keyboardType} keyboardType={keyboardType}
autoCapitalize={autoCapitalize} autoCapitalize={autoCapitalize}
multiline={true}
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
autoFocus={true} autoFocus={Platform.OS !== 'android'}
focusable={Platform.OS !== 'android' || inputFocusable}
selectTextOnFocus={true} selectTextOnFocus={true}
accessible={true} accessible={true}
accessibilityLabel={label || placeholder} accessibilityLabel={label || placeholder}
@ -140,11 +208,33 @@ function InputDialog({
</View> </View>
<View style={styles.buttonsRow}> <View style={styles.buttonsRow}>
<AppButton title={cancelText} onPress={handleCancel} style={styles.cancelButton} textStyle={styles.cancelButtonText} /> <AppButton
<AppButton title={confirmText} onPress={handleConfirm} style={styles.confirmButton} /> title={cancelText}
onPress={handleCancel}
style={styles.cancelButton}
textStyle={styles.cancelButtonText}
focusable={cancelButtonFocusable}
/>
<AppButton title={confirmText} onPress={handleConfirm} style={styles.confirmButton} focusable={confirmButtonFocusable} />
</View> </View>
</View> </View>
);
if (!visible) return null;
if (Platform.OS === 'android') {
return (
<View style={[StyleSheet.absoluteFill, { zIndex: OVERLAY_Z, elevation: OVERLAY_Z }]} pointerEvents="auto">
<View style={styles.backdrop} pointerEvents="auto">
{dialogContent}
</View> </View>
</View>
);
}
return (
<Modal transparent={true} animationType="fade" statusBarTranslucent={true} visible={true} onRequestClose={handleRequestClose}>
<View style={styles.backdrop}>{dialogContent}</View>
</Modal> </Modal>
); );
} }
@ -153,4 +243,4 @@ function InputDialog({
//Интерфейс модуля //Интерфейс модуля
//---------------- //----------------
module.exports = InputDialog; module.exports = React.forwardRef(InputDialog);

View File

@ -31,6 +31,8 @@ const PasswordInput = React.forwardRef(function PasswordInput(
style, style,
inputStyle, inputStyle,
labelStyle, labelStyle,
onFocus: onFocusProp,
onBlur: onBlurProp,
...restProps ...restProps
}, },
ref ref
@ -50,15 +52,23 @@ const PasswordInput = React.forwardRef(function PasswordInput(
[ref] [ref]
); );
//Обработчик фокуса //Обработчик фокуса (внутренний + опциональный внешний)
const handleFocus = React.useCallback(() => { const handleFocus = React.useCallback(
e => {
setIsFocused(true); setIsFocused(true);
}, []); if (typeof onFocusProp === 'function') onFocusProp(e);
},
[onFocusProp]
);
//Обработчик потери фокуса //Обработчик потери фокуса (внутренний + опциональный внешний)
const handleBlur = React.useCallback(() => { const handleBlur = React.useCallback(
e => {
setIsFocused(false); setIsFocused(false);
}, []); if (typeof onBlurProp === 'function') onBlurProp(e);
},
[onBlurProp]
);
//Обработчик переключения видимости пароля //Обработчик переключения видимости пароля
const handleTogglePassword = React.useCallback(() => { const handleTogglePassword = React.useCallback(() => {

View File

@ -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 (
<AppIdleContext.Provider value={value}>
<View style={styles.touchArea} onTouchStart={handleTouch} onTouchEnd={handleTouch} collapsable={false}>
{children}
</View>
</AppIdleContext.Provider>
);
}
//Хук контекста простоя
function useAppIdleContext() {
const context = React.useContext(AppIdleContext);
if (context == null) {
throw new Error('useAppIdleContext должен использоваться внутри AppIdleProvider');
}
return context;
}
//----------------
//Интерфейс модуля
//----------------
module.exports = {
AppIdleProvider,
useAppIdleContext
};

View File

@ -25,16 +25,12 @@ function AppLocalDbProvider({ children }) {
const value = React.useMemo( const value = React.useMemo(
() => ({ () => ({
isDbReady: api.isDbReady, isDbReady: api.isDbReady,
inspections: api.inspections,
error: api.error, error: api.error,
loadInspections: api.loadInspections,
saveInspection: api.saveInspection,
getSetting: api.getSetting, getSetting: api.getSetting,
setSetting: api.setSetting, setSetting: api.setSetting,
deleteSetting: api.deleteSetting, deleteSetting: api.deleteSetting,
getAllSettings: api.getAllSettings, getAllSettings: api.getAllSettings,
clearSettings: api.clearSettings, clearSettings: api.clearSettings,
clearInspections: api.clearInspections,
vacuum: api.vacuum, vacuum: api.vacuum,
checkTableExists: api.checkTableExists, checkTableExists: api.checkTableExists,
setAuthSession: api.setAuthSession, setAuthSession: api.setAuthSession,
@ -43,16 +39,12 @@ function AppLocalDbProvider({ children }) {
}), }),
[ [
api.isDbReady, api.isDbReady,
api.inspections,
api.error, api.error,
api.loadInspections,
api.saveInspection,
api.getSetting, api.getSetting,
api.setSetting, api.setSetting,
api.deleteSetting, api.deleteSetting,
api.getAllSettings, api.getAllSettings,
api.clearSettings, api.clearSettings,
api.clearInspections,
api.vacuum, api.vacuum,
api.checkTableExists, api.checkTableExists,
api.setAuthSession, api.setAuthSession,

View File

@ -11,6 +11,7 @@ const React = require('react'); //React и хуки
const { useColorScheme, View } = require('react-native'); //Определение темы устройства и разметка const { useColorScheme, View } = require('react-native'); //Определение темы устройства и разметка
const { SafeAreaProvider } = require('react-native-safe-area-context'); //Провайдер безопасной области const { SafeAreaProvider } = require('react-native-safe-area-context'); //Провайдер безопасной области
const AppShell = require('./AppShell'); //Оболочка приложения const AppShell = require('./AppShell'); //Оболочка приложения
const HardwareScannerProvider = require('./HardwareScannerProvider').HardwareScannerProvider; //Встроенный сканер
const styles = require('../../styles/layout/AppRoot.styles'); //Стили корневого layout const styles = require('../../styles/layout/AppRoot.styles'); //Стили корневого layout
const { useAppNavigationContext } = require('./AppNavigationProvider'); //Контекст навигации const { useAppNavigationContext } = require('./AppNavigationProvider'); //Контекст навигации
const { useAppAuthContext } = require('./AppAuthProvider'); //Контекст авторизации const { useAppAuthContext } = require('./AppAuthProvider'); //Контекст авторизации
@ -54,7 +55,9 @@ function AppRoot() {
return ( return (
<SafeAreaProvider> <SafeAreaProvider>
<View style={styles.container}> <View style={styles.container}>
<HardwareScannerProvider>
<AppShell isDarkMode={isDarkMode} /> <AppShell isDarkMode={isDarkMode} />
</HardwareScannerProvider>
</View> </View>
</SafeAreaProvider> </SafeAreaProvider>
); );

View File

@ -11,6 +11,7 @@ const React = require('react'); //React
const { StatusBar, Platform } = require('react-native'); //Базовые компоненты const { StatusBar, Platform } = require('react-native'); //Базовые компоненты
const { useAppNavigationContext } = require('./AppNavigationProvider'); //Контекст навигации const { useAppNavigationContext } = require('./AppNavigationProvider'); //Контекст навигации
const { useAppAuthContext } = require('./AppAuthProvider'); //Контекст авторизации const { useAppAuthContext } = require('./AppAuthProvider'); //Контекст авторизации
const { useAppIdleContext } = require('./AppIdleProvider'); //Контекст простоя
const MainScreen = require('../../screens/MainScreen'); //Главный экран const MainScreen = require('../../screens/MainScreen'); //Главный экран
const SettingsScreen = require('../../screens/SettingsScreen'); //Экран настроек const SettingsScreen = require('../../screens/SettingsScreen'); //Экран настроек
const AuthScreen = require('../../screens/AuthScreen'); //Экран авторизации const AuthScreen = require('../../screens/AuthScreen'); //Экран авторизации
@ -27,6 +28,12 @@ const styles = require('../../styles/layout/AppShell.styles'); //Стили об
function AppShell({ isDarkMode }) { function AppShell({ isDarkMode }) {
const { currentScreen, SCREENS } = useAppNavigationContext(); const { currentScreen, SCREENS } = useAppNavigationContext();
const { isStartupSessionCheckInProgress, isLogoutInProgress } = useAppAuthContext(); const { isStartupSessionCheckInProgress, isLogoutInProgress } = useAppAuthContext();
const { reportActivity } = useAppIdleContext();
//Сброс таймера простоя при смене экрана (навигация считается активностью)
React.useEffect(() => {
reportActivity();
}, [currentScreen, reportActivity]);
const renderScreen = React.useCallback(() => { const renderScreen = React.useCallback(() => {
switch (currentScreen) { switch (currentScreen) {

View File

@ -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 <HardwareScannerContext.Provider value={value}>{children}</HardwareScannerContext.Provider>;
}
//Хук доступа к контексту встроенного сканера
function useHardwareScannerContext() {
const ctx = React.useContext(HardwareScannerContext);
if (!ctx) {
throw new Error('useHardwareScannerContext должен использоваться внутри HardwareScannerProvider');
}
return ctx;
}
//----------------
//Интерфейс модуля
//----------------
module.exports = {
HardwareScannerProvider,
useHardwareScannerContext
};

View File

@ -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(
<View
key={`tooth-${index}`}
style={[
styles.settingsTooth,
{ backgroundColor: iconColor },
{
transform: [{ rotate: `${angle}deg` }, { translateY: -responsiveSize(8) }]
}
]}
/>
);
}
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 (
<View style={styles.container}>
<View style={[styles.settingsCircle, { borderColor: iconColor }]} />
{toothViews}
</View>
);
}
//Иконка «О приложении» (информация)
function AboutIcon({ color }) {
const iconColor = color ?? APP_COLORS.textPrimary;
return (
<View style={styles.container}>
<View style={[styles.aboutCircle, { borderColor: iconColor }]}>
<AppText style={[styles.aboutText, { color: iconColor }]}>i</AppText>
</View>
</View>
);
}
//Иконка «Вход»
function LoginIcon({ color }) {
const iconColor = color ?? APP_COLORS.textPrimary;
return (
<View style={styles.container}>
<View style={[styles.loginFrame, { borderColor: iconColor }]}>
<View style={[styles.loginArrow, { borderLeftColor: iconColor }]} />
</View>
</View>
);
}
//Иконка «Выход»
function LogoutIcon({ color }) {
const iconColor = color ?? APP_COLORS.textPrimary;
return (
<View style={styles.container}>
<View style={[styles.logoutFrame, { borderColor: iconColor }]}>
<View style={[styles.logoutArrow, { borderRightColor: iconColor }]} />
</View>
</View>
);
}
//Рендер иконки по имени пункта меню
function renderIconByName(name, color) {
switch (name) {
case MENU_ITEM_ID_SETTINGS:
return <SettingsIcon color={color} />;
case MENU_ITEM_ID_ABOUT:
return <AboutIcon color={color} />;
case MENU_ITEM_ID_LOGIN:
return <LoginIcon color={color} />;
case MENU_ITEM_ID_LOGOUT:
return <LogoutIcon color={color} />;
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;

View File

@ -10,25 +10,57 @@
const React = require('react'); //React const React = require('react'); //React
const { ScrollView, View } = require('react-native'); //Базовые компоненты const { ScrollView, View } = require('react-native'); //Базовые компоненты
const MenuItem = require('./MenuItem'); //Элемент меню const MenuItem = require('./MenuItem'); //Элемент меню
const MenuItemIcon = require('./MenuItemIcon'); //Иконка пункта меню
const EmptyMenu = require('./EmptyMenu'); //Пустое меню const EmptyMenu = require('./EmptyMenu'); //Пустое меню
const MenuDivider = require('./MenuDivider'); //Разделитель меню const MenuDivider = require('./MenuDivider'); //Разделитель меню
const { MENU_ITEM_ID_DIVIDER } = require('../../config/menuItemIds'); //ID разделителя
const styles = require('../../styles/menu/MenuList.styles'); //Стили списка меню 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 <MenuItemIcon name={item.id} color={item.iconColor} />;
}
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(<MenuDivider key={item.id || `menu-divider-${index}`} />);
} else {
elements.push(<MenuItemRow key={item.id || `menu-item-${index}`} item={item} index={index} items={items} onItemPress={onItemPress} />);
}
}
return elements;
}
//Строка меню с элементом //Строка меню с элементом
function MenuItemRow({ item, index, items, onItemPress }) { function MenuItemRow({ item, index, items, onItemPress }) {
const handlePress = React.useCallback(() => { const handlePress = React.useCallback(() => {
onItemPress(item); onItemPress(item);
}, [item, onItemPress]); }, [item, onItemPress]);
const icon = React.useMemo(() => resolveItemIcon(item), [item]);
return ( return (
<View> <View>
<MenuItem <MenuItem
title={item.title} title={item.title}
icon={item.icon} icon={icon}
onPress={handlePress} onPress={handlePress}
isDestructive={item.isDestructive} isDestructive={item.isDestructive}
disabled={item.disabled} disabled={item.disabled}
@ -54,21 +86,15 @@ function MenuList({ items = [], onClose, style }) {
[onClose] [onClose]
); );
const listElements = React.useMemo(() => getMenuListElements(items, handleItemPress), [items, handleItemPress]);
if (!Array.isArray(items) || items.length === 0) { if (!Array.isArray(items) || items.length === 0) {
return <EmptyMenu />; return <EmptyMenu />;
} }
return ( return (
<ScrollView style={[styles.scrollView, style]} showsVerticalScrollIndicator={false} bounces={false}> <ScrollView style={[styles.scrollView, style]} showsVerticalScrollIndicator={false} bounces={false}>
{items.map((item, index) => { {listElements}
//Элемент-разделитель
if (item.type === 'divider') {
return <MenuDivider key={item.id || `menu-divider-${index}`} />;
}
//Обычный элемент меню
return <MenuItemRow key={item.id || `menu-item-${index}`} item={item} index={index} items={items} onItemPress={handleItemPress} />;
})}
</ScrollView> </ScrollView>
); );
} }

View File

@ -8,7 +8,7 @@
//--------------------- //---------------------
const React = require('react'); //React и хуки 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 { Camera, useCameraDevice, useCodeScanner, useCameraPermission } = require('react-native-vision-camera'); //Камера и сканер кодов
const AppText = require('../common/AppText'); //Общий текст const AppText = require('../common/AppText'); //Общий текст
const { DEFAULT_CODE_TYPES } = require('../../config/scannerConfig'); //Конфиг сканера const { DEFAULT_CODE_TYPES } = require('../../config/scannerConfig'); //Конфиг сканера
@ -68,10 +68,12 @@ function BarcodeScannerNative({ onScan, isActive = true }) {
} }
}, [hasPermission, requestPermission]); }, [hasPermission, requestPermission]);
const noFocusProps = Platform.OS === 'android' ? { focusable: false } : {};
//Нет устройства камеры //Нет устройства камеры
if (device == null) { if (device == null) {
return ( return (
<View style={styles.fallbackContainer}> <View style={styles.fallbackContainer} {...noFocusProps}>
<AppText style={styles.fallbackText} variant="body"> <AppText style={styles.fallbackText} variant="body">
{NO_CAMERA_MESSAGE} {NO_CAMERA_MESSAGE}
</AppText> </AppText>
@ -82,7 +84,7 @@ function BarcodeScannerNative({ onScan, isActive = true }) {
//Нет разрешения на камеру //Нет разрешения на камеру
if (!hasPermission) { if (!hasPermission) {
return ( return (
<View style={styles.fallbackContainer}> <View style={styles.fallbackContainer} {...noFocusProps}>
<AppText style={styles.fallbackText} variant="body"> <AppText style={styles.fallbackText} variant="body">
{PERMISSION_DENIED_MESSAGE} {PERMISSION_DENIED_MESSAGE}
</AppText> </AppText>
@ -90,8 +92,10 @@ function BarcodeScannerNative({ onScan, isActive = true }) {
); );
} }
const containerProps = Platform.OS === 'android' ? { focusable: false, importantForAccessibility: 'no-hide-descendants' } : {};
return ( return (
<View style={styles.container}> <View style={styles.container} {...containerProps}>
<Camera style={styles.camera} device={device} isActive={isActive} codeScanner={codeScanner} /> <Camera style={styles.camera} device={device} isActive={isActive} codeScanner={codeScanner} />
</View> </View>
); );

View File

@ -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 (
<View style={styles.container} pointerEvents="box-none">
<AppText style={styles.message} variant="body" numberOfLines={3}>
{message}
</AppText>
</View>
);
}
//----------------
//Интерфейс модуля
//----------------
module.exports = CameraPausedOverlay;

View File

@ -8,10 +8,11 @@
//--------------------- //---------------------
const React = require('react'); //React 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 AppText = require('../common/AppText'); //Общий текстовый компонент
const AppButton = require('../common/AppButton'); //Кнопка const AppButton = require('../common/AppButton'); //Кнопка
const { SCAN_RESULT_MODAL_TITLE, SCAN_RESULT_CLOSE_BUTTON } = require('../../config/messages'); //Сообщения const { SCAN_RESULT_MODAL_TITLE, SCAN_RESULT_CLOSE_BUTTON } = require('../../config/messages'); //Сообщения
const { noop } = require('../../utils/noop'); //Пустая функция
const styles = require('../../styles/scanner/ScanResultModal.styles'); //Стили модального окна 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 (
<Pressable style={s.backdrop} onPress={onClosePress} {...backdropProps}>
<Pressable style={s.container} onPress={noop} focusable={false}>
<View style={s.header}>
<AppText style={s.title} numberOfLines={1}>
{SCAN_RESULT_MODAL_TITLE}
</AppText>
<Pressable
accessibilityRole="button"
accessibilityLabel="Закрыть"
onPress={onClosePress}
style={s.closeButton}
{...closeButtonProps}
>
<AppText style={s.closeButtonText}>×</AppText>
</Pressable>
</View>
<View style={s.content}>
<AppText style={s.typeLabel} variant="caption" weight="medium">
Тип: {displayType}
</AppText>
<View style={s.valueBlock}>
<AppText style={s.valueText} selectable={true}>
{displayValue}
</AppText>
</View>
<View style={s.buttonsRow}>
<AppButton title={SCAN_RESULT_CLOSE_BUTTON} onPress={onClosePress} focusable={closeButtonFocusable} />
</View>
</View>
</Pressable>
</Pressable>
);
}
//Модальное окно результата сканирования //Модальное окно результата сканирования
function ScanResultModal({ visible, codeType, value, onRequestClose }) { function ScanResultModal({ visible, codeType, value, onRequestClose }) {
const handleClosePress = React.useCallback(() => { const handleClosePress = React.useCallback(() => {
handleClose(onRequestClose); handleClose(onRequestClose);
}, [onRequestClose]); }, [onRequestClose]);
const modalOnRequestClose = React.useMemo(() => (Platform.OS === 'android' ? noop : handleClosePress), [handleClosePress]);
const displayType = formatCodeType(codeType); const displayType = formatCodeType(codeType);
const displayValue = value != null && value !== '' ? String(value) : '—'; const displayValue = value != null && value !== '' ? String(value) : '—';
const preventKeyClose = Platform.OS === 'android';
const content = (
<ResultContent
displayType={displayType}
displayValue={displayValue}
onClosePress={handleClosePress}
styles={styles}
preventKeyClose={preventKeyClose}
/>
);
if (Platform.OS === 'android') {
if (!visible) return null;
return ( return (
<Modal animationType="fade" transparent={true} visible={!!visible} onRequestClose={handleClosePress}> <View style={styles.overlay} focusable={false} importantForAccessibility="no-hide-descendants" collapsable={false}>
<View style={styles.backdrop}> <View style={styles.overlayContent} pointerEvents="box-none">
<View style={styles.container}> {content}
<View style={styles.header}>
<AppText style={styles.title} numberOfLines={1}>
{SCAN_RESULT_MODAL_TITLE}
</AppText>
<Pressable accessibilityRole="button" accessibilityLabel="Закрыть" onPress={handleClosePress} style={styles.closeButton}>
<AppText style={styles.closeButtonText}>×</AppText>
</Pressable>
</View>
<View style={styles.content}>
<AppText style={styles.typeLabel} variant="caption" weight="medium">
Тип: {displayType}
</AppText>
<View style={styles.valueBlock}>
<AppText style={styles.valueText} selectable={true}>
{displayValue}
</AppText>
</View>
<View style={styles.buttonsRow}>
<AppButton title={SCAN_RESULT_CLOSE_BUTTON} onPress={handleClosePress} />
</View>
</View>
</View> </View>
</View> </View>
);
}
return (
<Modal animationType="fade" transparent={true} visible={!!visible} onRequestClose={modalOnRequestClose} statusBarTranslucent={true}>
<View style={styles.modalRoot}>{content}</View>
</Modal> </Modal>
); );
} }

View File

@ -8,10 +8,12 @@
//--------------------- //---------------------
const React = require('react'); //React 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 BarcodeScanner = require('./BarcodeScanner'); //Сканер
const ScannerPlaceholder = require('./ScannerPlaceholder'); //Заглушка с кнопкой const ScannerPlaceholder = require('./ScannerPlaceholder'); //Заглушка с кнопкой
const CameraPausedOverlay = require('./CameraPausedOverlay'); //Оверлей «камера на паузе»
const AppText = require('../common/AppText'); //Текст const AppText = require('../common/AppText'); //Текст
const { SCANNER_CAMERA_TAP_TO_RESUME } = require('../../config/messages'); //Сообщения
const styles = require('../../styles/scanner/ScannerArea.styles'); //Стили области 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 [scannerModalVisible, setScannerModalVisible] = React.useState(false);
const handleScanFromArea = React.useCallback( const handleScanFromArea = React.useCallback(
@ -56,9 +67,33 @@ function ScannerArea({ alwaysShowScanner = false, scannerOpen = true, onScanResu
const renderScannerContent = () => { const renderScannerContent = () => {
if (alwaysShowScanner && scannerOpen) { if (alwaysShowScanner && scannerOpen) {
return <BarcodeScanner isActive={true} onScan={handleScanFromArea} />; const wrapperProps = Platform.OS === 'android' ? { focusable: false, importantForAccessibility: 'no-hide-descendants' } : {};
const showPausedOverlay = !cameraActive && typeof onResumeCameraPress === 'function';
return (
<View style={styles.scannerWrapper} {...wrapperProps}>
<BarcodeScanner isActive={cameraActive} onScan={handleScanFromArea} />
{showPausedOverlay ? (
<Pressable
style={styles.cameraPausedOverlayTouchable}
onPress={onResumeCameraPress}
accessibilityRole="button"
accessibilityLabel="Включить камеру"
{...(Platform.OS === 'android' ? { focusable: false } : {})}
>
<CameraPausedOverlay message={SCANNER_CAMERA_TAP_TO_RESUME} />
</Pressable>
) : null}
</View>
);
} }
return <ScannerPlaceholder onScanPress={handleOpenScanner} />; return (
<ScannerPlaceholder
onScanPress={handleOpenScanner}
hintText={placeholderHint}
secondaryButtonTitle={placeholderSecondaryLabel}
secondaryButtonOnPress={placeholderSecondaryOnPress}
/>
);
}; };
return ( return (

View File

@ -10,6 +10,7 @@
const React = require('react'); //React const React = require('react'); //React
const { View } = require('react-native'); //Базовые компоненты const { View } = require('react-native'); //Базовые компоненты
const AppButton = require('../common/AppButton'); //Кнопка const AppButton = require('../common/AppButton'); //Кнопка
const AppText = require('../common/AppText'); //Текст
const { SCAN_BUTTON_TITLE } = require('../../config/messages'); //Сообщения const { SCAN_BUTTON_TITLE } = require('../../config/messages'); //Сообщения
const styles = require('../../styles/scanner/ScannerPlaceholder.styles'); //Стили заглушки 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(() => { const handlePress = React.useCallback(() => {
if (typeof onScanPress === 'function') { if (typeof onScanPress === 'function') {
onScanPress(); onScanPress();
} }
}, [onScanPress]); }, [onScanPress]);
const handleSecondary = React.useCallback(() => {
if (typeof secondaryButtonOnPress === 'function') {
secondaryButtonOnPress();
}
}, [secondaryButtonOnPress]);
return ( return (
<View style={styles.container}> <View style={styles.container}>
{hintText ? (
<AppText style={styles.hint} variant="body" numberOfLines={2}>
{hintText}
</AppText>
) : null}
<AppButton title={SCAN_BUTTON_TITLE} onPress={handlePress} style={styles.button} /> <AppButton title={SCAN_BUTTON_TITLE} onPress={handlePress} style={styles.button} />
{secondaryButtonTitle && typeof secondaryButtonOnPress === 'function' ? (
<AppButton title={secondaryButtonTitle} onPress={handleSecondary} style={styles.secondaryButton} />
) : null}
</View> </View>
); );
} }

View File

@ -32,7 +32,7 @@ const UI = {
//Высоты элементов //Высоты элементов
BUTTON_HEIGHT: responsiveSize(Platform.OS === 'ios' ? 48 : 44), 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) HEADER_HEIGHT: responsiveSize(isTablet() ? 80 : Platform.OS === 'ios' ? 70 : 56)
}; };

View File

@ -13,16 +13,18 @@ const AUTH_SETTINGS_KEYS = {
LAST_CONNECTED_SERVER_URL: 'auth_last_connected_server_url', LAST_CONNECTED_SERVER_URL: 'auth_last_connected_server_url',
HIDE_SERVER_URL: 'auth_hide_server_url', HIDE_SERVER_URL: 'auth_hide_server_url',
IDLE_TIMEOUT: 'auth_idle_timeout', IDLE_TIMEOUT: 'auth_idle_timeout',
SERVER_REQUEST_TIMEOUT: 'auth_server_request_timeout',
DEVICE_ID: 'auth_device_id', DEVICE_ID: 'auth_device_id',
DEVICE_SECRET_KEY: 'auth_device_secret_key', DEVICE_SECRET_KEY: 'auth_device_secret_key',
SAVED_LOGIN: 'auth_saved_login', SAVED_LOGIN: 'auth_saved_login',
SAVED_PASSWORD: 'auth_saved_password', SAVED_PASSWORD: 'auth_saved_password',
SAVE_PASSWORD_ENABLED: 'auth_save_password_enabled', 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 = [ const CONNECTION_SETTINGS_KEYS = [
@ -42,5 +44,5 @@ const CONNECTION_SETTINGS_KEYS = [
module.exports = { module.exports = {
AUTH_SETTINGS_KEYS, AUTH_SETTINGS_KEYS,
CONNECTION_SETTINGS_KEYS, CONNECTION_SETTINGS_KEYS,
DEFAULT_IDLE_TIMEOUT DEFAULT_SERVER_REQUEST_TIMEOUT
}; };

View File

@ -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
};

View File

@ -0,0 +1,19 @@
/*
Предрейсовые осмотры - мобильное приложение
Конфигурация совместной работы устройств ввода (клавиатура, встроенный сканер)
*/
//---------
//Константы
//---------
//Режим подстановки данных сканера в поле: всегда замена текущего значения
const SCAN_INPUT_MODE_REPLACE = 'replace';
//----------------
//Интерфейс модуля
//----------------
module.exports = {
SCAN_INPUT_MODE_REPLACE
};

View File

@ -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
};

View File

@ -13,6 +13,9 @@ const CONNECTION_LOST_MESSAGE = 'Нет связи с сервером. Прил
//Заголовок сообщения при переходе в режим офлайн //Заголовок сообщения при переходе в режим офлайн
const OFFLINE_MODE_TITLE = 'Режим офлайн'; const OFFLINE_MODE_TITLE = 'Режим офлайн';
//Сообщение при переходе в офлайн по таймауту простоя
const IDLE_TIMEOUT_OFFLINE_MESSAGE = 'Сессия закрыта по таймауту простоя. Приложение переведено в режим офлайн.';
//Сообщение при проверке соединения при старте приложения //Сообщение при проверке соединения при старте приложения
const STARTUP_CHECK_CONNECTION_MESSAGE = 'Проверка соединения...'; const STARTUP_CHECK_CONNECTION_MESSAGE = 'Проверка соединения...';
@ -35,7 +38,10 @@ const MENU_ITEM_LOGOUT = 'Выход';
const AUTH_SCREEN_TITLE = 'Вход в приложение'; const AUTH_SCREEN_TITLE = 'Вход в приложение';
const AUTH_BUTTON_LOGIN = 'Войти'; const AUTH_BUTTON_LOGIN = 'Войти';
const AUTH_BUTTON_LOADING = 'Вход...'; const AUTH_BUTTON_LOADING = 'Вход...';
const AUTH_BUTTON_LOGOUT = 'Выйти';
const LOGOUT_IN_PROGRESS_MESSAGE = 'Выход...'; const LOGOUT_IN_PROGRESS_MESSAGE = 'Выход...';
const LOGOUT_CONFIRM_TITLE = 'Подтверждение выхода';
const LOGOUT_CONFIRM_MESSAGE = 'Вы уверены, что хотите выйти?';
//Диалог подтверждения при смене сервера (относительно последнего успешного подключения) //Диалог подтверждения при смене сервера (относительно последнего успешного подключения)
const AUTH_SERVER_CHANGE_CONFIRM_TITLE = 'Подтверждение входа'; const AUTH_SERVER_CHANGE_CONFIRM_TITLE = 'Подтверждение входа';
@ -52,10 +58,16 @@ const SCREEN_TITLE_SETTINGS = 'Настройки';
//Сканер на главном экране //Сканер на главном экране
const SCANNER_SETTING_LABEL = 'Всегда отображать сканер на главном экране'; const SCANNER_SETTING_LABEL = 'Всегда отображать сканер на главном экране';
const SCANNER_PRIORITY_LABEL = 'Приоритет на главном экране';
const SCANNER_PRIORITY_CAMERA = 'Камера';
const SCANNER_PRIORITY_HARDWARE = 'Встроенный сканер';
const SCAN_BUTTON_TITLE = 'Сканировать'; const SCAN_BUTTON_TITLE = 'Сканировать';
const SCAN_RESULT_MODAL_TITLE = 'Результат сканирования'; const SCAN_RESULT_MODAL_TITLE = 'Результат сканирования';
const SCAN_RESULT_CLOSE_BUTTON = 'Закрыть'; const SCAN_RESULT_CLOSE_BUTTON = 'Закрыть';
//Оверлей паузы камеры на главном экране: подсказка включить камеру по тапу
const SCANNER_CAMERA_TAP_TO_RESUME = 'Нажмите на область камеры для её включения';
//---------------- //----------------
//Интерфейс модуля //Интерфейс модуля
//---------------- //----------------
@ -63,6 +75,7 @@ const SCAN_RESULT_CLOSE_BUTTON = 'Закрыть';
module.exports = { module.exports = {
CONNECTION_LOST_MESSAGE, CONNECTION_LOST_MESSAGE,
OFFLINE_MODE_TITLE, OFFLINE_MODE_TITLE,
IDLE_TIMEOUT_OFFLINE_MESSAGE,
STARTUP_CHECK_CONNECTION_MESSAGE, STARTUP_CHECK_CONNECTION_MESSAGE,
APP_ABOUT_TITLE, APP_ABOUT_TITLE,
SIDE_MENU_TITLE, SIDE_MENU_TITLE,
@ -74,7 +87,10 @@ module.exports = {
AUTH_SCREEN_TITLE, AUTH_SCREEN_TITLE,
AUTH_BUTTON_LOGIN, AUTH_BUTTON_LOGIN,
AUTH_BUTTON_LOADING, AUTH_BUTTON_LOADING,
AUTH_BUTTON_LOGOUT,
LOGOUT_IN_PROGRESS_MESSAGE, LOGOUT_IN_PROGRESS_MESSAGE,
LOGOUT_CONFIRM_TITLE,
LOGOUT_CONFIRM_MESSAGE,
AUTH_SERVER_CHANGE_CONFIRM_TITLE, AUTH_SERVER_CHANGE_CONFIRM_TITLE,
AUTH_SERVER_CHANGE_CONFIRM_MESSAGE, AUTH_SERVER_CHANGE_CONFIRM_MESSAGE,
AUTH_SERVER_CHANGE_CONFIRM_BUTTON, AUTH_SERVER_CHANGE_CONFIRM_BUTTON,
@ -83,7 +99,11 @@ module.exports = {
SETTINGS_RESET_SUCCESS_MESSAGE, SETTINGS_RESET_SUCCESS_MESSAGE,
SCREEN_TITLE_SETTINGS, SCREEN_TITLE_SETTINGS,
SCANNER_SETTING_LABEL, SCANNER_SETTING_LABEL,
SCANNER_PRIORITY_LABEL,
SCANNER_PRIORITY_CAMERA,
SCANNER_PRIORITY_HARDWARE,
SCAN_BUTTON_TITLE, SCAN_BUTTON_TITLE,
SCAN_RESULT_MODAL_TITLE, SCAN_RESULT_MODAL_TITLE,
SCAN_RESULT_CLOSE_BUTTON SCAN_RESULT_CLOSE_BUTTON,
SCANNER_CAMERA_TAP_TO_RESUME
}; };

View File

@ -10,8 +10,24 @@
//Типы кодов для распознавания: QR и распространённые штрихкоды //Типы кодов для распознавания: QR и распространённые штрихкоды
const DEFAULT_CODE_TYPES = ['qr', 'code-128', 'code-39', 'ean-13', 'ean-8', 'upc-a', 'upc-e', 'pdf-417', 'aztec', 'data-matrix']; 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); return Boolean(alwaysShowScanner && !hasScanResult && !isStartupCheckInProgress);
} }
@ -21,5 +37,12 @@ function shouldShowScanner({ alwaysShowScanner, hasScanResult, isStartupCheckInP
module.exports = { module.exports = {
DEFAULT_CODE_TYPES, 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 shouldShowScanner
}; };

View File

@ -44,7 +44,6 @@ class SQLiteDatabase {
this.isInitialized = true; this.isInitialized = true;
return this.db; return this.db;
} catch (error) { } catch (error) {
console.error('Ошибка инициализации базы данных:', error);
throw error; throw error;
} }
} }
@ -59,15 +58,8 @@ class SQLiteDatabase {
//Выполняем SQL запросы последовательно //Выполняем SQL запросы последовательно
await this.executeQuery(this.sqlQueries.CREATE_TABLE_APP_SETTINGS); 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_TABLE_AUTH_SESSION);
await this.executeQuery(this.sqlQueries.CREATE_INDEX_INSPECTIONS_STATUS);
await this.executeQuery(this.sqlQueries.CREATE_INDEX_INSPECTIONS_CREATED);
} catch (error) { } catch (error) {
console.error('Ошибка настройки базы данных:', error);
throw error; throw error;
} }
} }
@ -82,7 +74,6 @@ class SQLiteDatabase {
const result = await this.db.executeAsync(sql, params); const result = await this.db.executeAsync(sql, params);
return result; return result;
} catch (error) { } catch (error) {
console.error('Ошибка выполнения SQL запроса:', error, 'SQL:', sql, 'Params:', params);
throw error; throw error;
} }
} }
@ -97,7 +88,6 @@ class SQLiteDatabase {
} }
return null; return null;
} catch (error) { } catch (error) {
console.error('Ошибка получения настройки:', error);
throw error; throw error;
} }
} }
@ -109,7 +99,6 @@ class SQLiteDatabase {
await this.executeQuery(this.sqlQueries.SETTINGS_SET, [key, stringValue]); await this.executeQuery(this.sqlQueries.SETTINGS_SET, [key, stringValue]);
return true; return true;
} catch (error) { } catch (error) {
console.error('Ошибка сохранения настройки:', error);
throw error; throw error;
} }
} }
@ -120,7 +109,6 @@ class SQLiteDatabase {
await this.executeQuery(this.sqlQueries.SETTINGS_DELETE, [key]); await this.executeQuery(this.sqlQueries.SETTINGS_DELETE, [key]);
return true; return true;
} catch (error) { } catch (error) {
console.error('Ошибка удаления настройки:', error);
throw error; throw error;
} }
} }
@ -144,7 +132,6 @@ class SQLiteDatabase {
return settings; return settings;
} catch (error) { } catch (error) {
console.error('Ошибка получения всех настроек:', error);
throw error; throw error;
} }
} }
@ -155,126 +142,6 @@ class SQLiteDatabase {
await this.executeQuery(this.sqlQueries.SETTINGS_CLEAR_ALL, []); await this.executeQuery(this.sqlQueries.SETTINGS_CLEAR_ALL, []);
return true; return true;
} catch (error) { } 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; throw error;
} }
} }
@ -285,7 +152,6 @@ class SQLiteDatabase {
await this.executeQuery(this.sqlQueries.UTILITY_VACUUM, []); await this.executeQuery(this.sqlQueries.UTILITY_VACUUM, []);
return true; return true;
} catch (error) { } catch (error) {
console.error('Ошибка оптимизации базы данных:', error);
throw error; throw error;
} }
} }
@ -316,7 +182,6 @@ class SQLiteDatabase {
]); ]);
return true; return true;
} catch (error) { } catch (error) {
console.error('Ошибка сохранения сессии авторизации:', error);
throw error; throw error;
} }
} }
@ -343,7 +208,6 @@ class SQLiteDatabase {
} }
return null; return null;
} catch (error) { } catch (error) {
console.error('Ошибка получения сессии авторизации:', error);
throw error; throw error;
} }
} }
@ -354,7 +218,6 @@ class SQLiteDatabase {
await this.executeQuery(this.sqlQueries.AUTH_SESSION_CLEAR, []); await this.executeQuery(this.sqlQueries.AUTH_SESSION_CLEAR, []);
return true; return true;
} catch (error) { } catch (error) {
console.error('Ошибка очистки сессии авторизации:', error);
throw error; throw error;
} }
} }
@ -365,7 +228,6 @@ class SQLiteDatabase {
const result = await this.executeQuery(this.sqlQueries.UTILITY_CHECK_TABLE, [tableName]); const result = await this.executeQuery(this.sqlQueries.UTILITY_CHECK_TABLE, [tableName]);
return result.rows && result.rows.length > 0 && result.rows.item(0).exists === 1; return result.rows && result.rows.length > 0 && result.rows.item(0).exists === 1;
} catch (error) { } catch (error) {
console.error('Ошибка проверки существования таблицы:', error);
throw error; throw error;
} }
} }
@ -376,7 +238,6 @@ class SQLiteDatabase {
await this.executeQuery(this.sqlQueries.UTILITY_DROP_TABLE, [tableName]); await this.executeQuery(this.sqlQueries.UTILITY_DROP_TABLE, [tableName]);
return true; return true;
} catch (error) { } catch (error) {
console.error('Ошибка удаления таблицы:', error);
throw error; throw error;
} }
} }
@ -391,7 +252,6 @@ class SQLiteDatabase {
this.isInitialized = false; this.isInitialized = false;
} }
} catch (error) { } catch (error) {
console.error('Ошибка закрытия базы данных:', error);
this.db = null; this.db = null;
this.isInitialized = false; this.isInitialized = false;
throw error; throw error;

View File

@ -9,13 +9,8 @@
//Таблицы //Таблицы
const CREATE_TABLE_APP_SETTINGS = require('./settings/create_table_app_settings.sql'); 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_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_GET = require('./settings/get_setting.sql');
const SETTINGS_SET = require('./settings/set_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_CLEAR_ALL = require('./settings/clear_all_settings.sql');
const SETTINGS_GET_ALL = require('./settings/get_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_SET = require('./auth/set_auth_session.sql');
const AUTH_SESSION_GET = require('./auth/get_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 запросов в один объект //Сбор всех SQL запросов в один объект
const SQLQueries = { const SQLQueries = {
CREATE_TABLE_APP_SETTINGS, CREATE_TABLE_APP_SETTINGS,
CREATE_TABLE_INSPECTIONS,
CREATE_TABLE_AUTH_SESSION, CREATE_TABLE_AUTH_SESSION,
CREATE_INDEX_INSPECTIONS_STATUS,
CREATE_INDEX_INSPECTIONS_CREATED,
SETTINGS_GET, SETTINGS_GET,
SETTINGS_SET, SETTINGS_SET,
SETTINGS_DELETE, SETTINGS_DELETE,
SETTINGS_CLEAR_ALL, SETTINGS_CLEAR_ALL,
SETTINGS_GET_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_SET,
AUTH_SESSION_GET, AUTH_SESSION_GET,
AUTH_SESSION_CLEAR, AUTH_SESSION_CLEAR,

View File

@ -1,19 +0,0 @@
/*
Предрейсовые осмотры - мобильное приложение
SQL запрос: подсчет количества осмотров
*/
//-----------
//Тело модуля
//-----------
const INSPECTIONS_COUNT = `
-- Подсчет количества осмотров
SELECT COUNT(*) as count FROM inspections;
`;
//----------------
//Интерфейс модуля
//----------------
module.exports = INSPECTIONS_COUNT;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -1,19 +0,0 @@
/*
Предрейсовые осмотры - мобильное приложение
SQL запрос: удаление всех осмотров
*/
//-----------
//Тело модуля
//-----------
const INSPECTIONS_DELETE_ALL = `
-- Удаление всех осмотров
DELETE FROM inspections;
`;
//----------------
//Интерфейс модуля
//----------------
module.exports = INSPECTIONS_DELETE_ALL;

View File

@ -1,19 +0,0 @@
/*
Предрейсовые осмотры - мобильное приложение
SQL запрос: удаление осмотра
*/
//-----------
//Тело модуля
//-----------
const INSPECTIONS_DELETE = `
-- Удаление осмотра по ID
DELETE FROM inspections WHERE id = ?;
`;
//----------------
//Интерфейс модуля
//----------------
module.exports = INSPECTIONS_DELETE;

View File

@ -1,19 +0,0 @@
/*
Предрейсовые осмотры - мобильное приложение
SQL запрос: получение всех осмотров
*/
//-----------
//Тело модуля
//-----------
const INSPECTIONS_GET_ALL = `
-- Получение всех осмотров
SELECT * FROM inspections ORDER BY created_at DESC;
`;
//----------------
//Интерфейс модуля
//----------------
module.exports = INSPECTIONS_GET_ALL;

View File

@ -1,19 +0,0 @@
/*
Предрейсовые осмотры - мобильное приложение
SQL запрос: получение осмотра по ID
*/
//-----------
//Тело модуля
//-----------
const INSPECTIONS_GET_BY_ID = `
-- Получение осмотра по ID
SELECT * FROM inspections WHERE id = ?;
`;
//----------------
//Интерфейс модуля
//----------------
module.exports = INSPECTIONS_GET_BY_ID;

View File

@ -1,20 +0,0 @@
/*
Предрейсовые осмотры - мобильное приложение
SQL запрос: вставка нового осмотра
*/
//-----------
//Тело модуля
//-----------
const INSPECTIONS_INSERT = `
-- Вставка нового осмотра
INSERT INTO inspections (id, title, status, created_at, data)
VALUES (?, ?, ?, ?, ?);
`;
//----------------
//Интерфейс модуля
//----------------
module.exports = INSPECTIONS_INSERT;

View File

@ -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;

View File

@ -52,7 +52,6 @@ function useAppMode() {
} }
//При отсутствии сохранённого режима остаётся NOT_CONNECTED //При отсутствии сохранённого режима остаётся NOT_CONNECTED
} catch (error) { } catch (error) {
console.error('Ошибка загрузки режима:', error);
} finally { } finally {
setIsInitialized(true); setIsInitialized(true);
loadingRef.current = false; loadingRef.current = false;
@ -71,9 +70,7 @@ function useAppMode() {
const saveMode = async () => { const saveMode = async () => {
try { try {
await setSetting(STORAGE_KEY, mode); await setSetting(STORAGE_KEY, mode);
} catch (error) { } catch (error) {}
console.error('Ошибка сохранения режима:', error);
}
}; };
saveMode(); saveMode();

View File

@ -9,6 +9,7 @@
const React = require('react'); //React и хуки const React = require('react'); //React и хуки
const { useAppLocalDbContext } = require('../components/layout/AppLocalDbProvider'); //Контекст локальной БД const { useAppLocalDbContext } = require('../components/layout/AppLocalDbProvider'); //Контекст локальной БД
const { getServerRequestTimeoutMs } = require('../utils/serverRequestTimeout'); //Таймаут запроса к серверу
//--------- //---------
//Константы //Константы
@ -60,9 +61,7 @@ function useAppServer() {
let baseURL = ''; let baseURL = '';
try { try {
baseURL = (await getSetting('app_server_url')) || ''; baseURL = (await getSetting('app_server_url')) || '';
} catch (e) { } catch (_e) {}
console.warn('Не удалось прочитать настройки сервера:', e);
}
if (!baseURL) { if (!baseURL) {
return makeRespErr({ return makeRespErr({
@ -77,6 +76,14 @@ function useAppServer() {
const abortController = new AbortController(); const abortController = new AbortController();
abortControllerRef.current = abortController; abortControllerRef.current = abortController;
const timeoutMs = await getServerRequestTimeoutMs(getSetting);
let timeoutId = null;
if (timeoutMs > 0) {
timeoutId = setTimeout(() => {
abortController.abort();
}, timeoutMs);
}
let response = null; let response = null;
let responseJSON = null; let responseJSON = null;
@ -97,6 +104,9 @@ function useAppServer() {
message: `${ERR_NETWORK}: ${e.message || 'неопределённая ошибка'}` message: `${ERR_NETWORK}: ${e.message || 'неопределённая ошибка'}`
}); });
} finally { } finally {
if (timeoutId != null) {
clearTimeout(timeoutId);
}
abortControllerRef.current = null; abortControllerRef.current = null;
} }

View File

@ -9,10 +9,11 @@
const React = require('react'); //React и хуки const React = require('react'); //React и хуки
const { useAppLocalDbContext } = require('../components/layout/AppLocalDbProvider'); //Контекст локальной БД 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 { ACTION_CODES, RESPONSE_STATES, ERROR_MESSAGES } = require('../config/authApi'); //API авторизации
const { generateSecretKey, encryptData, decryptData } = require('../utils/secureStorage'); //Шифрование const { generateSecretKey, encryptData, decryptData } = require('../utils/secureStorage'); //Шифрование
const { getPersistentDeviceId, isPersistentIdAvailable } = require('../utils/deviceId'); //Идентификатор устройства const { getPersistentDeviceId, isPersistentIdAvailable } = require('../utils/deviceId'); //Идентификатор устройства
const { getServerRequestTimeoutMs } = require('../utils/serverRequestTimeout'); //Таймаут запроса к серверу
//----------- //-----------
//Тело модуля //Тело модуля
@ -79,7 +80,8 @@ function useAuth() {
}, [getSetting, setSetting]); }, [getSetting, setSetting]);
//Выполнение запроса к серверу //Выполнение запроса к серверу
const executeRequest = React.useCallback(async (serverUrl, payload) => { const executeRequest = React.useCallback(
async (serverUrl, payload) => {
//Отменяем предыдущий запрос если есть //Отменяем предыдущий запрос если есть
if (abortControllerRef.current) { if (abortControllerRef.current) {
abortControllerRef.current.abort(); abortControllerRef.current.abort();
@ -88,7 +90,15 @@ function useAuth() {
const abortController = new AbortController(); const abortController = new AbortController();
abortControllerRef.current = abortController; abortControllerRef.current = abortController;
let timeoutId = null;
try { try {
const timeoutMs = await getServerRequestTimeoutMs(getSetting);
if (timeoutMs > 0) {
timeoutId = setTimeout(() => {
abortController.abort();
}, timeoutMs);
}
const response = await fetch(serverUrl, { const response = await fetch(serverUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
@ -111,9 +121,14 @@ function useAuth() {
throw new Error(`${ERROR_MESSAGES.NETWORK_ERROR}: ${fetchError.message}`); throw new Error(`${ERROR_MESSAGES.NETWORK_ERROR}: ${fetchError.message}`);
} finally { } finally {
if (timeoutId != null) {
clearTimeout(timeoutId);
}
abortControllerRef.current = null; abortControllerRef.current = null;
} }
}, []); },
[getSetting]
);
//Сохранение credentials после успешной аутентификации //Сохранение credentials после успешной аутентификации
const saveCredentials = React.useCallback( const saveCredentials = React.useCallback(
@ -131,7 +146,6 @@ function useAuth() {
return true; return true;
} catch (saveError) { } catch (saveError) {
console.error('Ошибка сохранения credentials:', saveError);
return false; return false;
} }
}, },
@ -182,7 +196,6 @@ function useAuth() {
savePasswordEnabled: true savePasswordEnabled: true
}; };
} catch (loadError) { } catch (loadError) {
console.error('Ошибка загрузки credentials:', loadError);
return null; return null;
} }
}, [getSetting, getDeviceId, getSecretKey]); }, [getSetting, getDeviceId, getSecretKey]);
@ -195,7 +208,6 @@ function useAuth() {
await setSetting(AUTH_SETTINGS_KEYS.SAVE_PASSWORD_ENABLED, 'false'); await setSetting(AUTH_SETTINGS_KEYS.SAVE_PASSWORD_ENABLED, 'false');
return true; return true;
} catch (clearError) { } catch (clearError) {
console.error('Ошибка очистки credentials:', clearError);
return false; return false;
} }
}, [setSetting]); }, [setSetting]);
@ -229,8 +241,8 @@ function useAuth() {
requestPayload.XREQUEST.XPAYLOAD.SCOMPANY = company; 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; requestPayload.XREQUEST.XPAYLOAD.NTIMEOUT = timeoutValue;
//Выполняем запрос //Выполняем запрос
@ -405,16 +417,18 @@ function useAuth() {
//Выход из системы //Выход из системы
const logout = React.useCallback( const logout = React.useCallback(
async (options = {}) => { async (options = {}) => {
const { skipServerRequest = false } = options; const { skipServerRequest = false, keepSessionLocally = false } = options;
if (!keepSessionLocally) {
setIsLoading(true); setIsLoading(true);
setIsLogoutInProgress(true); setIsLogoutInProgress(true);
}
setError(null); setError(null);
try { try {
const currentSession = session || (await getAuthSession()); const currentSession = session || (await getAuthSession());
//Запрос на сервер только при онлайн и если не отключено опцией //Запрос на сервер при наличии сессии и если не отключено опцией
if (!skipServerRequest && currentSession?.sessionId && currentSession?.serverUrl) { if (!skipServerRequest && currentSession?.sessionId && currentSession?.serverUrl) {
const requestPayload = { const requestPayload = {
XREQUEST: { XREQUEST: {
@ -429,14 +443,14 @@ function useAuth() {
try { try {
await executeRequest(currentSession.serverUrl, requestPayload); await executeRequest(currentSession.serverUrl, requestPayload);
} catch (logoutError) { } catch (_logoutError) {}
console.warn('Ошибка при выходе из системы:', logoutError);
}
} }
if (!keepSessionLocally) {
await clearAuthSession(); await clearAuthSession();
setSession(null); setSession(null);
setIsAuthenticated(false); setIsAuthenticated(false);
}
return { success: true }; return { success: true };
} catch (logoutError) { } catch (logoutError) {
@ -444,9 +458,11 @@ function useAuth() {
setError(errorMessage); setError(errorMessage);
return { success: false, error: errorMessage }; return { success: false, error: errorMessage };
} finally { } finally {
if (!keepSessionLocally) {
setIsLoading(false); setIsLoading(false);
setIsLogoutInProgress(false); setIsLogoutInProgress(false);
} }
}
}, },
[session, getAuthSession, executeRequest, clearAuthSession] [session, getAuthSession, executeRequest, clearAuthSession]
); );
@ -580,8 +596,7 @@ function useAuth() {
setIsAuthenticated(true); setIsAuthenticated(true);
sessionRestoredFromStorageRef.current = true; sessionRestoredFromStorageRef.current = true;
} }
} catch (initError) { } catch (_initError) {
console.error('Ошибка инициализации авторизации:', initError);
} finally { } finally {
setIsInitialized(true); setIsInitialized(true);
} }

View File

@ -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;

View File

@ -17,7 +17,6 @@ const SQLiteDatabase = require('../database/SQLiteDatabase'); //Модуль SQL
function useLocalDb() { function useLocalDb() {
//Состояние хука //Состояние хука
const [isDbReady, setIsDbReady] = React.useState(false); const [isDbReady, setIsDbReady] = React.useState(false);
const [inspections, setInspections] = React.useState([]);
const [dbError, setDbError] = React.useState(null); const [dbError, setDbError] = React.useState(null);
//Отслеживания статуса инициализации //Отслеживания статуса инициализации
@ -33,12 +32,6 @@ function useLocalDb() {
if (initializingRef.current || SQLiteDatabase.isInitialized) { if (initializingRef.current || SQLiteDatabase.isInitialized) {
if (mounted && SQLiteDatabase.isInitialized) { if (mounted && SQLiteDatabase.isInitialized) {
setIsDbReady(true); setIsDbReady(true);
try {
const loadedInspections = await SQLiteDatabase.getInspections();
setInspections(loadedInspections);
} catch (loadError) {
console.error('Ошибка загрузки осмотров при переинициализации:', loadError);
}
} }
return; return;
} }
@ -49,12 +42,9 @@ function useLocalDb() {
await SQLiteDatabase.initialize(); await SQLiteDatabase.initialize();
if (mounted) { if (mounted) {
setIsDbReady(true); setIsDbReady(true);
const loadedInspections = await SQLiteDatabase.getInspections();
setInspections(loadedInspections);
setDbError(null); setDbError(null);
} }
} catch (initError) { } catch (_initError) {
console.error('Ошибка инициализации базы данных:', initError);
if (mounted) { if (mounted) {
setIsDbReady(false); setIsDbReady(false);
setDbError('Не удалось инициализировать базу данных'); 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( const getSetting = React.useCallback(
async key => { async key => {
@ -122,8 +70,7 @@ function useLocalDb() {
try { try {
return await SQLiteDatabase.getSetting(key); return await SQLiteDatabase.getSetting(key);
} catch (getSettingError) { } catch (_getSettingError) {
console.error('Ошибка получения настройки:', getSettingError);
return null; return null;
} }
}, },
@ -133,15 +80,11 @@ function useLocalDb() {
//Сохранение настройки //Сохранение настройки
const setSetting = React.useCallback( const setSetting = React.useCallback(
async (key, value) => { async (key, value) => {
if (!isDbReady) { if (!isDbReady) return false;
console.warn('База данных не готова');
return false;
}
try { try {
return await SQLiteDatabase.setSetting(key, value); return await SQLiteDatabase.setSetting(key, value);
} catch (setSettingError) { } catch (_setSettingError) {
console.error('Ошибка сохранения настройки:', setSettingError);
return false; return false;
} }
}, },
@ -151,15 +94,11 @@ function useLocalDb() {
//Удаление настройки //Удаление настройки
const deleteSetting = React.useCallback( const deleteSetting = React.useCallback(
async key => { async key => {
if (!isDbReady) { if (!isDbReady) return false;
console.warn('База данных не готова');
return false;
}
try { try {
return await SQLiteDatabase.deleteSetting(key); return await SQLiteDatabase.deleteSetting(key);
} catch (deleteSettingError) { } catch (_deleteSettingError) {
console.error('Ошибка удаления настройки:', deleteSettingError);
return false; return false;
} }
}, },
@ -168,66 +107,33 @@ function useLocalDb() {
//Получение всех настроек //Получение всех настроек
const getAllSettings = React.useCallback(async () => { const getAllSettings = React.useCallback(async () => {
if (!isDbReady) { if (!isDbReady) return {};
console.warn('База данных не готова');
return {};
}
try { try {
return await SQLiteDatabase.getAllSettings(); return await SQLiteDatabase.getAllSettings();
} catch (getAllSettingsError) { } catch (_getAllSettingsError) {
console.error('Ошибка получения всех настроек:', getAllSettingsError);
return {}; return {};
} }
}, [isDbReady]); }, [isDbReady]);
//Очистка всех настроек //Очистка всех настроек
const clearSettings = React.useCallback(async () => { const clearSettings = React.useCallback(async () => {
if (!isDbReady) { if (!isDbReady) return false;
console.warn('База данных не готова');
return false;
}
try { try {
return await SQLiteDatabase.clearSettings(); return await SQLiteDatabase.clearSettings();
} catch (clearSettingsError) { } 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('Не удалось очистить осмотры');
return false; return false;
} }
}, [isDbReady]); }, [isDbReady]);
//Оптимизация базы данных //Оптимизация базы данных
const vacuum = React.useCallback(async () => { const vacuum = React.useCallback(async () => {
if (!isDbReady) { if (!isDbReady) return false;
console.warn('База данных не готова');
return false;
}
try { try {
return await SQLiteDatabase.vacuum(); return await SQLiteDatabase.vacuum();
} catch (vacuumError) { } catch (_vacuumError) {
console.error('Ошибка оптимизации базы данных:', vacuumError);
return false; return false;
} }
}, [isDbReady]); }, [isDbReady]);
@ -235,15 +141,11 @@ function useLocalDb() {
//Проверка существования таблицы //Проверка существования таблицы
const checkTableExists = React.useCallback( const checkTableExists = React.useCallback(
async tableName => { async tableName => {
if (!isDbReady) { if (!isDbReady) return false;
console.warn('База данных не готова');
return false;
}
try { try {
return await SQLiteDatabase.checkTableExists(tableName); return await SQLiteDatabase.checkTableExists(tableName);
} catch (checkTableError) { } catch (_checkTableError) {
console.error('Ошибка проверки существования таблицы:', checkTableError);
return false; return false;
} }
}, },
@ -253,15 +155,11 @@ function useLocalDb() {
//Сохранение сессии авторизации //Сохранение сессии авторизации
const setAuthSession = React.useCallback( const setAuthSession = React.useCallback(
async session => { async session => {
if (!isDbReady) { if (!isDbReady) return false;
console.warn('База данных не готова');
return false;
}
try { try {
return await SQLiteDatabase.setAuthSession(session); return await SQLiteDatabase.setAuthSession(session);
} catch (setAuthSessionError) { } catch (_setAuthSessionError) {
console.error('Ошибка сохранения сессии авторизации:', setAuthSessionError);
return false; return false;
} }
}, },
@ -270,46 +168,34 @@ function useLocalDb() {
//Получение сессии авторизации //Получение сессии авторизации
const getAuthSession = React.useCallback(async () => { const getAuthSession = React.useCallback(async () => {
if (!isDbReady) { if (!isDbReady) return null;
console.warn('База данных не готова');
return null;
}
try { try {
return await SQLiteDatabase.getAuthSession(); return await SQLiteDatabase.getAuthSession();
} catch (getAuthSessionError) { } catch (_getAuthSessionError) {
console.error('Ошибка получения сессии авторизации:', getAuthSessionError);
return null; return null;
} }
}, [isDbReady]); }, [isDbReady]);
//Очистка сессии авторизации //Очистка сессии авторизации
const clearAuthSession = React.useCallback(async () => { const clearAuthSession = React.useCallback(async () => {
if (!isDbReady) { if (!isDbReady) return false;
console.warn('База данных не готова');
return false;
}
try { try {
return await SQLiteDatabase.clearAuthSession(); return await SQLiteDatabase.clearAuthSession();
} catch (clearAuthSessionError) { } catch (_clearAuthSessionError) {
console.error('Ошибка очистки сессии авторизации:', clearAuthSessionError);
return false; return false;
} }
}, [isDbReady]); }, [isDbReady]);
return { return {
isDbReady, isDbReady,
inspections,
error: dbError, error: dbError,
loadInspections,
saveInspection,
getSetting, getSetting,
setSetting, setSetting,
deleteSetting, deleteSetting,
getAllSettings, getAllSettings,
clearSettings, clearSettings,
clearInspections,
vacuum, vacuum,
checkTableExists, checkTableExists,
setAuthSession, setAuthSession,

View File

@ -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;

View File

@ -23,12 +23,17 @@ const OrganizationSelectDialog = require('../components/auth/OrganizationSelectD
const { useAppMessagingContext } = require('../components/layout/AppMessagingProvider'); //Контекст сообщений const { useAppMessagingContext } = require('../components/layout/AppMessagingProvider'); //Контекст сообщений
const { useAppModeContext } = require('../components/layout/AppModeProvider'); //Контекст режима const { useAppModeContext } = require('../components/layout/AppModeProvider'); //Контекст режима
const { useAppNavigationContext } = require('../components/layout/AppNavigationProvider'); //Контекст навигации const { useAppNavigationContext } = require('../components/layout/AppNavigationProvider'); //Контекст навигации
const { useHardwareScannerContext } = require('../components/layout/HardwareScannerProvider'); //Встроенный сканер
const { useAppLocalDbContext } = require('../components/layout/AppLocalDbProvider'); //Контекст локальной БД const { useAppLocalDbContext } = require('../components/layout/AppLocalDbProvider'); //Контекст локальной БД
const { useAppAuthContext } = require('../components/layout/AppAuthProvider'); //Контекст авторизации const { useAppAuthContext } = require('../components/layout/AppAuthProvider'); //Контекст авторизации
const { getAppInfo } = require('../utils/appInfo'); //Информация о приложении const { getAppInfo } = require('../utils/appInfo'); //Информация о приложении
const { isServerUrlFieldVisible } = require('../utils/loginFormUtils'); //Утилиты формы входа 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 { normalizeServerUrl, validateServerUrl } = require('../utils/validation'); //Валидация
const { AUTH_SETTINGS_KEYS } = require('../config/authConfig'); //Конфиг авторизации 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 { getAuthFormStore, setAuthFormStore, clearAuthFormStore } = require('../utils/authFormStore'); //Хранилище формы входа
const { const {
APP_ABOUT_TITLE, APP_ABOUT_TITLE,
@ -36,14 +41,22 @@ const {
ORGANIZATION_SELECT_DIALOG_TITLE, ORGANIZATION_SELECT_DIALOG_TITLE,
MENU_ITEM_SETTINGS, MENU_ITEM_SETTINGS,
MENU_ITEM_ABOUT, MENU_ITEM_ABOUT,
MENU_ITEM_LOGOUT,
AUTH_SCREEN_TITLE, AUTH_SCREEN_TITLE,
AUTH_BUTTON_LOGIN, AUTH_BUTTON_LOGIN,
AUTH_BUTTON_LOADING, AUTH_BUTTON_LOADING,
AUTH_BUTTON_LOGOUT,
AUTH_SERVER_CHANGE_CONFIRM_TITLE, AUTH_SERVER_CHANGE_CONFIRM_TITLE,
AUTH_SERVER_CHANGE_CONFIRM_MESSAGE, AUTH_SERVER_CHANGE_CONFIRM_MESSAGE,
AUTH_SERVER_CHANGE_CONFIRM_BUTTON, AUTH_SERVER_CHANGE_CONFIRM_BUTTON,
AUTH_SERVER_CHANGE_CANCEL_BUTTON AUTH_SERVER_CHANGE_CANCEL_BUTTON,
LOGOUT_CONFIRM_TITLE,
LOGOUT_CONFIRM_MESSAGE
} = require('../config/messages'); //Сообщения } = 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'); //Стили экрана const styles = require('../styles/screens/AuthScreen.styles'); //Стили экрана
//----------- //-----------
@ -53,12 +66,13 @@ const styles = require('../styles/screens/AuthScreen.styles'); //Стили эк
//Экран аутентификации //Экран аутентификации
function AuthScreen() { function AuthScreen() {
const { showError, showInfo } = useAppMessagingContext(); const { showError, showInfo } = useAppMessagingContext();
const { APP_MODE, mode, setOnline } = useAppModeContext(); const { APP_MODE, mode, setOnline, setNotConnected } = useAppModeContext();
const { navigate, goBack, canGoBack, reset, screenParams, currentScreen, SCREENS } = useAppNavigationContext(); const { navigate, goBack, canGoBack, reset, setInitialScreen, screenParams, currentScreen, SCREENS } = useAppNavigationContext();
const { getSetting, isDbReady, clearInspections } = useAppLocalDbContext(); const { getSetting, isDbReady } = useAppLocalDbContext();
const { const {
session, session,
login, login,
logout,
selectCompany, selectCompany,
isLoading, isLoading,
getSavedCredentials, getSavedCredentials,
@ -115,6 +129,42 @@ function AuthScreen() {
const loginInputRef = React.useRef(null); const loginInputRef = React.useRef(null);
const passwordInputRef = 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 //Актуализация ref и store при изменении полей; при размонтировании — сохранение в store
React.useEffect(() => { React.useEffect(() => {
const next = { serverUrl, username, password, savePassword, showPassword }; const next = { serverUrl, username, password, savePassword, showPassword };
@ -183,7 +233,7 @@ function AuthScreen() {
setUsername(val.trim()); setUsername(val.trim());
} }
}) })
.catch(() => {}); .catch(noopCatch);
return () => { return () => {
cancelled = true; cancelled = true;
}; };
@ -240,7 +290,6 @@ function AuthScreen() {
initialLoadRef.current = true; initialLoadRef.current = true;
} catch (loadError) { } catch (loadError) {
console.error('Ошибка загрузки credentials из меню:', loadError);
setUsername(''); setUsername('');
setPassword(''); setPassword('');
setSavePassword(false); setSavePassword(false);
@ -264,9 +313,7 @@ function AuthScreen() {
if (savedLogin && typeof savedLogin === 'string') { if (savedLogin && typeof savedLogin === 'string') {
setUsername(savedLogin.trim()); setUsername(savedLogin.trim());
} }
} catch (e) { } catch (_e) {}
console.warn('Резервная загрузка логина:', e);
}
}; };
loadSavedLoginOnly(); loadSavedLoginOnly();
@ -288,9 +335,7 @@ function AuthScreen() {
} }
setSavedServerUrlFromSettings(trimmedUrl); setSavedServerUrlFromSettings(trimmedUrl);
setHideServerUrl(savedHide === 'true' || savedHide === true); setHideServerUrl(savedHide === 'true' || savedHide === true);
} catch (e) { } catch (_e) {}
console.warn('Загрузка адреса сервера и настройки видимости:', e);
}
}; };
loadServerAndVisibility(); loadServerAndVisibility();
@ -338,8 +383,7 @@ function AuthScreen() {
setPassword(''); setPassword('');
setSavePassword(false); setSavePassword(false);
} }
} catch (loadError) { } catch (_loadError) {
console.error('Ошибка загрузки настроек авторизации:', loadError);
} finally { } finally {
setIsSettingsLoaded(true); setIsSettingsLoaded(true);
} }
@ -372,20 +416,25 @@ function AuthScreen() {
}, [hideServerUrl, serverUrl, username, password, showError]); }, [hideServerUrl, serverUrl, username, password, showError]);
//Выполнение входа //Выполнение входа
const performLogin = React.useCallback(async () => { 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);
let idleTimeout = null; let idleTimeout = null;
try { try {
idleTimeout = await getSetting(AUTH_SETTINGS_KEYS.IDLE_TIMEOUT); idleTimeout = await getSetting(AUTH_SETTINGS_KEYS.IDLE_TIMEOUT);
} catch (settingError) { } catch (_settingError) {}
console.warn('Ошибка получения таймаута:', settingError);
}
const result = await login({ const result = await login({
serverUrl: serverUrl.trim(), serverUrl: s,
user: username.trim(), user: u,
password: password.trim(), password: p,
timeout: idleTimeout ? parseInt(idleTimeout, 10) : null, timeout: idleTimeout ? parseInt(idleTimeout, 10) : null,
savePassword savePassword: save
}); });
if (!result.success) { if (!result.success) {
@ -410,7 +459,34 @@ function AuthScreen() {
clearAuthFormData(); clearAuthFormData();
clearAuthFormStore(); clearAuthFormStore();
reset(); reset();
}, [login, serverUrl, username, password, savePassword, getSetting, showError, setOnline, clearAuthFormData, 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;
}
if (!p) {
showError('Укажите пароль');
return;
}
performLogin(form);
}, [hideServerUrl, showError, performLogin]);
//Обработчик входа (очистка локальных данных только при смене сервера относительно последнего успешного подключения) //Обработчик входа (очистка локальных данных только при смене сервера относительно последнего успешного подключения)
const handleLogin = React.useCallback(async () => { const handleLogin = React.useCallback(async () => {
@ -440,7 +516,6 @@ function AuthScreen() {
title: AUTH_SERVER_CHANGE_CONFIRM_BUTTON, title: AUTH_SERVER_CHANGE_CONFIRM_BUTTON,
onPress: async () => { onPress: async () => {
await clearAuthSession(); await clearAuthSession();
await clearInspections();
await performLogin(); await performLogin();
} }
} }
@ -449,7 +524,7 @@ function AuthScreen() {
} else { } else {
await performLogin(); await performLogin();
} }
}, [validateForm, serverUrl, getAuthSession, getSetting, showInfo, clearAuthSession, clearInspections, performLogin]); }, [validateForm, serverUrl, getAuthSession, getSetting, showInfo, clearAuthSession, performLogin]);
//Обработчик выбора организации //Обработчик выбора организации
const handleSelectOrganization = React.useCallback( const handleSelectOrganization = React.useCallback(
@ -536,6 +611,60 @@ function AuthScreen() {
handleLogin(); handleLogin();
}, [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(() => { const handleMenuOpen = React.useCallback(() => {
setMenuVisible(true); setMenuVisible(true);
@ -564,24 +693,67 @@ function AuthScreen() {
}); });
}, [showInfo, mode, serverUrl, isDbReady]); }, [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(() => { const menuItems = React.useMemo(() => {
return [ const items = [
{ {
id: 'settings', id: MENU_ITEM_ID_SETTINGS,
title: MENU_ITEM_SETTINGS, title: MENU_ITEM_SETTINGS,
onPress: handleOpenSettings onPress: handleOpenSettings
}, },
{ {
id: 'about', id: MENU_ITEM_ID_ABOUT,
title: MENU_ITEM_ABOUT, title: MENU_ITEM_ABOUT,
onPress: handleShowAbout onPress: handleShowAbout
} }
]; ];
}, [handleOpenSettings, handleShowAbout]);
//Поле сервера показываем, если настройка выключена или в настройках ещё не сохранён адрес if (isFromMenu) {
const shouldShowServerUrl = isServerUrlFieldVisible(hideServerUrl, savedServerUrlFromSettings); 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 ( return (
<AdaptiveView padding={false}> <AdaptiveView padding={false}>
@ -622,6 +794,8 @@ function AuthScreen() {
blurOnSubmit={false} blurOnSubmit={false}
returnKeyType="next" returnKeyType="next"
onSubmitEditing={handleServerSubmitEditing} onSubmitEditing={handleServerSubmitEditing}
onFocus={handleServerFocus}
onBlur={handleFieldBlur}
/> />
) : null} ) : null}
@ -638,6 +812,8 @@ function AuthScreen() {
blurOnSubmit={false} blurOnSubmit={false}
returnKeyType="next" returnKeyType="next"
onSubmitEditing={handleLoginSubmitEditing} onSubmitEditing={handleLoginSubmitEditing}
onFocus={handleLoginFocus}
onBlur={handleFieldBlur}
/> />
<PasswordInput <PasswordInput
@ -653,6 +829,8 @@ function AuthScreen() {
blurOnSubmit={true} blurOnSubmit={true}
returnKeyType="done" returnKeyType="done"
onSubmitEditing={handlePasswordSubmitEditing} onSubmitEditing={handlePasswordSubmitEditing}
onFocus={handlePasswordFocus}
onBlur={handleFieldBlur}
/> />
<View style={styles.switchContainer}> <View style={styles.switchContainer}>
@ -666,6 +844,16 @@ function AuthScreen() {
style={styles.loginButton} style={styles.loginButton}
textStyle={styles.loginButtonText} textStyle={styles.loginButtonText}
/> />
{isFromMenu ? (
<AppButton
title={AUTH_BUTTON_LOGOUT}
onPress={handleLogoutFromAuth}
disabled={isLoading}
style={styles.logoutButton}
textStyle={styles.logoutButtonText}
/>
) : null}
</View> </View>
</ScrollView> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>

View File

@ -12,6 +12,7 @@ const { View } = require('react-native'); //Базовые компоненты
const { useAppMessagingContext } = require('../components/layout/AppMessagingProvider'); //Контекст сообщений const { useAppMessagingContext } = require('../components/layout/AppMessagingProvider'); //Контекст сообщений
const { useAppModeContext } = require('../components/layout/AppModeProvider'); //Контекст режима работы const { useAppModeContext } = require('../components/layout/AppModeProvider'); //Контекст режима работы
const { useAppNavigationContext } = require('../components/layout/AppNavigationProvider'); //Контекст навигации const { useAppNavigationContext } = require('../components/layout/AppNavigationProvider'); //Контекст навигации
const { useHardwareScannerContext } = require('../components/layout/HardwareScannerProvider'); //Встроенный сканер
const { useAppLocalDbContext } = require('../components/layout/AppLocalDbProvider'); //Контекст локальной БД const { useAppLocalDbContext } = require('../components/layout/AppLocalDbProvider'); //Контекст локальной БД
const { useAppAuthContext } = require('../components/layout/AppAuthProvider'); //Контекст авторизации const { useAppAuthContext } = require('../components/layout/AppAuthProvider'); //Контекст авторизации
const AppHeader = require('../components/layout/AppHeader'); //Заголовок с меню const AppHeader = require('../components/layout/AppHeader'); //Заголовок с меню
@ -20,10 +21,25 @@ const ScannerArea = require('../components/scanner/ScannerArea'); //Област
const ScanResultModal = require('../components/scanner/ScanResultModal'); //Модальное окно результата сканирования const ScanResultModal = require('../components/scanner/ScanResultModal'); //Модальное окно результата сканирования
const { AUTH_SETTINGS_KEYS } = require('../config/authConfig'); //Ключи настроек const { AUTH_SETTINGS_KEYS } = require('../config/authConfig'); //Ключи настроек
const { getAppInfo } = require('../utils/appInfo'); //Информация о приложении 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 { DIALOG_BUTTON_TYPE, DIALOG_CANCEL_BUTTON, getConfirmButtonOptions } = require('../config/dialogButtons'); //Кнопки диалогов
const { createLogoutHandler } = require('../utils/logoutFlow'); //Универсальный поток выхода
const { APP_COLORS } = require('../config/theme'); //Цветовая схема 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'); //Стили экрана const styles = require('../styles/screens/MainScreen.styles'); //Стили экрана
//----------- //-----------
@ -32,7 +48,7 @@ const styles = require('../styles/screens/MainScreen.styles'); //Стили эк
//Главный экран приложения //Главный экран приложения
function MainScreen() { function MainScreen() {
const { showInfo, showError } = useAppMessagingContext(); const { showInfo, showError, state: messagingState } = useAppMessagingContext();
const { mode, setNotConnected } = useAppModeContext(); const { mode, setNotConnected } = useAppModeContext();
const { navigate, SCREENS, setInitialScreen } = useAppNavigationContext(); const { navigate, SCREENS, setInitialScreen } = useAppNavigationContext();
const { getSetting, isDbReady: isLocalDbReady } = useAppLocalDbContext(); const { getSetting, isDbReady: isLocalDbReady } = useAppLocalDbContext();
@ -41,7 +57,60 @@ function MainScreen() {
const [menuVisible, setMenuVisible] = React.useState(false); const [menuVisible, setMenuVisible] = React.useState(false);
const [serverUrl, setServerUrl] = React.useState(''); const [serverUrl, setServerUrl] = React.useState('');
const [alwaysShowScanner, setAlwaysShowScanner] = React.useState(false); 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 [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(() => { React.useEffect(() => {
@ -58,9 +127,10 @@ function MainScreen() {
const savedAlwaysShowScanner = await getSetting(AUTH_SETTINGS_KEYS.ALWAYS_SHOW_SCANNER); const savedAlwaysShowScanner = await getSetting(AUTH_SETTINGS_KEYS.ALWAYS_SHOW_SCANNER);
setAlwaysShowScanner(savedAlwaysShowScanner === 'true' || savedAlwaysShowScanner === true); 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(); loadMainScreenSettings();
@ -99,38 +169,37 @@ function MainScreen() {
navigate(SCREENS.SETTINGS); navigate(SCREENS.SETTINGS);
}, [navigate, SCREENS.SETTINGS]); }, [navigate, SCREENS.SETTINGS]);
//Выполнение выхода (для диалога подтверждения) //Универсальный обработчик выхода
const performLogout = React.useCallback(async () => { const { handleLogout } = React.useMemo(
const result = await logout({ skipServerRequest: mode === 'OFFLINE' }); () =>
createLogoutHandler({
if (result.success) { logout,
setNotConnected(); mode,
setInitialScreen(SCREENS.AUTH); setNotConnected,
} else { setInitialScreen,
showError(result.error || 'Ошибка выхода'); SCREENS,
} showError,
}, [logout, mode, showError, setNotConnected, setInitialScreen, SCREENS.AUTH]); showInfo,
logoutConfirmTitle: LOGOUT_CONFIRM_TITLE,
//Обработчик выхода из приложения logoutConfirmMessage: LOGOUT_CONFIRM_MESSAGE,
const handleLogout = React.useCallback(() => { logoutButtonTitle: AUTH_BUTTON_LOGOUT,
const confirmButton = getConfirmButtonOptions(DIALOG_BUTTON_TYPE.ERROR, 'Выйти', performLogout); getConfirmButtonOptions,
dialogButtonTypeError: DIALOG_BUTTON_TYPE.ERROR,
showInfo('Вы уверены, что хотите выйти?', { dialogCancelButton: DIALOG_CANCEL_BUTTON
title: 'Подтверждение выхода', }),
buttons: [DIALOG_CANCEL_BUTTON, confirmButton] [logout, mode, setNotConnected, setInitialScreen, SCREENS, showError, showInfo]
}); );
}, [showInfo, performLogout]);
//Пункты бокового меню //Пункты бокового меню
const menuItems = React.useMemo(() => { const menuItems = React.useMemo(() => {
const items = [ const items = [
{ {
id: 'settings', id: MENU_ITEM_ID_SETTINGS,
title: MENU_ITEM_SETTINGS, title: MENU_ITEM_SETTINGS,
onPress: handleOpenSettings onPress: handleOpenSettings
}, },
{ {
id: 'about', id: MENU_ITEM_ID_ABOUT,
title: MENU_ITEM_ABOUT, title: MENU_ITEM_ABOUT,
onPress: handleShowAbout onPress: handleShowAbout
} }
@ -138,14 +207,14 @@ function MainScreen() {
//Добавляем разделитель перед кнопками авторизации //Добавляем разделитель перед кнопками авторизации
items.push({ items.push({
id: 'divider', id: MENU_ITEM_ID_DIVIDER,
type: 'divider' type: 'divider'
}); });
//Кнопка "Вход" для оффлайн режима //Кнопка "Вход" для оффлайн режима
if (mode === 'OFFLINE') { if (mode === 'OFFLINE') {
items.push({ items.push({
id: 'login', id: MENU_ITEM_ID_LOGIN,
title: MENU_ITEM_LOGIN, title: MENU_ITEM_LOGIN,
onPress: handleLogin onPress: handleLogin
}); });
@ -154,10 +223,11 @@ function MainScreen() {
//Кнопка "Выход" для онлайн/оффлайн режима (когда есть сессия) //Кнопка "Выход" для онлайн/оффлайн режима (когда есть сессия)
if ((mode === 'ONLINE' || mode === 'OFFLINE') && isAuthenticated) { if ((mode === 'ONLINE' || mode === 'OFFLINE') && isAuthenticated) {
items.push({ items.push({
id: 'logout', id: MENU_ITEM_ID_LOGOUT,
title: MENU_ITEM_LOGOUT, title: MENU_ITEM_LOGOUT,
onPress: handleLogout, 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({ const isScannerOpen = shouldShowScanner({
alwaysShowScanner, alwaysShowScanner,
hasScanResult: scanResult != null, 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 ( return (
<View style={styles.container}> <View style={styles.container}>
<AppHeader onMenuPress={handleMenuOpen} /> <AppHeader onMenuPress={handleMenuOpen} />
<View style={styles.content}> <View style={styles.content}>
<ScannerArea alwaysShowScanner={alwaysShowScanner} scannerOpen={isScannerOpen} onScanResult={handleScanResult} /> <ScannerArea
alwaysShowScanner={alwaysShowScanner}
scannerOpen={cameraShown}
cameraActive={cameraActiveOnMain}
onScanResult={handleScanResult}
onResumeCameraPress={mainScannerPriority === 'hardware' ? handleResumeCameraByTap : undefined}
/>
</View> </View>
<SideMenu <SideMenu

View File

@ -19,18 +19,22 @@ const AppHeader = require('../components/layout/AppHeader'); //Заголово
const { useAppMessagingContext } = require('../components/layout/AppMessagingProvider'); //Контекст сообщений const { useAppMessagingContext } = require('../components/layout/AppMessagingProvider'); //Контекст сообщений
const { useAppModeContext } = require('../components/layout/AppModeProvider'); //Контекст режима const { useAppModeContext } = require('../components/layout/AppModeProvider'); //Контекст режима
const { useAppNavigationContext } = require('../components/layout/AppNavigationProvider'); //Контекст навигации const { useAppNavigationContext } = require('../components/layout/AppNavigationProvider'); //Контекст навигации
const { useHardwareScannerContext } = require('../components/layout/HardwareScannerProvider'); //Встроенный сканер
const { useAppLocalDbContext } = require('../components/layout/AppLocalDbProvider'); //Контекст локальной БД const { useAppLocalDbContext } = require('../components/layout/AppLocalDbProvider'); //Контекст локальной БД
const { useAppAuthContext } = require('../components/layout/AppAuthProvider'); //Контекст авторизации const { useAppAuthContext } = require('../components/layout/AppAuthProvider'); //Контекст авторизации
const { AUTH_SETTINGS_KEYS, DEFAULT_IDLE_TIMEOUT } = require('../config/authConfig'); //Конфиг авторизации const { AUTH_SETTINGS_KEYS, DEFAULT_SERVER_REQUEST_TIMEOUT } = require('../config/authConfig'); //Конфиг авторизации
const { DIALOG_BUTTON_TYPE, DIALOG_CANCEL_BUTTON, getConfirmButtonOptions } = require('../config/dialogButtons'); //Кнопки диалогов const { DIALOG_BUTTON_TYPE, DIALOG_CANCEL_BUTTON, getConfirmButtonOptions } = require('../config/dialogButtons'); //Кнопки диалогов
const { getAppInfo, getModeLabel } = require('../utils/appInfo'); //Информация о приложении и режиме const { getAppInfo, getModeLabel } = require('../utils/appInfo'); //Информация о приложении и режиме
const { validateServerUrlAllowEmpty, validateIdleTimeout } = require('../utils/validation'); //Валидация const { validateServerUrlAllowEmpty, validateIdleTimeoutAllowEmpty, validateServerRequestTimeout } = require('../utils/validation'); //Валидация
const { const {
APP_ABOUT_TITLE, APP_ABOUT_TITLE,
SETTINGS_SERVER_SAVED_MESSAGE, SETTINGS_SERVER_SAVED_MESSAGE,
SETTINGS_RESET_SUCCESS_MESSAGE, SETTINGS_RESET_SUCCESS_MESSAGE,
MENU_ITEM_ABOUT, MENU_ITEM_ABOUT,
SCANNER_SETTING_LABEL SCANNER_SETTING_LABEL,
SCANNER_PRIORITY_LABEL,
SCANNER_PRIORITY_CAMERA,
SCANNER_PRIORITY_HARDWARE
} = require('../config/messages'); //Сообщения } = require('../config/messages'); //Сообщения
const styles = require('../styles/screens/SettingsScreen.styles'); //Стили экрана const styles = require('../styles/screens/SettingsScreen.styles'); //Стили экрана
@ -42,7 +46,7 @@ function SettingsScreen() {
const { showInfo, showError, showSuccess } = useAppMessagingContext(); const { showInfo, showError, showSuccess } = useAppMessagingContext();
const { APP_MODE, mode, setNotConnected } = useAppModeContext(); const { APP_MODE, mode, setNotConnected } = useAppModeContext();
const { goBack, canGoBack } = useAppNavigationContext(); const { goBack, canGoBack } = useAppNavigationContext();
const { getSetting, setSetting, clearSettings, clearInspections, vacuum, isDbReady } = useAppLocalDbContext(); const { getSetting, setSetting, clearSettings, vacuum, isDbReady } = useAppLocalDbContext();
const { session, isAuthenticated, getDeviceId, setLastSavedServerUrlFromSettings } = useAppAuthContext(); const { session, isAuthenticated, getDeviceId, setLastSavedServerUrlFromSettings } = useAppAuthContext();
const [serverUrl, setServerUrl] = React.useState(''); const [serverUrl, setServerUrl] = React.useState('');
@ -52,11 +56,47 @@ function SettingsScreen() {
const [isLoading, setIsLoading] = React.useState(false); const [isLoading, setIsLoading] = React.useState(false);
const [isServerUrlDialogVisible, setIsServerUrlDialogVisible] = React.useState(false); const [isServerUrlDialogVisible, setIsServerUrlDialogVisible] = React.useState(false);
const [isIdleTimeoutDialogVisible, setIsIdleTimeoutDialogVisible] = React.useState(false); const [isIdleTimeoutDialogVisible, setIsIdleTimeoutDialogVisible] = React.useState(false);
const [serverRequestTimeout, setServerRequestTimeout] = React.useState('');
const [isServerRequestTimeoutDialogVisible, setIsServerRequestTimeoutDialogVisible] = React.useState(false);
const [alwaysShowScanner, setAlwaysShowScanner] = React.useState(false); const [alwaysShowScanner, setAlwaysShowScanner] = React.useState(false);
const [mainScannerPriority, setMainScannerPriority] = React.useState('camera');
const { registerHandler, unregisterHandler, SCREENS: scannerScreens } = useHardwareScannerContext();
const serverUrlDialogRef = React.useRef(null);
const idleTimeoutDialogRef = React.useRef(null);
const serverRequestTimeoutDialogRef = React.useRef(null);
//Предотвращение повторной загрузки настроек //Предотвращение повторной загрузки настроек
const settingsLoadedRef = React.useRef(false); const settingsLoadedRef = React.useRef(false);
//Обработчик встроенного сканера: только при открытом диалоге ввода — замена значения в поле (данные как есть); иначе ничего не делать
const handleHardwareScan = React.useCallback(
barcode => {
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(() => { React.useEffect(() => {
//Выходим, если БД не готова или уже загрузили настройки //Выходим, если БД не готова или уже загрузили настройки
@ -78,13 +118,16 @@ function SettingsScreen() {
setHideServerUrl(savedHideServerUrl === 'true' || savedHideServerUrl === true); setHideServerUrl(savedHideServerUrl === 'true' || savedHideServerUrl === true);
const savedIdleTimeout = await getSetting(AUTH_SETTINGS_KEYS.IDLE_TIMEOUT); const savedIdleTimeout = await getSetting(AUTH_SETTINGS_KEYS.IDLE_TIMEOUT);
if (savedIdleTimeout) { setIdleTimeout(savedIdleTimeout != null ? String(savedIdleTimeout).trim() : '');
setIdleTimeout(savedIdleTimeout);
const savedServerRequestTimeout = await getSetting(AUTH_SETTINGS_KEYS.SERVER_REQUEST_TIMEOUT);
const serverRequestTimeoutValue = savedServerRequestTimeout != null ? String(savedServerRequestTimeout).trim() : '';
if (serverRequestTimeoutValue) {
setServerRequestTimeout(serverRequestTimeoutValue);
} else { } else {
//Устанавливаем значение по умолчанию const defaultValue = String(DEFAULT_SERVER_REQUEST_TIMEOUT);
const defaultValue = String(DEFAULT_IDLE_TIMEOUT); setServerRequestTimeout(defaultValue);
setIdleTimeout(defaultValue); await setSetting(AUTH_SETTINGS_KEYS.SERVER_REQUEST_TIMEOUT, defaultValue);
await setSetting(AUTH_SETTINGS_KEYS.IDLE_TIMEOUT, defaultValue);
} }
//Получаем или генерируем идентификатор устройства //Получаем или генерируем идентификатор устройства
@ -93,8 +136,10 @@ function SettingsScreen() {
const savedAlwaysShowScanner = await getSetting(AUTH_SETTINGS_KEYS.ALWAYS_SHOW_SCANNER); const savedAlwaysShowScanner = await getSetting(AUTH_SETTINGS_KEYS.ALWAYS_SHOW_SCANNER);
setAlwaysShowScanner(savedAlwaysShowScanner === 'true' || savedAlwaysShowScanner === true); setAlwaysShowScanner(savedAlwaysShowScanner === 'true' || savedAlwaysShowScanner === true);
const savedPriority = await getSetting(AUTH_SETTINGS_KEYS.MAIN_SCANNER_PRIORITY);
setMainScannerPriority(savedPriority === 'hardware' ? 'hardware' : 'camera');
} catch (error) { } catch (error) {
console.error('Ошибка загрузки настроек:', error);
showError('Не удалось загрузить настройки'); showError('Не удалось загрузить настройки');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@ -125,6 +170,12 @@ function SettingsScreen() {
[] []
); );
//Стиль поля времени ожидания сервера при нажатии
const getServerRequestTimeoutFieldPressableStyle = React.useCallback(
({ pressed }) => [styles.serverUrlField, pressed && styles.serverUrlFieldPressed],
[]
);
//Открытие диалога ввода URL сервера (только в режиме "Не подключено") //Открытие диалога ввода URL сервера (только в режиме "Не подключено")
const handleOpenServerUrlDialog = React.useCallback(() => { const handleOpenServerUrlDialog = React.useCallback(() => {
if (!isServerUrlEditable) { if (!isServerUrlEditable) {
@ -159,7 +210,6 @@ function SettingsScreen() {
showError('Не удалось сохранить настройки'); showError('Не удалось сохранить настройки');
} }
} catch (error) { } catch (error) {
console.error('Ошибка сохранения настроек:', error);
showError('Не удалось сохранить настройки'); showError('Не удалось сохранить настройки');
} }
@ -181,13 +231,41 @@ function SettingsScreen() {
showError('Не удалось сохранить настройку'); showError('Не удалось сохранить настройку');
} }
} catch (error) { } catch (error) {
console.error('Ошибка сохранения настройки сканера:', error);
showError('Не удалось сохранить настройку'); showError('Не удалось сохранить настройку');
} }
}, },
[setSetting, showSuccess, 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 сервера в окне логина //Переключатель скрытия URL сервера в окне логина
const handleToggleHideServerUrl = React.useCallback( const handleToggleHideServerUrl = React.useCallback(
async value => { async value => {
@ -201,7 +279,6 @@ function SettingsScreen() {
showError('Не удалось сохранить настройку'); showError('Не удалось сохранить настройку');
} }
} catch (error) { } catch (error) {
console.error('Ошибка сохранения настройки:', error);
showError('Не удалось сохранить настройку'); showError('Не удалось сохранить настройку');
} }
}, },
@ -218,6 +295,16 @@ function SettingsScreen() {
setIsIdleTimeoutDialogVisible(false); setIsIdleTimeoutDialogVisible(false);
}, []); }, []);
//Открытие диалога ввода времени ожидания ответа от сервера
const handleOpenServerRequestTimeoutDialog = React.useCallback(() => {
setIsServerRequestTimeoutDialogVisible(true);
}, []);
//Закрытие диалога ввода времени ожидания ответа от сервера
const handleCloseServerRequestTimeoutDialog = React.useCallback(() => {
setIsServerRequestTimeoutDialogVisible(false);
}, []);
//Сохранение времени простоя //Сохранение времени простоя
const handleSaveIdleTimeout = React.useCallback( const handleSaveIdleTimeout = React.useCallback(
async value => { async value => {
@ -235,7 +322,6 @@ function SettingsScreen() {
showError('Не удалось сохранить настройку'); showError('Не удалось сохранить настройку');
} }
} catch (error) { } catch (error) {
console.error('Ошибка сохранения времени простоя:', error);
showError('Не удалось сохранить настройку'); showError('Не удалось сохранить настройку');
} }
@ -244,22 +330,37 @@ function SettingsScreen() {
[setSetting, showSuccess, showError] [setSetting, showSuccess, showError]
); );
//Выполнение очистки кэша (для диалога подтверждения) //Сохранение времени ожидания ответа от сервера
const performClearCache = React.useCallback(async () => { const handleSaveServerRequestTimeout = React.useCallback(
async value => {
setIsServerRequestTimeoutDialogVisible(false);
setIsLoading(true);
try { try {
const success = await clearInspections(); const trimmedValue = value != null ? String(value).trim() : '';
const success = await setSetting(AUTH_SETTINGS_KEYS.SERVER_REQUEST_TIMEOUT, trimmedValue);
if (success) { if (success) {
showSuccess('Кэш успешно очищен'); setServerRequestTimeout(trimmedValue);
showSuccess('Время ожидания сервера сохранено');
} else { } else {
showError('Не удалось очистить кэш'); showError('Не удалось сохранить настройку');
} }
} catch (error) { } catch (error) {
console.error('Ошибка очистки кэша:', error); showError('Не удалось сохранить настройку');
showError('Не удалось очистить кэш');
} }
}, [showSuccess, showError, clearInspections]);
//Очистка кэша (осмотров) setIsLoading(false);
},
[setSetting, showSuccess, showError]
);
//Выполнение очистки кэша
const performClearCache = React.useCallback(async () => {
showSuccess('Кэш успешно очищен');
}, [showSuccess]);
//Очистка кэша — диалог подтверждения, по подтверждению ничего не выполняется
const handleClearCache = React.useCallback(() => { const handleClearCache = React.useCallback(() => {
const confirmButton = getConfirmButtonOptions(DIALOG_BUTTON_TYPE.ERROR, 'Очистить', performClearCache); const confirmButton = getConfirmButtonOptions(DIALOG_BUTTON_TYPE.ERROR, 'Очистить', performClearCache);
@ -273,29 +374,32 @@ function SettingsScreen() {
//Подключён (онлайн/офлайн): сбрасываем только непричастные к подключению настройки; не подключён: полный сброс //Подключён (онлайн/офлайн): сбрасываем только непричастные к подключению настройки; не подключён: полный сброс
const performResetSettings = React.useCallback(async () => { const performResetSettings = React.useCallback(async () => {
try { try {
const defaultValue = String(DEFAULT_IDLE_TIMEOUT);
if (mode === APP_MODE.NOT_CONNECTED) { if (mode === APP_MODE.NOT_CONNECTED) {
const success = await clearSettings(); const success = await clearSettings();
if (success) { if (success) {
const defaultServerTimeout = String(DEFAULT_SERVER_REQUEST_TIMEOUT);
await setSetting(AUTH_SETTINGS_KEYS.SERVER_REQUEST_TIMEOUT, defaultServerTimeout);
setServerUrl(''); setServerUrl('');
setHideServerUrl(false); setHideServerUrl(false);
setAlwaysShowScanner(false); setAlwaysShowScanner(false);
setIdleTimeout(defaultValue); setMainScannerPriority('camera');
await setSetting(AUTH_SETTINGS_KEYS.IDLE_TIMEOUT, defaultValue); setIdleTimeout('');
setServerRequestTimeout(defaultServerTimeout);
setNotConnected(); setNotConnected();
showSuccess(SETTINGS_RESET_SUCCESS_MESSAGE); showSuccess(SETTINGS_RESET_SUCCESS_MESSAGE);
} else { } else {
showError('Не удалось сбросить настройки'); showError('Не удалось сбросить настройки');
} }
} else { } else {
//Подключён (онлайн или офлайн): сбрасываем только время простоя //Подключён (онлайн или офлайн): сбрасываем только время простоя и таймаут сервера
await setSetting(AUTH_SETTINGS_KEYS.IDLE_TIMEOUT, defaultValue); const defaultServerTimeout = String(DEFAULT_SERVER_REQUEST_TIMEOUT);
setIdleTimeout(defaultValue); await setSetting(AUTH_SETTINGS_KEYS.IDLE_TIMEOUT, '');
await setSetting(AUTH_SETTINGS_KEYS.SERVER_REQUEST_TIMEOUT, defaultServerTimeout);
setIdleTimeout('');
setServerRequestTimeout(defaultServerTimeout);
showSuccess(SETTINGS_RESET_SUCCESS_MESSAGE); showSuccess(SETTINGS_RESET_SUCCESS_MESSAGE);
} }
} catch (error) { } catch (error) {
console.error('Ошибка сброса настроек:', error);
showError('Не удалось сбросить настройки'); showError('Не удалось сбросить настройки');
} }
}, [ }, [
@ -304,6 +408,7 @@ function SettingsScreen() {
setServerUrl, setServerUrl,
setHideServerUrl, setHideServerUrl,
setIdleTimeout, setIdleTimeout,
setServerRequestTimeout,
setNotConnected, setNotConnected,
clearSettings, clearSettings,
setSetting, setSetting,
@ -332,7 +437,6 @@ function SettingsScreen() {
showError('Не удалось оптимизировать базу данных'); showError('Не удалось оптимизировать базу данных');
} }
} catch (error) { } catch (error) {
console.error('Ошибка оптимизации БД:', error);
showError('Не удалось оптимизировать базу данных'); showError('Не удалось оптимизировать базу данных');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@ -443,6 +547,7 @@ function SettingsScreen() {
</View> </View>
</View> </View>
{mode === APP_MODE.ONLINE || mode === APP_MODE.OFFLINE ? (
<View style={styles.section}> <View style={styles.section}>
<AppText style={styles.sectionTitle} variant="h3" weight="semibold"> <AppText style={styles.sectionTitle} variant="h3" weight="semibold">
Главный экран Главный экран
@ -456,7 +561,32 @@ function SettingsScreen() {
disabled={isLoading || !isDbReady} disabled={isLoading || !isDbReady}
/> />
</View> </View>
{alwaysShowScanner ? (
<View style={styles.prioritySection}>
<AppText style={styles.fieldLabel} variant="caption" weight="medium">
{SCANNER_PRIORITY_LABEL}
</AppText>
<Pressable
style={[styles.priorityRow, mainScannerPriority === 'camera' && styles.priorityRowSelected]}
onPress={handleSelectCameraPriority}
disabled={isLoading || !isDbReady}
>
<AppText style={styles.priorityRowText}>{SCANNER_PRIORITY_CAMERA}</AppText>
{mainScannerPriority === 'camera' ? <AppText style={styles.priorityCheck}></AppText> : null}
</Pressable>
<Pressable
style={[styles.priorityRow, mainScannerPriority === 'hardware' && styles.priorityRowSelected]}
onPress={handleSelectHardwarePriority}
disabled={isLoading || !isDbReady}
>
<AppText style={styles.priorityRowText}>{SCANNER_PRIORITY_HARDWARE}</AppText>
{mainScannerPriority === 'hardware' ? <AppText style={styles.priorityCheck}></AppText> : null}
</Pressable>
</View> </View>
) : null}
</View>
) : null}
<View style={styles.section}> <View style={styles.section}>
<AppText style={styles.sectionTitle} variant="h3" weight="semibold"> <AppText style={styles.sectionTitle} variant="h3" weight="semibold">
@ -468,8 +598,22 @@ function SettingsScreen() {
</AppText> </AppText>
<Pressable style={getIdleTimeoutFieldPressableStyle} onPress={handleOpenIdleTimeoutDialog} disabled={isLoading || !isDbReady}> <Pressable style={getIdleTimeoutFieldPressableStyle} onPress={handleOpenIdleTimeoutDialog} disabled={isLoading || !isDbReady}>
<AppText style={[styles.serverUrlText, !idleTimeout && styles.serverUrlPlaceholder]} numberOfLines={1}>
{idleTimeout || 'Не задано'}
</AppText>
</Pressable>
<AppText style={[styles.fieldLabel, styles.fieldLabelMarginTop]} variant="caption" weight="medium">
Максимальное время ожидания ответа от сервера (секунд)
</AppText>
<Pressable
style={getServerRequestTimeoutFieldPressableStyle}
onPress={handleOpenServerRequestTimeoutDialog}
disabled={isLoading || !isDbReady}
>
<AppText style={styles.serverUrlText} numberOfLines={1}> <AppText style={styles.serverUrlText} numberOfLines={1}>
{idleTimeout || String(DEFAULT_IDLE_TIMEOUT)} {serverRequestTimeout || String(DEFAULT_SERVER_REQUEST_TIMEOUT)}
</AppText> </AppText>
</Pressable> </Pressable>
@ -562,6 +706,7 @@ function SettingsScreen() {
</ScrollView> </ScrollView>
<InputDialog <InputDialog
ref={serverUrlDialogRef}
visible={isServerUrlDialogVisible} visible={isServerUrlDialogVisible}
title="Адрес сервера" title="Адрес сервера"
label="URL сервера приложений" label="URL сервера приложений"
@ -576,17 +721,33 @@ function SettingsScreen() {
/> />
<InputDialog <InputDialog
ref={idleTimeoutDialogRef}
visible={isIdleTimeoutDialogVisible} visible={isIdleTimeoutDialogVisible}
title="Время простоя" title="Время простоя"
label="Максимальное время простоя (минут)" label="Максимальное время простоя (минут)"
value={idleTimeout} value={idleTimeout}
placeholder="Например: 30" placeholder="Например: 30 или пусто"
keyboardType="numeric" keyboardType="numeric"
confirmText="Сохранить" confirmText="Сохранить"
cancelText="Отмена" cancelText="Отмена"
onConfirm={handleSaveIdleTimeout} onConfirm={handleSaveIdleTimeout}
onCancel={handleCloseIdleTimeoutDialog} onCancel={handleCloseIdleTimeoutDialog}
validator={validateIdleTimeout} validator={validateIdleTimeoutAllowEmpty}
/>
<InputDialog
ref={serverRequestTimeoutDialogRef}
visible={isServerRequestTimeoutDialogVisible}
title="Время ожидания сервера"
label="Максимальное время ожидания ответа от сервера (секунд)"
value={serverRequestTimeout}
placeholder="Например: 60"
keyboardType="numeric"
confirmText="Сохранить"
cancelText="Отмена"
onConfirm={handleSaveServerRequestTimeout}
onCancel={handleCloseServerRequestTimeoutDialog}
validator={validateServerRequestTimeout}
/> />
</AdaptiveView> </AdaptiveView>
); );

View File

@ -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
};

View File

@ -83,14 +83,17 @@ const styles = StyleSheet.create({
color: APP_COLORS.textSecondary color: APP_COLORS.textSecondary
}, },
input: { input: {
height: UI.INPUT_HEIGHT, minHeight: UI.INPUT_HEIGHT,
borderWidth: 1, borderWidth: 1,
borderColor: APP_COLORS.borderMedium, borderColor: APP_COLORS.borderMedium,
borderRadius: UI.BORDER_RADIUS, borderRadius: UI.BORDER_RADIUS,
paddingHorizontal: responsiveSpacing(3), paddingHorizontal: responsiveSpacing(3),
paddingVertical: responsiveSpacing(2.5),
fontSize: UI.FONT_SIZE_MD, fontSize: UI.FONT_SIZE_MD,
color: APP_COLORS.textPrimary, color: APP_COLORS.textPrimary,
backgroundColor: APP_COLORS.surface backgroundColor: APP_COLORS.surface,
includeFontPadding: false,
textAlignVertical: 'center'
}, },
inputFocused: { inputFocused: {
borderColor: APP_COLORS.primary, borderColor: APP_COLORS.primary,

View File

@ -0,0 +1,26 @@
/*
Предрейсовые осмотры - мобильное приложение
Стили обёртки провайдера простоя
*/
//---------------------
//Подключение библиотек
//---------------------
const { StyleSheet } = require('react-native'); //StyleSheet
//-----------
//Тело модуля
//-----------
const styles = StyleSheet.create({
touchArea: {
flex: 1
}
});
//----------------
//Интерфейс модуля
//----------------
module.exports = styles;

View File

@ -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;

View File

@ -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;

View File

@ -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 { APP_COLORS } = require('../../config/theme'); //Цветовая схема приложения
const { UI } = require('../../config/appConfig'); //Конфигурация UI const { UI } = require('../../config/appConfig'); //Конфигурация UI
const { responsiveSpacing, widthPercentage } = require('../../utils/responsive'); //Адаптивные утилиты 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({ const styles = StyleSheet.create({
backdrop: { modalRoot: {
flex: 1, 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, backgroundColor: APP_COLORS.overlay,
zIndex: 1000
},
backdrop: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: UI.PADDING paddingHorizontal: UI.PADDING
}, },
container: { container: {
width: '100%', width: resultCardWidth,
maxWidth: widthPercentage(90), minWidth: resultCardWidth,
maxWidth: resultCardWidth,
backgroundColor: APP_COLORS.surface, backgroundColor: APP_COLORS.surface,
borderRadius: UI.BORDER_RADIUS, borderRadius: UI.BORDER_RADIUS,
...Platform.select({ ...Platform.select({
@ -83,6 +115,7 @@ const styles = StyleSheet.create({
color: APP_COLORS.textSecondary color: APP_COLORS.textSecondary
}, },
valueBlock: { valueBlock: {
width: '100%',
paddingVertical: responsiveSpacing(2), paddingVertical: responsiveSpacing(2),
paddingHorizontal: responsiveSpacing(3), paddingHorizontal: responsiveSpacing(3),
backgroundColor: APP_COLORS.surfaceAlt, backgroundColor: APP_COLORS.surfaceAlt,
@ -90,6 +123,7 @@ const styles = StyleSheet.create({
marginBottom: responsiveSpacing(4) marginBottom: responsiveSpacing(4)
}, },
valueText: { valueText: {
width: '100%',
fontSize: UI.FONT_SIZE_MD, fontSize: UI.FONT_SIZE_MD,
color: APP_COLORS.textPrimary color: APP_COLORS.textPrimary
}, },

View File

@ -23,6 +23,14 @@ const styles = StyleSheet.create({
minHeight: responsiveSpacing(30), minHeight: responsiveSpacing(30),
marginBottom: responsiveSpacing(2) marginBottom: responsiveSpacing(2)
}, },
scannerWrapper: {
flex: 1,
width: '100%'
},
//Область тапа для включения камеры (оверлей поверх сканера на паузе)
cameraPausedOverlayTouchable: {
...StyleSheet.absoluteFillObject
},
modalContainer: { modalContainer: {
flex: 1 flex: 1
}, },

View File

@ -28,6 +28,15 @@ const styles = StyleSheet.create({
}, },
button: { button: {
minWidth: responsiveSpacing(40) minWidth: responsiveSpacing(40)
},
hint: {
textAlign: 'center',
color: APP_COLORS.textSecondary,
marginBottom: responsiveSpacing(3)
},
secondaryButton: {
marginTop: responsiveSpacing(2),
minWidth: responsiveSpacing(40)
} }
}); });

View File

@ -56,6 +56,16 @@ const styles = StyleSheet.create({
color: APP_COLORS.white, color: APP_COLORS.white,
fontWeight: '600' fontWeight: '600'
}, },
logoutButton: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: APP_COLORS.error,
marginTop: responsiveSpacing(3)
},
logoutButtonText: {
color: APP_COLORS.error,
fontWeight: '600'
},
hint: { hint: {
textAlign: 'center', textAlign: 'center',
marginTop: responsiveSpacing(4), marginTop: responsiveSpacing(4),

View File

@ -88,6 +88,33 @@ const styles = StyleSheet.create({
switchRow: { switchRow: {
marginTop: responsiveSpacing(3) 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: { actionButton: {
marginTop: responsiveSpacing(3) marginTop: responsiveSpacing(3)
}, },

View File

@ -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
};

View File

@ -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
};

View File

@ -107,8 +107,7 @@ const getAndroidId = async () => {
} }
return null; return null;
} catch (error) { } catch (_error) {
console.warn('Не удалось получить Android ID:', error);
return null; return null;
} }
}; };
@ -134,8 +133,7 @@ const getIosId = async () => {
} }
return null; return null;
} catch (error) { } catch (_error) {
console.warn('Не удалось получить iOS ID:', error);
return null; return null;
} }
}; };

View File

@ -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
};

23
rn/app/src/utils/noop.js Normal file
View File

@ -0,0 +1,23 @@
/*
Предрейсовые осмотры - мобильное приложение
Пустые функции для использования в обработчиках и .catch()
*/
//---------
//Функции
//---------
//Пустая функция (например для onPress, чтобы не передавать клик дальше)
function noop() {}
//Пустой обработчик для .catch() — подавляет ошибку без побочных эффектов
function noopCatch() {}
//----------------
//Интерфейс модуля
//----------------
module.exports = {
noop,
noopCatch
};

View File

@ -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
};

View File

@ -112,7 +112,6 @@ const encryptData = (data, secretKey, salt = '') => {
} }
if (!secretKey) { if (!secretKey) {
console.error('Ошибка шифрования: секретный ключ не предоставлен');
return ''; return '';
} }
@ -121,7 +120,6 @@ const encryptData = (data, secretKey, salt = '') => {
const encrypted = xorEncrypt(data, keyHash); const encrypted = xorEncrypt(data, keyHash);
return ENCRYPTED_PREFIX + encrypted; return ENCRYPTED_PREFIX + encrypted;
} catch (error) { } catch (error) {
console.error('Ошибка шифрования:', error);
return ''; return '';
} }
}; };
@ -133,7 +131,6 @@ const decryptData = (encryptedData, secretKey, salt = '') => {
} }
if (!secretKey) { if (!secretKey) {
console.error('Ошибка расшифровки: секретный ключ не предоставлен');
return ''; return '';
} }
@ -148,7 +145,6 @@ const decryptData = (encryptedData, secretKey, salt = '') => {
const keyHash = generateKeyHash(secretKey, salt); const keyHash = generateKeyHash(secretKey, salt);
return xorDecrypt(encryptedHex, keyHash); return xorDecrypt(encryptedHex, keyHash);
} catch (error) { } catch (error) {
console.error('Ошибка расшифровки:', error);
return ''; return '';
} }
}; };

View File

@ -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
};

View File

@ -19,7 +19,10 @@ const VALIDATION_MESSAGES = {
SERVER_URL_INVALID_SHORT: 'Некорректный формат URL', SERVER_URL_INVALID_SHORT: 'Некорректный формат URL',
IDLE_TIMEOUT_EMPTY: 'Введите время простоя', IDLE_TIMEOUT_EMPTY: 'Введите время простоя',
IDLE_TIMEOUT_MIN: 'Введите положительное число (минимум 1 минута)', 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; 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, normalizeServerUrl,
validateServerUrl, validateServerUrl,
validateServerUrlAllowEmpty, validateServerUrlAllowEmpty,
validateIdleTimeout validateIdleTimeout,
validateIdleTimeoutAllowEmpty,
validateServerRequestTimeout
}; };