diff --git a/rn/app/App.js b/rn/app/App.js
index ac0e62e..8afd4bd 100644
--- a/rn/app/App.js
+++ b/rn/app/App.js
@@ -13,8 +13,9 @@ const AppMessagingProvider = require('./src/components/layout/AppMessagingProvid
const AppNavigationProvider = require('./src/components/layout/AppNavigationProvider').AppNavigationProvider; //Провайдер навигации
const AppModeProvider = require('./src/components/layout/AppModeProvider').AppModeProvider; //Провайдер режима работы
const AppLocalDbProvider = require('./src/components/layout/AppLocalDbProvider').AppLocalDbProvider; //Провайдер локальной БД
+const AppAuthProvider = require('./src/components/layout/AppAuthProvider').AppAuthProvider; //Провайдер авторизации
const AppServerProvider = require('./src/components/layout/AppServerProvider').AppServerProvider; //Провайдер сервера
-const AppPreTripInspectionsProvider = require('./src/components/layout/AppPreTripInspectionsProvider').AppPreTripInspectionsProvider; //Провайдер осмотров
+const { AppIdleProvider } = require('./src/components/layout/AppIdleProvider'); //Провайдер простоя
const AppRoot = require('./src/components/layout/AppRoot'); //Корневой layout приложения
//-----------
@@ -28,13 +29,15 @@ function App() {
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
diff --git a/rn/app/android/app/build.gradle b/rn/app/android/app/build.gradle
index 0510375..c855596 100644
--- a/rn/app/android/app/build.gradle
+++ b/rn/app/android/app/build.gradle
@@ -77,9 +77,9 @@ android {
buildToolsVersion rootProject.ext.buildToolsVersion
compileSdk rootProject.ext.compileSdkVersion
- namespace "com.app"
+ namespace "com.parus_pre_trip_inspections"
defaultConfig {
- applicationId "com.app"
+ applicationId "com.parus_pre_trip_inspections"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
@@ -117,3 +117,19 @@ dependencies {
implementation jscFlavor
}
}
+
+// Обход: перед native clean нужны сгенерированные codegen-папки автолинкованных библиотек
+afterEvaluate {
+ def codegenDeps = []
+ rootProject.subprojects.each { sub ->
+ def codegenTask = sub.tasks.findByName("generateCodegenArtifactsFromSchema")
+ if (codegenTask != null) {
+ codegenDeps.add(codegenTask)
+ }
+ }
+ tasks.configureEach { task ->
+ if (task.name.startsWith("externalNativeBuild") && task.name.contains("Clean")) {
+ codegenDeps.each { task.dependsOn(it) }
+ }
+ }
+}
diff --git a/rn/app/android/app/src/main/AndroidManifest.xml b/rn/app/android/app/src/main/AndroidManifest.xml
index a971b7c..0aa2155 100644
--- a/rn/app/android/app/src/main/AndroidManifest.xml
+++ b/rn/app/android/app/src/main/AndroidManifest.xml
@@ -11,7 +11,8 @@
android:allowBackup="false"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="${usesCleartextTraffic}"
- android:supportsRtl="true">
+ android:supportsRtl="true"
+ android:requestLegacyExternalStorage="true">
(android.R.id.content) ?: return
+ setNotFocusableRecursive(content)
+ } catch (_: Exception) {}
+ }
+
+ private fun setNotFocusableRecursive(view: View) {
+ val name = view.javaClass.simpleName
+ if (name.contains("Camera", ignoreCase = true) ||
+ name.contains("Preview", ignoreCase = true) ||
+ name.contains("TextureView", ignoreCase = true) ||
+ name.contains("SurfaceView", ignoreCase = true)
+ ) {
+ view.setFocusable(false)
+ view.setFocusableInTouchMode(false)
+ }
+ if (view is ViewGroup) {
+ for (i in 0 until view.childCount) {
+ setNotFocusableRecursive(view.getChildAt(i))
+ }
+ }
+ }
+}
diff --git a/rn/app/android/app/src/main/java/com/parus_pre_trip_inspections/HardwareScannerPackage.kt b/rn/app/android/app/src/main/java/com/parus_pre_trip_inspections/HardwareScannerPackage.kt
new file mode 100644
index 0000000..3641bdd
--- /dev/null
+++ b/rn/app/android/app/src/main/java/com/parus_pre_trip_inspections/HardwareScannerPackage.kt
@@ -0,0 +1,23 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Регистрация нативного модуля встроенного сканера
+*/
+
+package com.parus_pre_trip_inspections
+
+import com.facebook.react.ReactPackage
+import com.facebook.react.bridge.NativeModule
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.uimanager.ViewManager
+
+//Пакет нативного модуля встроенного сканера
+class HardwareScannerPackage : ReactPackage {
+
+ override fun createNativeModules(reactContext: ReactApplicationContext): List {
+ return listOf(HardwareScannerModule(reactContext))
+ }
+
+ override fun createViewManagers(reactContext: ReactApplicationContext): List> {
+ return emptyList()
+ }
+}
diff --git a/rn/app/android/app/src/main/java/com/parus_pre_trip_inspections/MainActivity.kt b/rn/app/android/app/src/main/java/com/parus_pre_trip_inspections/MainActivity.kt
new file mode 100644
index 0000000..e5fda3d
--- /dev/null
+++ b/rn/app/android/app/src/main/java/com/parus_pre_trip_inspections/MainActivity.kt
@@ -0,0 +1,221 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Главная Activity: встроенный сканер — сессия по триггеру, ENTER в буфер как \n, отправка по таймауту неактивности
+*/
+
+package com.parus_pre_trip_inspections
+
+import android.os.Handler
+import android.os.Looper
+import android.view.KeyCharacterMap
+import android.view.KeyEvent
+import android.view.Window
+import com.facebook.react.ReactActivity
+import com.facebook.react.ReactActivityDelegate
+import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
+import com.facebook.react.defaults.DefaultReactActivityDelegate
+
+class MainActivity : ReactActivity() {
+
+ private val scannerBuffer = StringBuilder()
+ private var scannerSessionActive = false
+ private var lastScannerKeyTime = 0L
+ private var lastBarcodeSentTime = 0L
+ private var scannerDeviceId: Int? = null
+ private var dispatchFromWindowCallback = false
+
+ private val mainHandler = Handler(Looper.getMainLooper())
+
+ private fun normalizeBarcodeForSend(raw: String): String = raw.trim()
+
+ override fun onCreate(savedInstanceState: android.os.Bundle?) {
+ setDefaultUncaughtExceptionHandler()
+ super.onCreate(savedInstanceState)
+ installScannerKeyInterceptor()
+ }
+
+ private fun installScannerKeyInterceptor() {
+ val w = window ?: return
+ val orig = w.callback ?: return
+ w.callback = object : Window.Callback by orig {
+ override fun dispatchKeyEvent(event: KeyEvent): Boolean {
+ if (handleScannerKeyEvent(event)) return true
+ dispatchFromWindowCallback = true
+ return try {
+ orig.dispatchKeyEvent(event)
+ } finally {
+ dispatchFromWindowCallback = false
+ }
+ }
+ }
+ }
+
+ private fun setDefaultUncaughtExceptionHandler() {
+ val prev = Thread.getDefaultUncaughtExceptionHandler()
+ Thread.setDefaultUncaughtExceptionHandler { t, e ->
+ prev?.uncaughtException(t, e)
+ }
+ }
+ private val sessionIdleRunnable = Runnable {
+ if (!scannerSessionActive) return@Runnable
+ if (scannerBuffer.isEmpty()) {
+ scannerSessionActive = false
+ return@Runnable
+ }
+ val barcode = normalizeBarcodeForSend(scannerBuffer.toString())
+ scannerBuffer.setLength(0)
+ scannerSessionActive = false
+ lastBarcodeSentTime = System.currentTimeMillis()
+ sendBarcodeToReactNative(barcode)
+ }
+
+ companion object {
+ private const val LOG_TAG = "PTI_HwScan"
+ private const val SCANNER_SESSION_TIMEOUT_MS = 2000L
+ private const val SESSION_IDLE_SEND_MS = 1000L
+ private const val POST_BARCODE_SILENT_MS = 800L
+
+ private val TRIGGER_KEY_CODES = setOf(
+ KeyEvent.KEYCODE_NUMPAD_DOT,
+ 139, 140, 141, 142, 143, 144,
+ KeyEvent.KEYCODE_ASSIST
+ )
+ }
+
+ private fun isTriggerKey(keyCode: Int, event: KeyEvent): Boolean {
+ if (event.getUnicodeChar(event.metaState) != 0) return false
+ return keyCode in TRIGGER_KEY_CODES
+ }
+
+ private fun isPrintableBarcodeChar(code: Int): Boolean =
+ (code in 32..126) || (code in 160..255) || (code in 0x400..0x4FF) || (code in 0x370..0x3FF)
+
+ override fun getMainComponentName(): String = "Pre-Trip_Inspections"
+
+ override fun createReactActivityDelegate(): ReactActivityDelegate =
+ DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
+
+ override fun dispatchKeyEvent(event: KeyEvent): Boolean {
+ //Если событие пришло через установленный Window.Callback, здесь не повторяем обработку.
+ if (dispatchFromWindowCallback) return super.dispatchKeyEvent(event)
+ if (handleScannerKeyEvent(event)) return true
+ return super.dispatchKeyEvent(event)
+ }
+
+ private fun handleScannerKeyEvent(event: KeyEvent): Boolean {
+ val now = System.currentTimeMillis()
+ val keyCode = event.keyCode
+
+ if (lastBarcodeSentTime != 0L && (now - lastBarcodeSentTime) < POST_BARCODE_SILENT_MS) return true
+
+ if (scannerSessionActive) {
+ if (event.action != KeyEvent.ACTION_DOWN) return true
+ if (keyCode == KeyEvent.KEYCODE_BACK) {
+ mainHandler.removeCallbacks(sessionIdleRunnable)
+ scannerSessionActive = false
+ scannerBuffer.setLength(0)
+ return false
+ }
+ if (lastScannerKeyTime != 0L && now - lastScannerKeyTime > SCANNER_SESSION_TIMEOUT_MS) {
+ mainHandler.removeCallbacks(sessionIdleRunnable)
+ if (scannerBuffer.isNotEmpty()) {
+ val barcode = normalizeBarcodeForSend(scannerBuffer.toString())
+ scannerBuffer.setLength(0)
+ scannerSessionActive = false
+ lastBarcodeSentTime = now
+ sendBarcodeToReactNative(barcode)
+ } else scannerSessionActive = false
+ return true
+ }
+ lastScannerKeyTime = now
+ when {
+ isTriggerKey(keyCode, event) -> { }
+ keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER -> {
+ scannerBuffer.append('\n')
+ mainHandler.postDelayed(sessionIdleRunnable, SESSION_IDLE_SEND_MS)
+ }
+ else -> {
+ val appended = appendKeyToBuffer(event)
+ if (appended) mainHandler.postDelayed(sessionIdleRunnable, SESSION_IDLE_SEND_MS)
+ }
+ }
+ return true
+ }
+
+ val fromScanner = when {
+ event.deviceId == -1 -> false
+ scannerDeviceId != null -> event.deviceId == scannerDeviceId
+ isTriggerKey(keyCode, event) -> true
+ else -> false
+ }
+ if (!fromScanner) {
+ mainHandler.removeCallbacks(sessionIdleRunnable)
+ if (scannerSessionActive) {
+ scannerSessionActive = false
+ scannerBuffer.setLength(0)
+ }
+ return false
+ }
+
+ if (event.action != KeyEvent.ACTION_DOWN) return true
+ if (keyCode == KeyEvent.KEYCODE_BACK) return false
+
+ mainHandler.removeCallbacks(sessionIdleRunnable)
+ scannerSessionActive = true
+ scannerBuffer.setLength(0)
+ lastScannerKeyTime = now
+ if (scannerDeviceId == null && event.deviceId > 0) scannerDeviceId = event.deviceId
+ if (isTriggerKey(keyCode, event)) {
+ HardwareScannerModule.emitTriggerFromActivity(applicationContext)
+ return true
+ }
+ val appended = appendKeyToBuffer(event)
+ if (appended) mainHandler.postDelayed(sessionIdleRunnable, SESSION_IDLE_SEND_MS)
+ return true
+ }
+
+ private fun appendKeyToBuffer(event: KeyEvent): Boolean {
+ val keyCode = event.keyCode
+ if (keyCode in TRIGGER_KEY_CODES && event.getUnicodeChar(event.metaState) == 0) return false
+ var unicode = event.getUnicodeChar(event.metaState)
+ if (unicode == 0 && event.action == KeyEvent.ACTION_DOWN) {
+ try {
+ val map = KeyCharacterMap.load(event.deviceId)
+ unicode = map.get(keyCode, event.metaState)
+ } catch (_: Exception) {}
+ }
+ if (unicode != 0 && Character.isBmpCodePoint(unicode)) {
+ scannerBuffer.append(unicode.toChar())
+ return true
+ }
+ if (keyCode == KeyEvent.KEYCODE_UNKNOWN || keyCode == 0) {
+ val label = event.displayLabel
+ val labelCode = label.code
+ if (labelCode != 0 && isPrintableBarcodeChar(labelCode)) {
+ scannerBuffer.append(label)
+ return true
+ }
+ }
+ when (keyCode) {
+ KeyEvent.KEYCODE_0, KeyEvent.KEYCODE_1, KeyEvent.KEYCODE_2, KeyEvent.KEYCODE_3, KeyEvent.KEYCODE_4,
+ KeyEvent.KEYCODE_5, KeyEvent.KEYCODE_6, KeyEvent.KEYCODE_7, KeyEvent.KEYCODE_8, KeyEvent.KEYCODE_9 ->
+ scannerBuffer.append('0' + (keyCode - KeyEvent.KEYCODE_0))
+ KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.KEYCODE_NUMPAD_1, KeyEvent.KEYCODE_NUMPAD_2, KeyEvent.KEYCODE_NUMPAD_3,
+ KeyEvent.KEYCODE_NUMPAD_4, KeyEvent.KEYCODE_NUMPAD_5, KeyEvent.KEYCODE_NUMPAD_6, KeyEvent.KEYCODE_NUMPAD_7,
+ KeyEvent.KEYCODE_NUMPAD_8, KeyEvent.KEYCODE_NUMPAD_9 ->
+ scannerBuffer.append('0' + (keyCode - KeyEvent.KEYCODE_NUMPAD_0))
+ KeyEvent.KEYCODE_MINUS, KeyEvent.KEYCODE_NUMPAD_SUBTRACT -> scannerBuffer.append('-')
+ KeyEvent.KEYCODE_SLASH -> scannerBuffer.append('/')
+ KeyEvent.KEYCODE_PERIOD, KeyEvent.KEYCODE_NUMPAD_DOT ->
+ if (event.getUnicodeChar(event.metaState) != 0) scannerBuffer.append('.')
+ in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z -> scannerBuffer.append('A' + (keyCode - KeyEvent.KEYCODE_A))
+ KeyEvent.KEYCODE_SPACE -> scannerBuffer.append(' ')
+ else -> return false
+ }
+ return true
+ }
+
+ private fun sendBarcodeToReactNative(barcode: String) {
+ HardwareScannerModule.emitBarcodeToJs(barcode)
+ }
+}
diff --git a/rn/app/android/app/src/main/java/com/app/MainApplication.kt b/rn/app/android/app/src/main/java/com/parus_pre_trip_inspections/MainApplication.kt
similarity index 80%
rename from rn/app/android/app/src/main/java/com/app/MainApplication.kt
rename to rn/app/android/app/src/main/java/com/parus_pre_trip_inspections/MainApplication.kt
index 05a9022..bfc7f51 100644
--- a/rn/app/android/app/src/main/java/com/app/MainApplication.kt
+++ b/rn/app/android/app/src/main/java/com/parus_pre_trip_inspections/MainApplication.kt
@@ -1,4 +1,4 @@
-package com.app
+package com.parus_pre_trip_inspections
import android.app.Application
import com.facebook.react.PackageList
@@ -14,8 +14,7 @@ class MainApplication : Application(), ReactApplication {
context = applicationContext,
packageList =
PackageList(this).packages.apply {
- // Packages that cannot be autolinked yet can be added manually here, for example:
- // add(MyReactNativePackage())
+ add(HardwareScannerPackage())
},
)
}
diff --git a/rn/app/index.js b/rn/app/index.js
index b4a1100..d484a11 100644
--- a/rn/app/index.js
+++ b/rn/app/index.js
@@ -11,10 +11,6 @@ const { AppRegistry } = require("react-native"); //Регистрация кор
const App = require("./App"); //Корневой компонент приложения
const { name: appName } = require("./app.json"); //Имя приложения
-//-----------
-//Тело модуля
-//-----------
-
//Регистрация корневого компонента
AppRegistry.registerComponent(appName, () => App);
diff --git a/rn/app/react-native.config.js b/rn/app/react-native.config.js
new file mode 100644
index 0000000..3677f8b
--- /dev/null
+++ b/rn/app/react-native.config.js
@@ -0,0 +1,7 @@
+module.exports = {
+ project: {
+ android: {
+ packageName: 'com.parus_pre_trip_inspections',
+ },
+ },
+};
diff --git a/rn/app/scripts/generate-app-icons.js b/rn/app/scripts/generate-app-icons.js
index 9a862bf..5c31b57 100644
--- a/rn/app/scripts/generate-app-icons.js
+++ b/rn/app/scripts/generate-app-icons.js
@@ -159,7 +159,7 @@ function readPng(filePath) {
const inflated = zlib.inflateSync(concatenated, { chunkSize: rawSize + 1024 });
if (inflated.length < rawSize) throw new Error('PNG decompressed size too small');
- // Снятие фильтров строк и приведение к RGBA
+ //Снятие фильтров строк и приведение к RGBA
const rgba = new Uint8Array(width * height * 4);
const stride = width * bytesPerPixel;
let prev = null;
@@ -251,7 +251,7 @@ function writePngBuffer(width, height, rgba) {
const rawRows = [];
for (let y = 0; y < height; y++) {
const row = Buffer.alloc(1 + stride);
- row[0] = 0; // фильтр None
+ row[0] = 0; //фильтр None
for (let x = 0; x < width; x++) {
const i = (y * width + x) * 4;
row[1 + x * 4] = rgba[i];
@@ -278,11 +278,11 @@ function writePngBuffer(width, height, rgba) {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
- ihdr[8] = 8; // глубина цвета
- ihdr[9] = 6; // тип цвета RGBA
- ihdr[10] = 0; // сжатие deflate
- ihdr[11] = 0; // фильтр адаптивный
- ihdr[12] = 0; // без чересстрочности
+ ihdr[8] = 8; //глубина цвета
+ ihdr[9] = 6; //тип цвета RGBA
+ ihdr[10] = 0; //сжатие deflate
+ ihdr[11] = 0; //фильтр адаптивный
+ ihdr[12] = 0; //без чересстрочности
const out = [PNG_SIGNATURE];
writeChunk(out, 'IHDR', ihdr);
diff --git a/rn/app/src/components/common/AppButton.js b/rn/app/src/components/common/AppButton.js
index 666d9c2..df5e5e7 100644
--- a/rn/app/src/components/common/AppButton.js
+++ b/rn/app/src/components/common/AppButton.js
@@ -24,7 +24,7 @@ function getAppButtonPressableStyle(stylesRef, disabled, style) {
}
//Общая кнопка приложения
-function AppButton({ title, onPress, disabled = false, style, textStyle }) {
+function AppButton({ title, onPress, disabled = false, style, textStyle, focusable }) {
const handlePress = React.useCallback(() => {
if (!disabled && typeof onPress === 'function') onPress();
}, [disabled, onPress]);
@@ -32,7 +32,7 @@ function AppButton({ title, onPress, disabled = false, style, textStyle }) {
const getPressableStyle = React.useMemo(() => getAppButtonPressableStyle(styles, disabled, style), [disabled, style]);
return (
-
+
{title}
diff --git a/rn/app/src/components/common/AppInput.js b/rn/app/src/components/common/AppInput.js
index c6a5072..5254367 100644
--- a/rn/app/src/components/common/AppInput.js
+++ b/rn/app/src/components/common/AppInput.js
@@ -32,21 +32,31 @@ const AppInput = React.forwardRef(function AppInput(
style,
inputStyle,
labelStyle,
+ onFocus: onFocusProp,
+ onBlur: onBlurProp,
...restProps
},
ref
) {
const [isFocused, setIsFocused] = React.useState(false);
- //Обработчик фокуса
- const handleFocus = React.useCallback(() => {
- setIsFocused(true);
- }, []);
+ //Обработчик фокуса (внутренний + опциональный внешний)
+ const handleFocus = React.useCallback(
+ e => {
+ setIsFocused(true);
+ if (typeof onFocusProp === 'function') onFocusProp(e);
+ },
+ [onFocusProp]
+ );
- //Обработчик потери фокуса
- const handleBlur = React.useCallback(() => {
- setIsFocused(false);
- }, []);
+ //Обработчик потери фокуса (внутренний + опциональный внешний)
+ const handleBlur = React.useCallback(
+ e => {
+ setIsFocused(false);
+ if (typeof onBlurProp === 'function') onBlurProp(e);
+ },
+ [onBlurProp]
+ );
return (
diff --git a/rn/app/src/components/common/AppMessage.js b/rn/app/src/components/common/AppMessage.js
index efc634e..796fa9a 100644
--- a/rn/app/src/components/common/AppMessage.js
+++ b/rn/app/src/components/common/AppMessage.js
@@ -23,6 +23,28 @@ function getMessageButtonPressableStyle(stylesRef, buttonStyle) {
};
}
+//Формирование массива кнопок сообщения
+function getMessageButtonElements(buttons, handleClose) {
+ if (!Array.isArray(buttons) || buttons.length === 0) {
+ return [];
+ }
+ const elements = [];
+ for (let i = 0; i < buttons.length; i++) {
+ const btn = buttons[i];
+ elements.push(
+
+ );
+ }
+ return elements;
+}
+
//Кнопка сообщения
function AppMessageButton({ title, onPress, onDismiss, buttonStyle, textStyle }) {
//Обработчик нажатия - вызывает onPress и закрывает диалог
@@ -95,20 +117,7 @@ function AppMessage({
{message}
- {hasButtons ? (
-
- {buttons.map(btn => (
-
- ))}
-
- ) : null}
+ {hasButtons ? {getMessageButtonElements(buttons, handleClose)} : null}
diff --git a/rn/app/src/components/common/CopyButton.js b/rn/app/src/components/common/CopyButton.js
index 485d1ef..7d2d25c 100644
--- a/rn/app/src/components/common/CopyButton.js
+++ b/rn/app/src/components/common/CopyButton.js
@@ -48,8 +48,6 @@ function CopyButton({ value, onCopy, onError, disabled = false, style }) {
onCopy(value);
}
} catch (error) {
- console.error('Ошибка копирования в буфер:', error);
-
if (typeof onError === 'function') {
onError(error);
}
diff --git a/rn/app/src/components/common/InputDialog.js b/rn/app/src/components/common/InputDialog.js
index 1329008..1bf6582 100644
--- a/rn/app/src/components/common/InputDialog.js
+++ b/rn/app/src/components/common/InputDialog.js
@@ -8,44 +8,100 @@
//---------------------
const React = require('react'); //React
-const { Modal, View, TextInput, Pressable } = require('react-native'); //Базовые компоненты
+const { Modal, View, TextInput, Pressable, Platform, StyleSheet, BackHandler } = require('react-native'); //Базовые компоненты
const AppText = require('./AppText'); //Общий текстовый компонент
const AppButton = require('./AppButton'); //Кнопка
const styles = require('../../styles/common/InputDialog.styles'); //Стили диалога
+const OVERLAY_Z = 9999;
+
//-----------
//Тело модуля
//-----------
//Модальное окно с полем ввода
-function InputDialog({
- visible,
- title = 'Ввод данных',
- label,
- value = '',
- placeholder,
- keyboardType = 'default',
- autoCapitalize = 'none',
- confirmText = 'Сохранить',
- cancelText = 'Отмена',
- onConfirm,
- onCancel,
- validator,
- errorMessage
-}) {
+function InputDialog(
+ {
+ visible,
+ title = 'Ввод данных',
+ label,
+ value = '',
+ placeholder,
+ keyboardType = 'default',
+ autoCapitalize = 'none',
+ confirmText = 'Сохранить',
+ cancelText = 'Отмена',
+ onConfirm,
+ onCancel,
+ validator,
+ errorMessage
+ },
+ ref
+) {
//Локальное значение для редактирования
const [inputValue, setInputValue] = React.useState(value);
+ const inputRef = React.useRef(null);
+ //На Android поле ввода изначально не фокусируемо, чтобы нативный слой видел «нет фокуса» и перехватывал сканер с первого символа (как на остальных экранах)
+ const [inputFocusable, setInputFocusable] = React.useState(Platform.OS !== 'android');
+
+ const focusFirstEditableInput = React.useCallback(() => {
+ if (inputRef.current && typeof inputRef.current.focus === 'function') {
+ inputRef.current.focus();
+ }
+ }, []);
+
+ React.useImperativeHandle(
+ ref,
+ function exposeScannerApi() {
+ return {
+ setValueFromScanner(val) {
+ setInputValue(val != null ? String(val) : '');
+ if (Platform.OS === 'android') setInputFocusable(true);
+ focusFirstEditableInput();
+ }
+ };
+ },
+ [focusFirstEditableInput]
+ );
+
+ //После включения фокусируемости на Android — ставим фокус на поле (после setValueFromScanner)
+ React.useEffect(
+ function focusWhenFocusable() {
+ if (Platform.OS !== 'android' || !inputFocusable) return;
+ focusFirstEditableInput();
+ },
+ [inputFocusable, focusFirstEditableInput]
+ );
const [error, setError] = React.useState('');
const [isFocused, setIsFocused] = React.useState(false);
- //Сброс значения при открытии диалога
+ //Сброс значения и фокусируемости только при открытии диалога
+ const prevVisibleRef = React.useRef(false);
React.useEffect(() => {
- if (visible) {
+ const justOpened = visible && !prevVisibleRef.current;
+ prevVisibleRef.current = visible;
+ if (justOpened) {
setInputValue(value);
setError('');
+ if (Platform.OS === 'android') setInputFocusable(false);
}
}, [visible, value]);
+ //Кнопка «Назад» на Android при overlay (без Modal) закрывает диалог
+ React.useEffect(
+ function backHandler() {
+ if (Platform.OS !== 'android' || !visible) return;
+ const sub = BackHandler.addEventListener('hardwareBackPress', function onBack() {
+ handleCancel();
+ return true;
+ });
+ return function remove() {
+ sub.remove();
+ };
+ },
+ [visible, handleCancel]
+ );
+
//Обработчик фокуса
const handleFocus = React.useCallback(() => {
setIsFocused(true);
@@ -97,54 +153,88 @@ function InputDialog({
handleCancel();
}, [handleCancel]);
- return (
-
-
-
-
- {title}
-
- ×
-
-
+ const preventKeyActions = Platform.OS === 'android';
+ const closePressableProps = preventKeyActions ? { focusable: false } : {};
+ const cancelButtonFocusable = preventKeyActions ? false : undefined;
+ const confirmButtonFocusable = preventKeyActions ? false : undefined;
-
- {label ? (
-
- {label}
-
- ) : null}
+ const dialogContent = (
+
+
+ {title}
+
+ ×
+
+
-
+
+ {label ? (
+
+ {label}
+
+ ) : null}
- {error ? (
-
- {error}
-
- ) : null}
-
+
-
-
-
-
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+
+
+
+
+ );
+
+ if (!visible) return null;
+
+ if (Platform.OS === 'android') {
+ return (
+
+
+ {dialogContent}
+ );
+ }
+
+ return (
+
+ {dialogContent}
);
}
@@ -153,4 +243,4 @@ function InputDialog({
//Интерфейс модуля
//----------------
-module.exports = InputDialog;
+module.exports = React.forwardRef(InputDialog);
diff --git a/rn/app/src/components/common/PasswordInput.js b/rn/app/src/components/common/PasswordInput.js
index 7a118f3..555f657 100644
--- a/rn/app/src/components/common/PasswordInput.js
+++ b/rn/app/src/components/common/PasswordInput.js
@@ -31,6 +31,8 @@ const PasswordInput = React.forwardRef(function PasswordInput(
style,
inputStyle,
labelStyle,
+ onFocus: onFocusProp,
+ onBlur: onBlurProp,
...restProps
},
ref
@@ -50,15 +52,23 @@ const PasswordInput = React.forwardRef(function PasswordInput(
[ref]
);
- //Обработчик фокуса
- const handleFocus = React.useCallback(() => {
- setIsFocused(true);
- }, []);
+ //Обработчик фокуса (внутренний + опциональный внешний)
+ const handleFocus = React.useCallback(
+ e => {
+ setIsFocused(true);
+ if (typeof onFocusProp === 'function') onFocusProp(e);
+ },
+ [onFocusProp]
+ );
- //Обработчик потери фокуса
- const handleBlur = React.useCallback(() => {
- setIsFocused(false);
- }, []);
+ //Обработчик потери фокуса (внутренний + опциональный внешний)
+ const handleBlur = React.useCallback(
+ e => {
+ setIsFocused(false);
+ if (typeof onBlurProp === 'function') onBlurProp(e);
+ },
+ [onBlurProp]
+ );
//Обработчик переключения видимости пароля
const handleTogglePassword = React.useCallback(() => {
diff --git a/rn/app/src/components/layout/AppIdleProvider.js b/rn/app/src/components/layout/AppIdleProvider.js
new file mode 100644
index 0000000..5fc6b9f
--- /dev/null
+++ b/rn/app/src/components/layout/AppIdleProvider.js
@@ -0,0 +1,63 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Провайдер отслеживания простоя
+*/
+
+//---------------------
+//Подключение библиотек
+//---------------------
+
+const React = require('react'); //React
+const { View } = require('react-native'); //Базовые компоненты
+const useIdleTimeout = require('../../hooks/useIdleTimeout'); //Хук простоя
+const styles = require('../../styles/layout/AppIdleProvider.styles'); //Стили обёртки
+
+//-----------
+//Тело модуля
+//-----------
+
+//Контекст простоя (reportActivity для сброса таймера)
+const AppIdleContext = React.createContext(null);
+
+//Провайдер простоя: оборачивает приложение и при отсутствии активности выполняет выход
+function AppIdleProvider({ children }) {
+ const { reportActivity } = useIdleTimeout();
+
+ const value = React.useMemo(
+ () => ({
+ reportActivity
+ }),
+ [reportActivity]
+ );
+
+ //Обёртка по touch сбрасывает таймер простоя
+ const handleTouch = React.useCallback(() => {
+ reportActivity();
+ }, [reportActivity]);
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+//Хук контекста простоя
+function useAppIdleContext() {
+ const context = React.useContext(AppIdleContext);
+ if (context == null) {
+ throw new Error('useAppIdleContext должен использоваться внутри AppIdleProvider');
+ }
+ return context;
+}
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = {
+ AppIdleProvider,
+ useAppIdleContext
+};
diff --git a/rn/app/src/components/layout/AppLocalDbProvider.js b/rn/app/src/components/layout/AppLocalDbProvider.js
index 1a9ab46..b855bdf 100644
--- a/rn/app/src/components/layout/AppLocalDbProvider.js
+++ b/rn/app/src/components/layout/AppLocalDbProvider.js
@@ -25,16 +25,12 @@ function AppLocalDbProvider({ children }) {
const value = React.useMemo(
() => ({
isDbReady: api.isDbReady,
- inspections: api.inspections,
error: api.error,
- loadInspections: api.loadInspections,
- saveInspection: api.saveInspection,
getSetting: api.getSetting,
setSetting: api.setSetting,
deleteSetting: api.deleteSetting,
getAllSettings: api.getAllSettings,
clearSettings: api.clearSettings,
- clearInspections: api.clearInspections,
vacuum: api.vacuum,
checkTableExists: api.checkTableExists,
setAuthSession: api.setAuthSession,
@@ -43,16 +39,12 @@ function AppLocalDbProvider({ children }) {
}),
[
api.isDbReady,
- api.inspections,
api.error,
- api.loadInspections,
- api.saveInspection,
api.getSetting,
api.setSetting,
api.deleteSetting,
api.getAllSettings,
api.clearSettings,
- api.clearInspections,
api.vacuum,
api.checkTableExists,
api.setAuthSession,
diff --git a/rn/app/src/components/layout/AppRoot.js b/rn/app/src/components/layout/AppRoot.js
index 526cc30..08e4d87 100644
--- a/rn/app/src/components/layout/AppRoot.js
+++ b/rn/app/src/components/layout/AppRoot.js
@@ -11,6 +11,7 @@ const React = require('react'); //React и хуки
const { useColorScheme, View } = require('react-native'); //Определение темы устройства и разметка
const { SafeAreaProvider } = require('react-native-safe-area-context'); //Провайдер безопасной области
const AppShell = require('./AppShell'); //Оболочка приложения
+const HardwareScannerProvider = require('./HardwareScannerProvider').HardwareScannerProvider; //Встроенный сканер
const styles = require('../../styles/layout/AppRoot.styles'); //Стили корневого layout
const { useAppNavigationContext } = require('./AppNavigationProvider'); //Контекст навигации
const { useAppAuthContext } = require('./AppAuthProvider'); //Контекст авторизации
@@ -54,7 +55,9 @@ function AppRoot() {
return (
-
+
+
+
);
diff --git a/rn/app/src/components/layout/AppShell.js b/rn/app/src/components/layout/AppShell.js
index b364a62..f6f20b2 100644
--- a/rn/app/src/components/layout/AppShell.js
+++ b/rn/app/src/components/layout/AppShell.js
@@ -11,6 +11,7 @@ const React = require('react'); //React
const { StatusBar, Platform } = require('react-native'); //Базовые компоненты
const { useAppNavigationContext } = require('./AppNavigationProvider'); //Контекст навигации
const { useAppAuthContext } = require('./AppAuthProvider'); //Контекст авторизации
+const { useAppIdleContext } = require('./AppIdleProvider'); //Контекст простоя
const MainScreen = require('../../screens/MainScreen'); //Главный экран
const SettingsScreen = require('../../screens/SettingsScreen'); //Экран настроек
const AuthScreen = require('../../screens/AuthScreen'); //Экран авторизации
@@ -27,6 +28,12 @@ const styles = require('../../styles/layout/AppShell.styles'); //Стили об
function AppShell({ isDarkMode }) {
const { currentScreen, SCREENS } = useAppNavigationContext();
const { isStartupSessionCheckInProgress, isLogoutInProgress } = useAppAuthContext();
+ const { reportActivity } = useAppIdleContext();
+
+ //Сброс таймера простоя при смене экрана (навигация считается активностью)
+ React.useEffect(() => {
+ reportActivity();
+ }, [currentScreen, reportActivity]);
const renderScreen = React.useCallback(() => {
switch (currentScreen) {
diff --git a/rn/app/src/components/layout/HardwareScannerProvider.js b/rn/app/src/components/layout/HardwareScannerProvider.js
new file mode 100644
index 0000000..c7e1269
--- /dev/null
+++ b/rn/app/src/components/layout/HardwareScannerProvider.js
@@ -0,0 +1,97 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Провайдер встроенного сканера: маршрутизация данных по текущему экрану
+*/
+
+//---------------------
+//Подключение библиотек
+//---------------------
+
+const React = require('react');
+const { useAppNavigationContext } = require('./AppNavigationProvider'); //Контекст навигации
+const HardwareScannerBridge = require('../../services/HardwareScannerBridge'); //Мост встроенного сканера
+
+//---------
+//Контекст
+//---------
+
+const HardwareScannerContext = React.createContext(null);
+
+//-----------
+//Тело модуля
+//-----------
+
+//Вызов обработчика для текущего экрана при получении данных от встроенного сканера
+function dispatchHardwareScan(currentScreen, handlersRef, barcode) {
+ const handlers = handlersRef.current;
+ const hasHandler = handlers && typeof handlers[currentScreen] === 'function';
+ if (!handlers || !hasHandler) return;
+ try {
+ handlers[currentScreen](barcode);
+ } catch (_err) {}
+}
+
+//Провайдер встроенного сканера
+function HardwareScannerProvider({ children }) {
+ const { currentScreen, SCREENS } = useAppNavigationContext();
+ const handlersRef = React.useRef(Object.create(null));
+ const currentScreenRef = React.useRef(currentScreen);
+ const unsubscribeRef = React.useRef(null);
+
+ currentScreenRef.current = currentScreen;
+
+ const registerHandler = React.useCallback(function registerHandler(screen, handler) {
+ if (!screen || typeof handler !== 'function') return;
+ handlersRef.current[screen] = handler;
+ }, []);
+
+ const unregisterHandler = React.useCallback(function unregisterHandler(screen) {
+ if (!screen) return;
+ delete handlersRef.current[screen];
+ }, []);
+
+ React.useEffect(function subscribeToHardwareScanner() {
+ const unsubscribe = HardwareScannerBridge.startHardwareListener(function onData(barcode) {
+ const screen = currentScreenRef.current;
+ dispatchHardwareScan(screen, handlersRef, barcode);
+ });
+ unsubscribeRef.current = unsubscribe;
+ return function cleanup() {
+ if (typeof unsubscribeRef.current === 'function') {
+ unsubscribeRef.current();
+ }
+ unsubscribeRef.current = null;
+ };
+ }, []);
+
+ const value = React.useMemo(
+ function getValue() {
+ return {
+ registerHandler,
+ unregisterHandler,
+ SCREENS
+ };
+ },
+ [registerHandler, unregisterHandler, SCREENS]
+ );
+
+ return {children};
+}
+
+//Хук доступа к контексту встроенного сканера
+function useHardwareScannerContext() {
+ const ctx = React.useContext(HardwareScannerContext);
+ if (!ctx) {
+ throw new Error('useHardwareScannerContext должен использоваться внутри HardwareScannerProvider');
+ }
+ return ctx;
+}
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = {
+ HardwareScannerProvider,
+ useHardwareScannerContext
+};
diff --git a/rn/app/src/components/menu/MenuItemIcon.js b/rn/app/src/components/menu/MenuItemIcon.js
new file mode 100644
index 0000000..34257c2
--- /dev/null
+++ b/rn/app/src/components/menu/MenuItemIcon.js
@@ -0,0 +1,136 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Компонент иконки пункта бокового меню
+*/
+
+//---------------------
+//Подключение библиотек
+//---------------------
+
+const React = require('react'); //React
+const { View } = require('react-native'); //Базовые компоненты
+const AppText = require('../common/AppText'); //Текст приложения
+const { APP_COLORS } = require('../../config/theme'); //Цветовая схема
+const { MENU_ITEM_ID_SETTINGS, MENU_ITEM_ID_ABOUT, MENU_ITEM_ID_LOGIN, MENU_ITEM_ID_LOGOUT } = require('../../config/menuItemIds'); //ID пунктов меню
+const { responsiveSize } = require('../../utils/responsive'); //Адаптивные утилиты
+const styles = require('../../styles/menu/MenuItemIcon.styles'); //Стили иконки
+
+//-----------
+//Тело модуля
+//-----------
+
+//Количество зубцов шестерёнки и угол шага (градусы)
+const SETTINGS_TEETH_COUNT = 6;
+const SETTINGS_TEETH_ANGLE_STEP = 360 / SETTINGS_TEETH_COUNT;
+
+//Формирование массива элементов зубцов шестерёнки
+function getTeethViews(teeth, iconColor) {
+ const views = [];
+ for (let index = 0; index < teeth.length; index++) {
+ const angle = teeth[index];
+ views.push(
+
+ );
+ }
+ return views;
+}
+
+//Иконка «Настройки» (шестерёнка)
+function SettingsIcon({ color }) {
+ const iconColor = color ?? APP_COLORS.textPrimary;
+
+ const teeth = React.useMemo(() => {
+ return Array.from({ length: SETTINGS_TEETH_COUNT }, (_, i) => i * SETTINGS_TEETH_ANGLE_STEP);
+ }, []);
+
+ const toothViews = React.useMemo(() => getTeethViews(teeth, iconColor), [teeth, iconColor]);
+
+ return (
+
+
+ {toothViews}
+
+ );
+}
+
+//Иконка «О приложении» (информация)
+function AboutIcon({ color }) {
+ const iconColor = color ?? APP_COLORS.textPrimary;
+
+ return (
+
+
+ i
+
+
+ );
+}
+
+//Иконка «Вход»
+function LoginIcon({ color }) {
+ const iconColor = color ?? APP_COLORS.textPrimary;
+
+ return (
+
+
+
+
+
+ );
+}
+
+//Иконка «Выход»
+function LogoutIcon({ color }) {
+ const iconColor = color ?? APP_COLORS.textPrimary;
+
+ return (
+
+
+
+
+
+ );
+}
+
+//Рендер иконки по имени пункта меню
+function renderIconByName(name, color) {
+ switch (name) {
+ case MENU_ITEM_ID_SETTINGS:
+ return ;
+ case MENU_ITEM_ID_ABOUT:
+ return ;
+ case MENU_ITEM_ID_LOGIN:
+ return ;
+ case MENU_ITEM_ID_LOGOUT:
+ return ;
+ default:
+ return null;
+ }
+}
+
+//Компонент иконки пункта меню
+function MenuItemIcon({ name, color }) {
+ const iconElement = React.useMemo(() => renderIconByName(name, color), [name, color]);
+
+ if (iconElement == null) {
+ return null;
+ }
+
+ return iconElement;
+}
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = MenuItemIcon;
+module.exports.renderIconByName = renderIconByName;
diff --git a/rn/app/src/components/menu/MenuList.js b/rn/app/src/components/menu/MenuList.js
index ad2df0c..9f80f01 100644
--- a/rn/app/src/components/menu/MenuList.js
+++ b/rn/app/src/components/menu/MenuList.js
@@ -10,25 +10,57 @@
const React = require('react'); //React
const { ScrollView, View } = require('react-native'); //Базовые компоненты
const MenuItem = require('./MenuItem'); //Элемент меню
+const MenuItemIcon = require('./MenuItemIcon'); //Иконка пункта меню
const EmptyMenu = require('./EmptyMenu'); //Пустое меню
const MenuDivider = require('./MenuDivider'); //Разделитель меню
+const { MENU_ITEM_ID_DIVIDER } = require('../../config/menuItemIds'); //ID разделителя
const styles = require('../../styles/menu/MenuList.styles'); //Стили списка меню
//-----------
//Тело модуля
//-----------
+//Иконка для пункта меню
+function resolveItemIcon(item) {
+ if (item.icon != null) {
+ return item.icon;
+ }
+ if (item.id != null && item.id !== MENU_ITEM_ID_DIVIDER) {
+ return ;
+ }
+ return null;
+}
+
+//Формирование массива элементов списка меню
+function getMenuListElements(items, onItemPress) {
+ if (!Array.isArray(items) || items.length === 0) {
+ return [];
+ }
+ const elements = [];
+ for (let index = 0; index < items.length; index++) {
+ const item = items[index];
+ if (item.type === 'divider') {
+ elements.push();
+ } else {
+ elements.push();
+ }
+ }
+ return elements;
+}
+
//Строка меню с элементом
function MenuItemRow({ item, index, items, onItemPress }) {
const handlePress = React.useCallback(() => {
onItemPress(item);
}, [item, onItemPress]);
+ const icon = React.useMemo(() => resolveItemIcon(item), [item]);
+
return (