diff --git a/rn/app/scripts/generate-app-icons.js b/rn/app/scripts/generate-app-icons.js
new file mode 100644
index 0000000..9a862bf
--- /dev/null
+++ b/rn/app/scripts/generate-app-icons.js
@@ -0,0 +1,341 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Генератор иконок приложения из одного PNG
+*/
+
+'use strict';
+
+//---------------------
+//Подключение библиотек
+//---------------------
+
+const fs = require('fs');
+const path = require('path');
+const zlib = require('zlib');
+
+//---------
+//Константы
+//---------
+
+const APP_ROOT = path.join(__dirname, '..');
+const LOGO_PATH = path.join(APP_ROOT, 'assets', 'icons', 'logo.png');
+
+const ANDROID_SIZES = Object.freeze([
+ Object.freeze({ dir: 'mipmap-mdpi', size: 48 }),
+ Object.freeze({ dir: 'mipmap-hdpi', size: 72 }),
+ Object.freeze({ dir: 'mipmap-xhdpi', size: 96 }),
+ Object.freeze({ dir: 'mipmap-xxhdpi', size: 144 }),
+ Object.freeze({ dir: 'mipmap-xxxhdpi', size: 192 })
+]);
+
+const IOS_ICONS = Object.freeze([
+ Object.freeze({ filename: 'icon-20@2x.png', size: 40 }),
+ Object.freeze({ filename: 'icon-20@3x.png', size: 60 }),
+ Object.freeze({ filename: 'icon-29@2x.png', size: 58 }),
+ Object.freeze({ filename: 'icon-29@3x.png', size: 87 }),
+ Object.freeze({ filename: 'icon-40@2x.png', size: 80 }),
+ Object.freeze({ filename: 'icon-40@3x.png', size: 120 }),
+ Object.freeze({ filename: 'icon-60@2x.png', size: 120 }),
+ Object.freeze({ filename: 'icon-60@3x.png', size: 180 }),
+ Object.freeze({ filename: 'icon-1024.png', size: 1024 })
+]);
+
+const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+const ANDROID_RES_DIR = path.join(APP_ROOT, 'android', 'app', 'src', 'main', 'res');
+const IOS_APP_ICON_DIR = path.join(APP_ROOT, 'ios', 'app', 'Images.xcassets', 'AppIcon.appiconset');
+
+const U32_MAX = 4294967296;
+
+//-----------
+//Тело модуля
+//-----------
+
+//Приведение числа к беззнаковому 32-битному (без побитовых операторов)
+function u32(x) {
+ return ((x % U32_MAX) + U32_MAX) % U32_MAX;
+}
+
+//Побитовое XOR для двух 32-битных беззнаковых (через арифметику, для соответствия ESLint)
+function xor32(a, b) {
+ const au = u32(a);
+ const bu = u32(b);
+ let r = 0;
+ let p = 1;
+ for (let i = 0; i < 32; i++) {
+ if (Math.floor(au / p) % 2 !== Math.floor(bu / p) % 2) r += p;
+ p *= 2;
+ }
+ return u32(r);
+}
+
+//Байт в диапазоне 0..255 (эквивалент & 0xff для суммы/разности байтов)
+function byteClamp(v) {
+ return ((v % 256) + 256) % 256;
+}
+
+//Таблица CRC32 для PNG-чанков (полином IEEE, без побитовых операторов)
+const CRC_TABLE = (() => {
+ const table = new Uint32Array(256);
+ const poly = 0xedb88320;
+ for (let n = 0; n < 256; n++) {
+ let c = n;
+ for (let k = 0; k < 8; k++) {
+ const low = c % 2;
+ c = Math.floor(u32(c) / 2);
+ if (low) c = xor32(poly, c);
+ }
+ table[n] = u32(c);
+ }
+ return table;
+})();
+
+//CRC32 от буфера (тип чанка + данные), для проверки и записи PNG. Индекс таблицы — младший байт (crc XOR byte).
+function crc32(buffer, start = 0, length = buffer.length - start) {
+ let crc = 0xffffffff;
+ const end = start + length;
+ for (let i = start; i < end; i++) {
+ const lo = xor32(crc, buffer[i]);
+ const indexByte = u32(lo) % 256;
+ crc = xor32(CRC_TABLE[indexByte], Math.floor(u32(crc) / 256));
+ }
+ return u32(xor32(crc, 0xffffffff));
+}
+
+//Чтение PNG: файл → RGBA (Uint8Array, по строкам, 4 байта на пиксель). Поддержка RGB/RGBA 8-bit.
+function readPng(filePath) {
+ const data = fs.readFileSync(filePath);
+ let offset = 0;
+
+ if (data.length < 8 || !PNG_SIGNATURE.equals(data.subarray(0, 8))) {
+ throw new Error(`Invalid PNG signature: ${filePath}`);
+ }
+ offset = 8;
+
+ let width = 0;
+ let height = 0;
+ let bitDepth = 0;
+ let colorType = 0;
+ let interlace = 0;
+ const idatChunks = [];
+
+ while (offset < data.length) {
+ const length = data.readUInt32BE(offset);
+ const type = data.toString('ascii', offset + 4, offset + 8);
+ const chunkStart = offset + 8;
+ const chunkEnd = chunkStart + length;
+
+ if (chunkEnd + 4 > data.length) throw new Error('PNG truncated');
+ const crcExpected = data.readUInt32BE(chunkEnd);
+ const typeAndDataStart = offset + 4;
+ const crcInput = data.subarray(typeAndDataStart, typeAndDataStart + 4 + length);
+ const crcGot = crc32(crcInput);
+ if (crcGot !== crcExpected) throw new Error(`PNG chunk CRC mismatch: ${type}`);
+
+ offset = chunkEnd + 4;
+
+ if (type === 'IHDR') {
+ if (length < 13) throw new Error('IHDR too short');
+ width = data.readUInt32BE(chunkStart);
+ height = data.readUInt32BE(chunkStart + 4);
+ bitDepth = data[chunkStart + 8];
+ colorType = data[chunkStart + 9];
+ interlace = data[chunkStart + 12];
+ } else if (type === 'IDAT') {
+ idatChunks.push(data.subarray(chunkStart, chunkEnd));
+ } else if (type === 'IEND') {
+ break;
+ }
+ }
+
+ if (interlace !== 0) throw new Error('Interlaced PNG is not supported');
+ if (bitDepth !== 8) throw new Error('Only 8-bit PNG is supported');
+ if (colorType !== 2 && colorType !== 6) {
+ throw new Error('Only RGB or RGBA PNG is supported (color type 2 or 6)');
+ }
+
+ const bytesPerPixel = colorType === 6 ? 4 : 3;
+ const rawSize = (1 + width * bytesPerPixel) * height;
+ const concatenated = Buffer.concat(idatChunks);
+ const inflated = zlib.inflateSync(concatenated, { chunkSize: rawSize + 1024 });
+ if (inflated.length < rawSize) throw new Error('PNG decompressed size too small');
+
+ // Снятие фильтров строк и приведение к RGBA
+ const rgba = new Uint8Array(width * height * 4);
+ const stride = width * bytesPerPixel;
+ let prev = null;
+
+ for (let y = 0; y < height; y++) {
+ const rowStart = y * (1 + stride);
+ const filter = inflated[rowStart];
+ const raw = inflated.subarray(rowStart + 1, rowStart + 1 + stride);
+ const out = new Uint8Array(stride);
+
+ for (let x = 0; x < stride; x++) {
+ const left = x >= bytesPerPixel ? out[x - bytesPerPixel] : 0;
+ const up = prev ? prev[x] : 0;
+ const upLeft = prev && x >= bytesPerPixel ? prev[x - bytesPerPixel] : 0;
+
+ let v = raw[x];
+ if (filter === 1) v = byteClamp(v + left);
+ else if (filter === 2) v = byteClamp(v + up);
+ else if (filter === 3) v = byteClamp(v + Math.floor((left + up) / 2));
+ else if (filter === 4) v = byteClamp(v + paeth(left, up, upLeft));
+ out[x] = v;
+ }
+ prev = out;
+
+ for (let x = 0; x < width; x++) {
+ const i = x * bytesPerPixel;
+ const o = (y * width + x) * 4;
+ rgba[o] = out[i];
+ rgba[o + 1] = out[i + 1];
+ rgba[o + 2] = out[i + 2];
+ rgba[o + 3] = bytesPerPixel === 4 ? out[i + 3] : 255;
+ }
+ }
+
+ return { width, height, rgba };
+}
+
+//Предиктор Paeth для фильтра PNG (без побитовых операций)
+function paeth(a, b, c) {
+ const p = a + b - c;
+ const pa = Math.abs(p - a);
+ const pb = Math.abs(p - b);
+ const pc = Math.abs(p - c);
+ if (pa <= pb && pa <= pc) return a;
+ if (pb <= pc) return b;
+ return c;
+}
+
+//Масштабирование RGBA билинейной интерполяцией
+function resizeBilinear(srcWidth, srcHeight, srcRgba, dstWidth, dstHeight) {
+ const dst = new Uint8Array(dstWidth * dstHeight * 4);
+ const src = srcRgba;
+
+ for (let dy = 0; dy < dstHeight; dy++) {
+ const sy = (dy + 0.5) * (srcHeight / dstHeight) - 0.5;
+ const sy0 = Math.max(0, Math.floor(sy));
+ const sy1 = Math.min(srcHeight - 1, sy0 + 1);
+ const fy = sy - sy0;
+
+ for (let dx = 0; dx < dstWidth; dx++) {
+ const sx = (dx + 0.5) * (srcWidth / dstWidth) - 0.5;
+ const sx0 = Math.max(0, Math.floor(sx));
+ const sx1 = Math.min(srcWidth - 1, sx0 + 1);
+ const fx = sx - sx0;
+
+ const i00 = (sy0 * srcWidth + sx0) * 4;
+ const i10 = (sy0 * srcWidth + sx1) * 4;
+ const i01 = (sy1 * srcWidth + sx0) * 4;
+ const i11 = (sy1 * srcWidth + sx1) * 4;
+
+ const o = (dy * dstWidth + dx) * 4;
+ for (let ch = 0; ch < 4; ch++) {
+ const v00 = src[i00 + ch];
+ const v10 = src[i10 + ch];
+ const v01 = src[i01 + ch];
+ const v11 = src[i11 + ch];
+ const top = v00 * (1 - fx) + v10 * fx;
+ const bot = v01 * (1 - fx) + v11 * fx;
+ dst[o + ch] = Math.round(top * (1 - fy) + bot * fy);
+ }
+ }
+ }
+ return dst;
+}
+
+//Кодирование RGBA в буфер PNG (фильтр None, deflate)
+function writePngBuffer(width, height, rgba) {
+ const stride = width * 4;
+ const rawRows = [];
+ for (let y = 0; y < height; y++) {
+ const row = Buffer.alloc(1 + stride);
+ row[0] = 0; // фильтр None
+ for (let x = 0; x < width; x++) {
+ const i = (y * width + x) * 4;
+ row[1 + x * 4] = rgba[i];
+ row[2 + x * 4] = rgba[i + 1];
+ row[3 + x * 4] = rgba[i + 2];
+ row[4 + x * 4] = rgba[i + 3];
+ }
+ rawRows.push(row);
+ }
+ const rawData = Buffer.concat(rawRows);
+ const compressed = zlib.deflateSync(rawData, { level: 9 });
+
+ function writeChunk(out, type, data) {
+ const len = data ? data.length : 0;
+ const buf = Buffer.alloc(12 + len);
+ buf.writeUInt32BE(len, 0);
+ buf.write(type, 4, 4, 'ascii');
+ if (data) data.copy(buf, 8);
+ const crc = crc32(Buffer.concat([Buffer.from(type, 'ascii'), data || Buffer.alloc(0)]));
+ buf.writeUInt32BE(crc, 8 + len);
+ out.push(buf);
+ }
+
+ 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; // без чересстрочности
+
+ const out = [PNG_SIGNATURE];
+ writeChunk(out, 'IHDR', ihdr);
+ writeChunk(out, 'IDAT', compressed);
+ writeChunk(out, 'IEND', null);
+ return Buffer.concat(out);
+}
+
+//Создание каталога при необходимости
+function ensureDir(dirPath) {
+ if (!fs.existsSync(dirPath)) {
+ fs.mkdirSync(dirPath, { recursive: true });
+ }
+}
+
+//Генерация всех иконок: один раз читаем логотип, ресайзим и пишем в Android/iOS
+async function generate() {
+ if (!fs.existsSync(LOGO_PATH)) {
+ const err = new Error(`Logo not found: ${LOGO_PATH}`);
+ err.code = 'ENOENT';
+ throw err;
+ }
+
+ const logo = readPng(LOGO_PATH);
+ const { width: w, height: h, rgba } = logo;
+
+ for (const { dir, size } of ANDROID_SIZES) {
+ const dirPath = path.join(ANDROID_RES_DIR, dir);
+ ensureDir(dirPath);
+ const resized = resizeBilinear(w, h, rgba, size, size);
+ const png = writePngBuffer(size, size, resized);
+ const base = path.join(dirPath, 'ic_launcher.png');
+ const round = path.join(dirPath, 'ic_launcher_round.png');
+ await Promise.all([fs.promises.writeFile(base, png), fs.promises.writeFile(round, png)]);
+ console.log('Android', dir, `${size}x${size}`);
+ }
+
+ for (const { filename, size } of IOS_ICONS) {
+ const outPath = path.join(IOS_APP_ICON_DIR, filename);
+ const resized = resizeBilinear(w, h, rgba, size, size);
+ const png = writePngBuffer(size, size, resized);
+ await fs.promises.writeFile(outPath, png);
+ console.log('iOS', filename, `${size}x${size}`);
+ }
+
+ console.log('Done. App icon set from', LOGO_PATH);
+}
+
+//----------------
+//Точка входа
+//----------------
+
+generate().catch(err => {
+ console.error(err.message || err);
+ process.exit(1);
+});
diff --git a/rn/app/src/components/auth/OrganizationSelectDialog.js b/rn/app/src/components/auth/OrganizationSelectDialog.js
new file mode 100644
index 0000000..09b7ed7
--- /dev/null
+++ b/rn/app/src/components/auth/OrganizationSelectDialog.js
@@ -0,0 +1,158 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Диалог выбора организации
+*/
+
+//---------------------
+//Подключение библиотек
+//---------------------
+
+const React = require('react');
+const { Modal, View, FlatList, Pressable, Keyboard } = require('react-native');
+const { useSafeAreaInsets } = require('react-native-safe-area-context');
+const AppText = require('../common/AppText');
+const AppButton = require('../common/AppButton');
+const styles = require('../../styles/auth/OrganizationSelectDialog.styles');
+
+//-----------
+//Тело модуля
+//-----------
+
+//Иконка закрытия (крестик)
+function CloseIcon() {
+ return (
+
+
+
+
+ );
+}
+
+//Стиль элемента организации
+function getOrganizationItemStyle(stylesRef, isSelected, pressed) {
+ return [stylesRef.organizationItem, isSelected && stylesRef.organizationItemSelected, pressed && stylesRef.organizationItemPressed];
+}
+
+//Элемент списка организаций
+function OrganizationItem({ item, onSelect, isSelected }) {
+ const handlePress = React.useCallback(() => {
+ if (typeof onSelect === 'function') {
+ onSelect(item);
+ }
+ }, [item, onSelect]);
+
+ const styleFn = React.useCallback(({ pressed }) => getOrganizationItemStyle(styles, isSelected, pressed), [isSelected]);
+
+ return (
+
+
+ {item.SNAME}
+
+
+ );
+}
+
+//Диалог выбора организации
+function OrganizationSelectDialog({ visible, organizations = [], onSelect, onCancel, title = 'Выбор организации' }) {
+ const insets = useSafeAreaInsets();
+ const [selectedOrg, setSelectedOrg] = React.useState(null);
+
+ //При открытии диалога: сброс выбора и снятие фокуса с клавиатуры
+ React.useEffect(() => {
+ if (visible) {
+ setSelectedOrg(null);
+ Keyboard.dismiss();
+ }
+ }, [visible]);
+
+ //Обработчик выбора организации из списка
+ const handleSelectItem = React.useCallback(org => {
+ setSelectedOrg(org);
+ }, []);
+
+ //Обработчик подтверждения выбора
+ const handleConfirm = React.useCallback(() => {
+ if (selectedOrg && typeof onSelect === 'function') {
+ onSelect(selectedOrg);
+ }
+ }, [selectedOrg, onSelect]);
+
+ //Обработчик отмены
+ const handleCancel = React.useCallback(() => {
+ if (typeof onCancel === 'function') {
+ onCancel();
+ }
+ }, [onCancel]);
+
+ //Функция отрисовки элемента списка
+ const renderItem = React.useCallback(
+ ({ item }) => {
+ const isSelected = selectedOrg != null && item != null && String(selectedOrg.NRN) === String(item.NRN);
+ return ;
+ },
+ [selectedOrg, handleSelectItem]
+ );
+
+ //Генератор ключей для списка
+ const keyExtractor = React.useCallback(item => String(item.NRN), []);
+
+ //Стиль кнопки закрытия диалога при нажатии
+ const getDialogCloseButtonPressableStyle = React.useMemo(() => {
+ return function styleFn({ pressed }) {
+ return [styles.closeButton, pressed && styles.closeButtonPressed];
+ };
+ }, []);
+
+ //Стили с учетом safe area
+ const footerStyle = React.useMemo(
+ () => [styles.footer, { paddingBottom: Math.max(insets.bottom, styles.footer.padding || 16) }],
+ [insets.bottom]
+ );
+
+ return (
+
+
+
+
+
+ {title}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = OrganizationSelectDialog;
diff --git a/rn/app/src/components/common/AppButton.js b/rn/app/src/components/common/AppButton.js
index db6ee08..666d9c2 100644
--- a/rn/app/src/components/common/AppButton.js
+++ b/rn/app/src/components/common/AppButton.js
@@ -16,18 +16,23 @@ const styles = require('../../styles/common/AppButton.styles'); //Стили к
//Тело модуля
//-----------
+//Стиль кнопки в зависимости от состояния нажатия
+function getAppButtonPressableStyle(stylesRef, disabled, style) {
+ return function styleFn({ pressed }) {
+ return [stylesRef.base, disabled && stylesRef.disabled, pressed && !disabled && stylesRef.pressed, style];
+ };
+}
+
//Общая кнопка приложения
function AppButton({ title, onPress, disabled = false, style, textStyle }) {
const handlePress = React.useCallback(() => {
if (!disabled && typeof onPress === 'function') onPress();
}, [disabled, onPress]);
+ const getPressableStyle = React.useMemo(() => getAppButtonPressableStyle(styles, disabled, style), [disabled, style]);
+
return (
- [styles.base, disabled && styles.disabled, pressed && !disabled && styles.pressed, style]}
- >
+
{title}
diff --git a/rn/app/src/components/common/AppInput.js b/rn/app/src/components/common/AppInput.js
index 61b2e9c..c6a5072 100644
--- a/rn/app/src/components/common/AppInput.js
+++ b/rn/app/src/components/common/AppInput.js
@@ -16,37 +16,48 @@ const styles = require('../../styles/common/AppInput.styles'); //Стили вв
//Тело модуля
//-----------
-//Адаптивный компонент ввода
-function AppInput({
- label,
- value,
- onChangeText,
- placeholder,
- secureTextEntry = false,
- keyboardType = 'default',
- autoCapitalize = 'none',
- error,
- helperText,
- disabled = false,
- style,
- inputStyle,
- labelStyle,
- ...restProps
-}) {
+//Адаптивный компонент ввода (с поддержкой ref для фокуса)
+const AppInput = React.forwardRef(function AppInput(
+ {
+ label,
+ value,
+ onChangeText,
+ placeholder,
+ secureTextEntry = false,
+ keyboardType = 'default',
+ autoCapitalize = 'none',
+ error,
+ helperText,
+ disabled = false,
+ style,
+ inputStyle,
+ labelStyle,
+ ...restProps
+ },
+ ref
+) {
const [isFocused, setIsFocused] = React.useState(false);
- const handleFocus = () => setIsFocused(true);
- const handleBlur = () => setIsFocused(false);
+ //Обработчик фокуса
+ const handleFocus = React.useCallback(() => {
+ setIsFocused(true);
+ }, []);
+
+ //Обработчик потери фокуса
+ const handleBlur = React.useCallback(() => {
+ setIsFocused(false);
+ }, []);
return (
- {label && (
+ {label ? (
{label}
- )}
+ ) : null}
- {(error || helperText) && (
+ {error || helperText ? (
{error || helperText}
- )}
+ ) : null}
);
-}
+});
//----------------
//Интерфейс модуля
diff --git a/rn/app/src/components/common/AppLogo.js b/rn/app/src/components/common/AppLogo.js
index fc2f785..8890bff 100644
--- a/rn/app/src/components/common/AppLogo.js
+++ b/rn/app/src/components/common/AppLogo.js
@@ -8,59 +8,43 @@
//---------------------
const React = require('react'); //React
-const { View } = require('react-native'); //Базовые компоненты
+const { View, Image } = require('react-native'); //Базовые компоненты
+const { APP_LOGO, LOGO_SIZE_KEYS } = require('../../config/appAssets'); //Ресурсы приложения
const styles = require('../../styles/common/AppLogo.styles'); //Стили логотипа
//-----------
//Тело модуля
//-----------
-//Иконка/логотип приложения
-function AppLogo({ size = 'medium', style }) {
- //Выбор стилей в зависимости от размера
- const getSizeStyles = React.useCallback(() => {
- switch (size) {
- case 'small':
- return {
- container: styles.containerSmall,
- head: styles.headSmall,
- eye: styles.eyeSmall,
- antenna: styles.antennaSmall
- };
- case 'large':
- return {
- container: styles.containerLarge,
- head: styles.headLarge,
- eye: styles.eyeLarge,
- antenna: styles.antennaLarge
- };
- default:
- return {
- container: styles.containerMedium,
- head: styles.headMedium,
- eye: styles.eyeMedium,
- antenna: styles.antennaMedium
- };
- }
- }, [size]);
+//Возвращает стили контейнера в зависимости от размера
+function getContainerStyleBySize(size) {
+ switch (size) {
+ case LOGO_SIZE_KEYS.SMALL:
+ return styles.containerSmall;
+ case LOGO_SIZE_KEYS.LARGE:
+ return styles.containerLarge;
+ case LOGO_SIZE_KEYS.MEDIUM:
+ default:
+ return styles.containerMedium;
+ }
+}
- const sizeStyles = getSizeStyles();
+//Нормализация пропа size к допустимому значению
+function normalizeSize(size) {
+ if (size === LOGO_SIZE_KEYS.SMALL || size === LOGO_SIZE_KEYS.LARGE) {
+ return size;
+ }
+ return LOGO_SIZE_KEYS.MEDIUM;
+}
+
+//Иконка/логотип приложения
+function AppLogo({ size = LOGO_SIZE_KEYS.MEDIUM, style }) {
+ const normalizedSize = normalizeSize(size);
+ const containerSizeStyle = getContainerStyleBySize(normalizedSize);
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
);
}
diff --git a/rn/app/src/components/common/AppMessage.js b/rn/app/src/components/common/AppMessage.js
index e658867..efc634e 100644
--- a/rn/app/src/components/common/AppMessage.js
+++ b/rn/app/src/components/common/AppMessage.js
@@ -7,43 +7,41 @@
//Подключение библиотек
//---------------------
-const React = require("react"); //React и хуки
-const { Modal, View, Text, Pressable } = require("react-native"); //Базовые компоненты
-const styles = require("../../styles/common/AppMessage.styles"); //Стили сообщения
-
-//---------
-//Константы
-//---------
-
-//Типы сообщений
-const APP_MESSAGE_VARIANT = {
- INFO: "INFO",
- WARN: "WARN",
- ERR: "ERR",
- SUCCESS: "SUCCESS"
-};
+const React = require('react'); //React и хуки
+const { Modal, View, Text, Pressable } = require('react-native'); //Базовые компоненты
+const { APP_MESSAGE_VARIANT } = require('../../config/messagingConfig'); //Типы сообщений
+const styles = require('../../styles/common/AppMessage.styles'); //Стили сообщения
//-----------
//Тело модуля
//-----------
+//Стиль кнопки сообщения при нажатии
+function getMessageButtonPressableStyle(stylesRef, buttonStyle) {
+ return function styleFn({ pressed }) {
+ return [stylesRef.buttonBase, pressed && stylesRef.buttonPressed, buttonStyle];
+ };
+}
+
//Кнопка сообщения
function AppMessageButton({ title, onPress, onDismiss, buttonStyle, textStyle }) {
//Обработчик нажатия - вызывает onPress и закрывает диалог
const handlePress = React.useCallback(() => {
//Сначала закрываем диалог
- if (typeof onDismiss === "function") {
+ if (typeof onDismiss === 'function') {
onDismiss();
}
//Затем выполняем действие кнопки
- if (typeof onPress === "function") {
+ if (typeof onPress === 'function') {
onPress();
}
}, [onPress, onDismiss]);
+ const getPressableStyle = React.useMemo(() => getMessageButtonPressableStyle(styles, buttonStyle), [buttonStyle]);
+
return (
- [styles.buttonBase, pressed && styles.buttonPressed, buttonStyle]} onPress={handlePress}>
+
{title}
);
@@ -81,28 +79,16 @@ function AppMessage({
//Обработчик закрытия
const handleClose = React.useCallback(() => {
- if (typeof onRequestClose === "function") onRequestClose();
+ if (typeof onRequestClose === 'function') onRequestClose();
}, [onRequestClose]);
return (
-
+
-
- {title || ""}
-
-
+ {title || ''}
+
×
@@ -137,4 +123,3 @@ module.exports = {
AppMessage,
APP_MESSAGE_VARIANT
};
-
diff --git a/rn/app/src/components/common/AppSwitch.js b/rn/app/src/components/common/AppSwitch.js
new file mode 100644
index 0000000..52b7749
--- /dev/null
+++ b/rn/app/src/components/common/AppSwitch.js
@@ -0,0 +1,64 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Компонент переключателя
+*/
+
+//---------------------
+//Подключение библиотек
+//---------------------
+
+const React = require('react');
+const { Switch, Pressable } = require('react-native');
+const AppText = require('./AppText');
+const styles = require('../../styles/common/AppSwitch.styles');
+const { APP_COLORS } = require('../../config/theme');
+
+//-----------
+//Тело модуля
+//-----------
+
+//Компонент переключателя с меткой
+function AppSwitch({ label, value, onValueChange, disabled = false, style, labelStyle }) {
+ //Обработчик изменения значения
+ const handleValueChange = React.useCallback(
+ newValue => {
+ if (!disabled && typeof onValueChange === 'function') {
+ onValueChange(newValue);
+ }
+ },
+ [disabled, onValueChange]
+ );
+
+ //Обработчик нажатия на контейнер (переключение значения)
+ const handlePress = React.useCallback(() => {
+ handleValueChange(!value);
+ }, [handleValueChange, value]);
+
+ return (
+
+ {label ? (
+
+ {label}
+
+ ) : null}
+
+
+
+ );
+}
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = AppSwitch;
diff --git a/rn/app/src/components/common/CopyButton.js b/rn/app/src/components/common/CopyButton.js
index bc41cb0..485d1ef 100644
--- a/rn/app/src/components/common/CopyButton.js
+++ b/rn/app/src/components/common/CopyButton.js
@@ -16,7 +16,7 @@ const styles = require('../../styles/common/CopyButton.styles'); //Стили к
//Тело модуля
//-----------
-//Иконка копирования (два прямоугольника)
+//Иконка копирования
function CopyIcon() {
return (
@@ -26,6 +26,13 @@ function CopyIcon() {
);
}
+//Стиль кнопки копирования при нажатии
+function getCopyButtonPressableStyle(stylesRef, disabled, style) {
+ return function styleFn({ pressed }) {
+ return [stylesRef.button, pressed && !disabled && stylesRef.buttonPressed, disabled && stylesRef.buttonDisabled, style];
+ };
+}
+
//Кнопка копирования в буфер обмена
function CopyButton({ value, onCopy, onError, disabled = false, style }) {
//Обработчик нажатия
@@ -49,11 +56,13 @@ function CopyButton({ value, onCopy, onError, disabled = false, style }) {
}
}, [value, disabled, onCopy, onError]);
+ const getPressableStyle = React.useMemo(() => getCopyButtonPressableStyle(styles, disabled, style), [disabled, style]);
+
return (
[styles.button, pressed && !disabled && styles.buttonPressed, disabled && styles.buttonDisabled, style]}
+ style={getPressableStyle}
onPress={handlePress}
disabled={disabled}
>
diff --git a/rn/app/src/components/common/LoadingOverlay.js b/rn/app/src/components/common/LoadingOverlay.js
new file mode 100644
index 0000000..6635d37
--- /dev/null
+++ b/rn/app/src/components/common/LoadingOverlay.js
@@ -0,0 +1,45 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Компонент оверлея загрузки (блокировка экрана)
+*/
+
+//---------------------
+//Подключение библиотек
+//---------------------
+
+const React = require('react');
+const { Modal, View, ActivityIndicator } = require('react-native');
+const AppText = require('./AppText');
+const styles = require('../../styles/common/LoadingOverlay.styles');
+
+//-----------
+//Тело модуля
+//-----------
+
+//Компонент оверлея загрузки
+function LoadingOverlay({ visible, message = 'Загрузка...' }) {
+ if (!visible) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ {message ? (
+
+ {message}
+
+ ) : null}
+
+
+
+ );
+}
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = LoadingOverlay;
diff --git a/rn/app/src/components/common/PasswordInput.js b/rn/app/src/components/common/PasswordInput.js
new file mode 100644
index 0000000..65e3912
--- /dev/null
+++ b/rn/app/src/components/common/PasswordInput.js
@@ -0,0 +1,136 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Компонент ввода пароля с возможностью показать/скрыть
+*/
+
+//---------------------
+//Подключение библиотек
+//---------------------
+
+const React = require('react');
+const { View, TextInput, Pressable } = require('react-native');
+const AppText = require('./AppText');
+const styles = require('../../styles/common/PasswordInput.styles');
+
+//-----------
+//Тело модуля
+//-----------
+
+//Компонент ввода пароля
+const PasswordInput = React.forwardRef(function PasswordInput(
+ {
+ label,
+ value,
+ onChangeText,
+ placeholder,
+ showPassword = false,
+ onTogglePassword,
+ error,
+ helperText,
+ disabled = false,
+ style,
+ inputStyle,
+ labelStyle,
+ ...restProps
+ },
+ ref
+) {
+ const [isFocused, setIsFocused] = React.useState(false);
+
+ const inputRef = React.useRef(null);
+ const setRef = React.useCallback(
+ node => {
+ inputRef.current = node;
+ if (typeof ref === 'function') {
+ ref(node);
+ } else if (ref) {
+ ref.current = node;
+ }
+ },
+ [ref]
+ );
+
+ //Обработчик фокуса
+ const handleFocus = React.useCallback(() => {
+ setIsFocused(true);
+ }, []);
+
+ //Обработчик потери фокуса
+ const handleBlur = React.useCallback(() => {
+ setIsFocused(false);
+ }, []);
+
+ //Обработчик переключения видимости пароля
+ const handleTogglePassword = React.useCallback(() => {
+ if (typeof onTogglePassword === 'function') {
+ onTogglePassword(!showPassword);
+ }
+ }, [onTogglePassword, showPassword]);
+
+ //Обработчик нажатия на контейнер для фокуса на поле ввода
+ const handleContainerPress = React.useCallback(() => {
+ if (!disabled && inputRef.current) {
+ inputRef.current.focus();
+ }
+ }, [disabled]);
+
+ return (
+
+ {label ? (
+
+ {label}
+
+ ) : null}
+
+
+
+
+
+
+ {showPassword ? 'Скрыть' : 'Показать'}
+
+
+
+
+ {error || helperText ? (
+
+ {error || helperText}
+
+ ) : null}
+
+ );
+});
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = PasswordInput;
diff --git a/rn/app/src/components/inspections/InspectionItem.js b/rn/app/src/components/inspections/InspectionItem.js
index 4e7262b..eaa218d 100644
--- a/rn/app/src/components/inspections/InspectionItem.js
+++ b/rn/app/src/components/inspections/InspectionItem.js
@@ -7,10 +7,10 @@
//Подключение библиотек
//---------------------
-const React = require("react"); //React
-const { View } = require("react-native"); //Базовые компоненты
-const AppText = require("../common/AppText"); //Общий текстовый компонент
-const styles = require("../../styles/inspections/InspectionItem.styles"); //Стили элемента
+const React = require('react'); //React
+const { View } = require('react-native'); //Базовые компоненты
+const AppText = require('../common/AppText'); //Общий текстовый компонент
+const styles = require('../../styles/inspections/InspectionItem.styles'); //Стили элемента
//-----------
//Тело модуля
@@ -34,4 +34,3 @@ function InspectionItem({ item }) {
//----------------
module.exports = InspectionItem;
-
diff --git a/rn/app/src/components/inspections/InspectionList.js b/rn/app/src/components/inspections/InspectionList.js
index e1d2f32..e953138 100644
--- a/rn/app/src/components/inspections/InspectionList.js
+++ b/rn/app/src/components/inspections/InspectionList.js
@@ -33,7 +33,7 @@ function InspectionList({ inspections, isLoading, error, onRefresh }) {
if (!hasData && isLoading) {
return (
-
+
Загружаем данные...
);
diff --git a/rn/app/src/components/layout/AppAuthProvider.js b/rn/app/src/components/layout/AppAuthProvider.js
new file mode 100644
index 0000000..52b39ec
--- /dev/null
+++ b/rn/app/src/components/layout/AppAuthProvider.js
@@ -0,0 +1,124 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Провайдер авторизации приложения
+*/
+
+//---------------------
+//Подключение библиотек
+//---------------------
+
+const React = require('react');
+const useAuth = require('../../hooks/useAuth');
+
+//-----------
+//Тело модуля
+//-----------
+
+//Контекст авторизации
+const AppAuthContext = React.createContext(null);
+
+//Провайдер авторизации
+function AppAuthProvider({ children }) {
+ const auth = useAuth();
+
+ //Состояние для хранения данных формы авторизации
+ const [authFormData, setAuthFormData] = React.useState({
+ serverUrl: '',
+ username: '',
+ password: '',
+ savePassword: false
+ });
+
+ //Флаг проверки сессии
+ const [sessionChecked, setSessionChecked] = React.useState(false);
+
+ //Обновление данных формы авторизации
+ const updateAuthFormData = React.useCallback(data => {
+ setAuthFormData(prev => ({ ...prev, ...data }));
+ }, []);
+
+ //Очистка данных формы авторизации
+ const clearAuthFormData = React.useCallback(() => {
+ setAuthFormData({
+ serverUrl: '',
+ username: '',
+ password: '',
+ savePassword: false
+ });
+ }, []);
+
+ //Отметка что сессия была проверена
+ const markSessionChecked = React.useCallback(() => {
+ setSessionChecked(true);
+ }, []);
+
+ //Мемоизация значения контекста с перечислением отдельных свойств
+ const value = React.useMemo(
+ () => ({
+ session: auth.session,
+ isAuthenticated: auth.isAuthenticated,
+ isLoading: auth.isLoading,
+ isInitialized: auth.isInitialized,
+ error: auth.error,
+ login: auth.login,
+ logout: auth.logout,
+ selectCompany: auth.selectCompany,
+ checkSession: auth.checkSession,
+ getDeviceId: auth.getDeviceId,
+ getSavedCredentials: auth.getSavedCredentials,
+ clearSavedCredentials: auth.clearSavedCredentials,
+ getAuthSession: auth.getAuthSession,
+ clearAuthSession: auth.clearAuthSession,
+ getAndClearSessionRestoredFromStorage: auth.getAndClearSessionRestoredFromStorage,
+ AUTH_SETTINGS_KEYS: auth.AUTH_SETTINGS_KEYS,
+ authFormData,
+ updateAuthFormData,
+ clearAuthFormData,
+ sessionChecked,
+ markSessionChecked
+ }),
+ [
+ auth.session,
+ auth.isAuthenticated,
+ auth.isLoading,
+ auth.isInitialized,
+ auth.error,
+ auth.login,
+ auth.logout,
+ auth.selectCompany,
+ auth.checkSession,
+ auth.getDeviceId,
+ auth.getSavedCredentials,
+ auth.clearSavedCredentials,
+ auth.getAuthSession,
+ auth.clearAuthSession,
+ auth.getAndClearSessionRestoredFromStorage,
+ auth.AUTH_SETTINGS_KEYS,
+ authFormData,
+ updateAuthFormData,
+ clearAuthFormData,
+ sessionChecked,
+ markSessionChecked
+ ]
+ );
+
+ return {children};
+}
+
+//Хук доступа к контексту авторизации
+function useAppAuthContext() {
+ const ctx = React.useContext(AppAuthContext);
+ if (!ctx) {
+ throw new Error('useAppAuthContext должен использоваться внутри AppAuthProvider');
+ }
+ return ctx;
+}
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = {
+ AppAuthProvider,
+ useAppAuthContext
+};
diff --git a/rn/app/src/components/layout/AppErrorBoundary.js b/rn/app/src/components/layout/AppErrorBoundary.js
index 93a44ed..8136f1b 100644
--- a/rn/app/src/components/layout/AppErrorBoundary.js
+++ b/rn/app/src/components/layout/AppErrorBoundary.js
@@ -17,20 +17,24 @@ const styles = require('../../styles/layout/AppErrorBoundary.styles'); //Сти
//Тело модуля
//-----------
+//Обработчик перезагрузки приложения
+function handleErrorReload(onReload) {
+ if (typeof onReload === 'function') {
+ onReload();
+ return;
+ }
+ if (typeof window !== 'undefined' && window.location && typeof window.location.reload === 'function') {
+ window.location.reload();
+ }
+}
+
//Компонент страницы ошибки
function AppErrorPage({ error, onReload }) {
const message = error && error.message ? String(error.message) : 'Произошла непредвиденная ошибка приложения.';
- const handleReload = () => {
- if (typeof onReload === 'function') {
- onReload();
- return;
- }
- //Попытка перезагрузки для Web
- if (typeof window !== 'undefined' && window.location && typeof window.location.reload === 'function') {
- window.location.reload();
- }
- };
+ const handleReload = React.useCallback(() => {
+ handleErrorReload(onReload);
+ }, [onReload]);
return (
diff --git a/rn/app/src/components/layout/AppHeader.js b/rn/app/src/components/layout/AppHeader.js
index 8dfb8a7..269099c 100644
--- a/rn/app/src/components/layout/AppHeader.js
+++ b/rn/app/src/components/layout/AppHeader.js
@@ -13,52 +13,12 @@ const AppText = require('../common/AppText'); //Общий текстовый к
const AppLogo = require('../common/AppLogo'); //Логотип приложения
const { useAppModeContext } = require('./AppModeProvider'); //Контекст режима работы
const { useAppNavigationContext } = require('./AppNavigationProvider'); //Контекст навигации
-const { useAppMessagingContext } = require('./AppMessagingProvider'); //Контекст сообщений
-const { getModeLabel, getModeDescription } = require('../../utils/appInfo'); //Утилиты информации
const styles = require('../../styles/layout/AppHeader.styles'); //Стили заголовка
//-----------
//Тело модуля
//-----------
-//Индикатор режима работы
-function ModeIndicator({ mode, onPress }) {
- //Получение конфигурации стилей для режима
- const getModeStyleConfig = React.useCallback(() => {
- switch (mode) {
- case 'ONLINE':
- return {
- color: styles.modeOnline,
- textColor: styles.modeTextOnline
- };
- case 'OFFLINE':
- return {
- color: styles.modeOffline,
- textColor: styles.modeTextOffline
- };
- case 'NOT_CONNECTED':
- return {
- color: styles.modeNotConnected,
- textColor: styles.modeTextNotConnected
- };
- default:
- return {
- color: styles.modeUnknown,
- textColor: styles.modeTextUnknown
- };
- }
- }, [mode]);
-
- const styleConfig = getModeStyleConfig();
- const label = getModeLabel(mode);
-
- return (
- [styles.modeContainer, styleConfig.color, pressed && styles.modePressed]} onPress={onPress}>
- {label}
-
- );
-}
-
//Иконка стрелки назад
function BackArrowIcon() {
return (
@@ -69,33 +29,28 @@ function BackArrowIcon() {
);
}
+//Стиль кнопки назад при нажатии
+function getBackButtonPressableStyle(stylesRef) {
+ return function styleFn({ pressed }) {
+ return [stylesRef.backButton, pressed && stylesRef.backButtonPressed];
+ };
+}
+
//Кнопка назад
function BackButton({ onPress }) {
+ const getPressableStyle = React.useMemo(() => getBackButtonPressableStyle(styles), []);
+
return (
- [styles.backButton, pressed && styles.backButtonPressed]}
- onPress={onPress}
- >
+
);
}
//Заголовок приложения
-function AppHeader({
- title,
- subtitle,
- showMenuButton = true,
- onMenuPress,
- showModeIndicator = true,
- showBackButton = false,
- onBackPress
-}) {
+function AppHeader({ title, subtitle, showMenuButton = true, onMenuPress, showBackButton = false, onBackPress }) {
const { mode } = useAppModeContext();
const { currentScreen, SCREENS } = useAppNavigationContext();
- const { showInfo } = useAppMessagingContext();
//Получение заголовка экрана
const getTitle = React.useCallback(() => {
@@ -125,15 +80,12 @@ function AppHeader({
}
}, [subtitle, currentScreen, mode, SCREENS.MAIN, SCREENS.SETTINGS]);
- //Обработчик нажатия на индикатор режима (универсальный для всех экранов)
- const handleModeIndicatorPress = React.useCallback(() => {
- const modeLabel = getModeLabel(mode);
- const modeDescription = getModeDescription(mode);
-
- showInfo(`Текущий режим: ${modeLabel}`, {
- message: modeDescription
- });
- }, [mode, showInfo]);
+ //Стиль кнопки меню при нажатии
+ const getMenuButtonPressableStyle = React.useMemo(() => {
+ return function styleFn({ pressed }) {
+ return [styles.menuButton, pressed && styles.menuButtonPressed];
+ };
+ }, []);
//Отрисовка левой части шапки (логотип или кнопка назад)
const renderLeftSection = () => {
@@ -160,19 +112,15 @@ function AppHeader({
) : null}
-
- {showModeIndicator ? : null}
-
- {showMenuButton ? (
- [styles.menuButton, pressed && styles.menuButtonPressed]} onPress={onMenuPress}>
-
-
-
-
-
-
- ) : null}
-
+ {showMenuButton ? (
+
+
+
+
+
+
+
+ ) : null}
);
diff --git a/rn/app/src/components/layout/AppLocalDbProvider.js b/rn/app/src/components/layout/AppLocalDbProvider.js
index a4dfe14..1a9ab46 100644
--- a/rn/app/src/components/layout/AppLocalDbProvider.js
+++ b/rn/app/src/components/layout/AppLocalDbProvider.js
@@ -36,7 +36,10 @@ function AppLocalDbProvider({ children }) {
clearSettings: api.clearSettings,
clearInspections: api.clearInspections,
vacuum: api.vacuum,
- checkTableExists: api.checkTableExists
+ checkTableExists: api.checkTableExists,
+ setAuthSession: api.setAuthSession,
+ getAuthSession: api.getAuthSession,
+ clearAuthSession: api.clearAuthSession
}),
[
api.isDbReady,
@@ -51,7 +54,10 @@ function AppLocalDbProvider({ children }) {
api.clearSettings,
api.clearInspections,
api.vacuum,
- api.checkTableExists
+ api.checkTableExists,
+ api.setAuthSession,
+ api.getAuthSession,
+ api.clearAuthSession
]
);
diff --git a/rn/app/src/components/layout/AppNavigationProvider.js b/rn/app/src/components/layout/AppNavigationProvider.js
index 3075ea8..98dce38 100644
--- a/rn/app/src/components/layout/AppNavigationProvider.js
+++ b/rn/app/src/components/layout/AppNavigationProvider.js
@@ -38,7 +38,7 @@ function AppNavigationProvider({ children }) {
}, [canGoBack, goBack]);
//Подключаем обработчик кнопки "Назад"
- useHardwareBackPress(handleHardwareBackPress, [handleHardwareBackPress]);
+ useHardwareBackPress(handleHardwareBackPress);
//Мемоизация значения контекста с перечислением отдельных свойств
const value = React.useMemo(
@@ -49,6 +49,7 @@ function AppNavigationProvider({ children }) {
navigate: navigationApi.navigate,
goBack: navigationApi.goBack,
reset: navigationApi.reset,
+ setInitialScreen: navigationApi.setInitialScreen,
canGoBack: navigationApi.canGoBack
}),
[
@@ -58,6 +59,7 @@ function AppNavigationProvider({ children }) {
navigationApi.navigate,
navigationApi.goBack,
navigationApi.reset,
+ navigationApi.setInitialScreen,
navigationApi.canGoBack
]
);
diff --git a/rn/app/src/components/layout/AppPreTripInspectionsProvider.js b/rn/app/src/components/layout/AppPreTripInspectionsProvider.js
index 1bd2be5..d427744 100644
--- a/rn/app/src/components/layout/AppPreTripInspectionsProvider.js
+++ b/rn/app/src/components/layout/AppPreTripInspectionsProvider.js
@@ -8,7 +8,7 @@
//---------------------
const React = require('react'); //React и хуки
-const { usePreTripInspections } = require('../../hooks/usePreTripInspections'); //Хук предметной области
+const usePreTripInspections = require('../../hooks/usePreTripInspections'); //Хук предметной области
//-----------
//Тело модуля
diff --git a/rn/app/src/components/layout/AppRoot.js b/rn/app/src/components/layout/AppRoot.js
index 2a52aec..453968b 100644
--- a/rn/app/src/components/layout/AppRoot.js
+++ b/rn/app/src/components/layout/AppRoot.js
@@ -11,6 +11,9 @@ const React = require('react'); //React и хуки
const { useColorScheme } = require('react-native'); //Определение темы устройства
const { SafeAreaProvider } = require('react-native-safe-area-context'); //Провайдер безопасной области
const AppShell = require('./AppShell'); //Оболочка приложения
+const { useAppNavigationContext } = require('./AppNavigationProvider'); //Контекст навигации
+const { useAppAuthContext } = require('./AppAuthProvider'); //Контекст авторизации
+const { useAppLocalDbContext } = require('./AppLocalDbProvider'); //Контекст локальной БД
//-----------
//Тело модуля
@@ -21,6 +24,28 @@ function AppRoot() {
const colorScheme = useColorScheme();
const isDarkMode = colorScheme === 'dark';
+ const { setInitialScreen, SCREENS } = useAppNavigationContext();
+ const { isAuthenticated, isInitialized } = useAppAuthContext();
+ const { isDbReady } = useAppLocalDbContext();
+
+ //Флаг для предотвращения повторной установки начального экрана
+ const initialScreenSetRef = React.useRef(false);
+
+ //Установка начального экрана при готовности
+ React.useEffect(() => {
+ //Ждём инициализации БД и авторизации
+ if (!isDbReady || !isInitialized || initialScreenSetRef.current) {
+ return;
+ }
+
+ initialScreenSetRef.current = true;
+
+ //Если авторизован - показываем главный экран
+ if (isAuthenticated) {
+ setInitialScreen(SCREENS.MAIN);
+ }
+ }, [isDbReady, isInitialized, isAuthenticated, setInitialScreen, SCREENS.MAIN]);
+
return (
diff --git a/rn/app/src/components/layout/AppShell.js b/rn/app/src/components/layout/AppShell.js
index 74212c9..920fdad 100644
--- a/rn/app/src/components/layout/AppShell.js
+++ b/rn/app/src/components/layout/AppShell.js
@@ -12,6 +12,7 @@ const { StatusBar, Platform } = require('react-native'); //Базовые ком
const { useAppNavigationContext } = require('./AppNavigationProvider'); //Контекст навигации
const MainScreen = require('../../screens/MainScreen'); //Главный экран
const SettingsScreen = require('../../screens/SettingsScreen'); //Экран настроек
+const AuthScreen = require('../../screens/AuthScreen'); //Экран авторизации
const AdaptiveView = require('../common/AdaptiveView'); //Адаптивный контейнер
const styles = require('../../styles/layout/AppShell.styles'); //Стили оболочки
@@ -29,10 +30,12 @@ function AppShell({ isDarkMode }) {
return ;
case SCREENS.SETTINGS:
return ;
+ case SCREENS.AUTH:
+ return ;
default:
return ;
}
- }, [currentScreen, SCREENS.MAIN, SCREENS.SETTINGS]);
+ }, [currentScreen, SCREENS.MAIN, SCREENS.SETTINGS, SCREENS.AUTH]);
//Определяем цвет status bar в зависимости от темы
const statusBarStyle = isDarkMode ? 'light-content' : 'dark-content';
diff --git a/rn/app/src/components/menu/MenuHeader.js b/rn/app/src/components/menu/MenuHeader.js
index bb093d5..4f4900c 100644
--- a/rn/app/src/components/menu/MenuHeader.js
+++ b/rn/app/src/components/menu/MenuHeader.js
@@ -16,6 +16,13 @@ const styles = require('../../styles/menu/MenuHeader.styles'); //Стили за
//Тело модуля
//-----------
+//Стиль кнопки закрытия меню при нажатии
+function getMenuCloseButtonPressableStyle(stylesRef) {
+ return function styleFn({ pressed }) {
+ return [stylesRef.closeButton, pressed && stylesRef.closeButtonPressed];
+ };
+}
+
//Заголовок меню
function MenuHeader({ title, onClose, showCloseButton = true, style }) {
const handleClose = React.useCallback(() => {
@@ -24,18 +31,15 @@ function MenuHeader({ title, onClose, showCloseButton = true, style }) {
}
}, [onClose]);
+ const getCloseButtonPressableStyle = React.useMemo(() => getMenuCloseButtonPressableStyle(styles), []);
+
return (
{title || 'Меню'}
{showCloseButton ? (
- [styles.closeButton, pressed && styles.closeButtonPressed]}
- >
+
×
diff --git a/rn/app/src/components/menu/MenuItem.js b/rn/app/src/components/menu/MenuItem.js
index b92c4f9..bf2a30c 100644
--- a/rn/app/src/components/menu/MenuItem.js
+++ b/rn/app/src/components/menu/MenuItem.js
@@ -16,6 +16,19 @@ const styles = require('../../styles/menu/MenuItem.styles'); //Стили эле
//Тело модуля
//-----------
+//Стиль элемента меню при нажатии
+function getMenuItemPressableStyle(stylesRef, isDestructive, disabled, style) {
+ return function styleFn({ pressed }) {
+ return [
+ stylesRef.menuItem,
+ pressed && !disabled && stylesRef.menuItemPressed,
+ isDestructive && stylesRef.menuItemDestructive,
+ disabled && stylesRef.menuItemDisabled,
+ style
+ ];
+ };
+}
+
//Элемент меню
function MenuItem({ title, icon, onPress, isDestructive = false, disabled = false, style, textStyle }) {
const handlePress = React.useCallback(() => {
@@ -24,18 +37,13 @@ function MenuItem({ title, icon, onPress, isDestructive = false, disabled = fals
}
}, [disabled, onPress]);
+ const getPressableStyle = React.useMemo(
+ () => getMenuItemPressableStyle(styles, isDestructive, disabled, style),
+ [isDestructive, disabled, style]
+ );
+
return (
- [
- styles.menuItem,
- pressed && !disabled && styles.menuItemPressed,
- isDestructive && styles.menuItemDestructive,
- disabled && styles.menuItemDisabled,
- style
- ]}
- onPress={handlePress}
- disabled={disabled}
- >
+
{icon ? {icon} : null}
{
+ onItemPress(item);
+ }, [item, onItemPress]);
+
+ return (
+
+
+ {item.showDivider && index < items.length - 1 && }
+
+ );
+}
+
//Список элементов меню
function MenuList({ items = [], onClose, style }) {
const handleItemPress = React.useCallback(
@@ -38,20 +60,15 @@ function MenuList({ items = [], onClose, style }) {
return (
- {items.map((item, index) => (
-
-
- ))}
+ {items.map((item, index) => {
+ //Элемент-разделитель
+ if (item.type === 'divider') {
+ return ;
+ }
+
+ //Обычный элемент меню
+ return ;
+ })}
);
}
diff --git a/rn/app/src/components/menu/MenuUserInfo.js b/rn/app/src/components/menu/MenuUserInfo.js
new file mode 100644
index 0000000..9d76dca
--- /dev/null
+++ b/rn/app/src/components/menu/MenuUserInfo.js
@@ -0,0 +1,83 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Компонент информации о пользователе и режиме работы в меню
+*/
+
+//---------------------
+//Подключение библиотек
+//---------------------
+
+const React = require('react');
+const { View } = require('react-native');
+const AppText = require('../common/AppText');
+const { getModeLabel } = require('../../utils/appInfo');
+const styles = require('../../styles/menu/MenuUserInfo.styles');
+
+//-----------
+//Тело модуля
+//-----------
+
+//Компонент информации о пользователе и режиме работы
+function MenuUserInfo({ mode, username, organization, isConnected }) {
+ //Получение стиля для индикатора режима
+ const getModeStyle = React.useCallback(() => {
+ switch (mode) {
+ case 'ONLINE':
+ return styles.modeOnline;
+ case 'OFFLINE':
+ return styles.modeOffline;
+ case 'NOT_CONNECTED':
+ default:
+ return styles.modeNotConnected;
+ }
+ }, [mode]);
+
+ //Получение стиля текста для режима
+ const getModeTextStyle = React.useCallback(() => {
+ switch (mode) {
+ case 'ONLINE':
+ return styles.modeTextOnline;
+ case 'OFFLINE':
+ return styles.modeTextOffline;
+ case 'NOT_CONNECTED':
+ default:
+ return styles.modeTextNotConnected;
+ }
+ }, [mode]);
+
+ return (
+
+
+ {getModeLabel(mode)}
+
+
+ {isConnected && (username || organization) ? (
+
+ {username ? (
+
+ Пользователь:
+
+ {username}
+
+
+ ) : null}
+
+ {organization ? (
+
+ Организация:
+
+ {organization}
+
+
+ ) : null}
+
+ ) : null}
+
+ );
+}
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = MenuUserInfo;
diff --git a/rn/app/src/components/menu/SideMenu.js b/rn/app/src/components/menu/SideMenu.js
index 6fa9330..8143b2c 100644
--- a/rn/app/src/components/menu/SideMenu.js
+++ b/rn/app/src/components/menu/SideMenu.js
@@ -11,24 +11,23 @@ const React = require('react'); //React
const { Modal, View, Animated, Pressable } = require('react-native'); //Базовые компоненты
const { useSafeAreaInsets } = require('react-native-safe-area-context'); //Отступы безопасной области
const MenuHeader = require('./MenuHeader'); //Заголовок меню
+const MenuUserInfo = require('./MenuUserInfo'); //Информация о пользователе
const MenuList = require('./MenuList'); //Список элементов меню
const { widthPercentage, isTablet } = require('../../utils/responsive'); //Адаптивные утилиты
+const {
+ MENU_WIDTH_PHONE_PERCENT,
+ MENU_WIDTH_TABLET_PERCENT,
+ MENU_MAX_WIDTH,
+ MENU_MIN_WIDTH,
+ ANIMATION_DURATION_OPEN,
+ ANIMATION_DURATION_CLOSE
+} = require('../../config/menuConfig'); //Конфигурация меню
const styles = require('../../styles/menu/SideMenu.styles'); //Стили бокового меню
//-----------
//Тело модуля
//-----------
-//Ширина меню в зависимости от типа устройства
-const MENU_WIDTH_PHONE_PERCENT = 70;
-const MENU_WIDTH_TABLET_PERCENT = 40;
-const MENU_MAX_WIDTH = 360;
-const MENU_MIN_WIDTH = 280;
-
-//Длительность анимации (мс)
-const ANIMATION_DURATION_OPEN = 250;
-const ANIMATION_DURATION_CLOSE = 200;
-
//Расчёт ширины меню с учётом адаптивности
const calculateMenuWidth = () => {
const percent = isTablet() ? MENU_WIDTH_TABLET_PERCENT : MENU_WIDTH_PHONE_PERCENT;
@@ -37,7 +36,9 @@ const calculateMenuWidth = () => {
};
//Боковое меню приложения
-function SideMenu({ visible, onClose, items = [], title = 'Меню', headerStyle, containerStyle, contentStyle }) {
+function SideMenu({ visible, onClose, items = [], title = 'Меню', mode, username, organization, headerStyle, containerStyle, contentStyle }) {
+ //Определение подключено ли приложение
+ const isConnected = mode === 'ONLINE' || mode === 'OFFLINE';
//Получаем отступы безопасной области
const insets = useSafeAreaInsets();
@@ -123,13 +124,7 @@ function SideMenu({ visible, onClose, items = [], title = 'Меню', headerStyl
}, [visible, modalVisible, openMenu, closeMenu]);
return (
-
+
@@ -148,6 +143,8 @@ function SideMenu({ visible, onClose, items = [], title = 'Меню', headerStyl
>
+
+
diff --git a/rn/app/src/config/appAssets.js b/rn/app/src/config/appAssets.js
new file mode 100644
index 0000000..9039974
--- /dev/null
+++ b/rn/app/src/config/appAssets.js
@@ -0,0 +1,27 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Конфигурация ресурсов приложения (изображения, иконки)
+*/
+
+//---------
+//Константы
+//---------
+
+//Ключи размеров логотипа
+const LOGO_SIZE_KEYS = Object.freeze({
+ SMALL: 'small',
+ MEDIUM: 'medium',
+ LARGE: 'large'
+});
+
+//Изображение логотипа приложения
+const APP_LOGO = require('../../assets/icons/logo.png');
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = {
+ LOGO_SIZE_KEYS,
+ APP_LOGO
+};
diff --git a/rn/app/src/config/appConfig.js b/rn/app/src/config/appConfig.js
index 183d500..de3ed1e 100644
--- a/rn/app/src/config/appConfig.js
+++ b/rn/app/src/config/appConfig.js
@@ -14,30 +14,6 @@ const { Platform } = require('react-native');
//Константы
//---------
-//Настройки сервера приложений
-const SYSTEM = {
- //Адрес сервера приложений
- SERVER: '',
-
- //Таймаут сетевых запросов (мс)
- REQUEST_TIMEOUT: 30000,
-
- //Минимальная версия Android для работы с SQLite
- MIN_ANDROID_VERSION: 7.0,
-
- //Минимальная версия iOS для работы с SQLite
- MIN_IOS_VERSION: 11.0
-};
-
-//Настройки локального хранилища
-const LOCAL_DB = {
- //Ключ для хранения данных предрейсовых осмотров
- INSPECTIONS_KEY: 'pretrip_inspections',
-
- //Резервное хранилище для старых устройств (AsyncStorage)
- FALLBACK_STORAGE_KEY: 'pretrip_fallback_storage'
-};
-
//Настройки интерфейса
const UI = {
//Отступы по умолчанию (адаптивные)
@@ -89,8 +65,6 @@ const COMPATIBILITY = {
//----------------
module.exports = {
- SYSTEM,
- LOCAL_DB,
UI,
COMPATIBILITY
};
diff --git a/rn/app/src/config/authApi.js b/rn/app/src/config/authApi.js
new file mode 100644
index 0000000..81be77a
--- /dev/null
+++ b/rn/app/src/config/authApi.js
@@ -0,0 +1,42 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Константы API авторизации и сообщения об ошибках
+*/
+
+//---------
+//Константы
+//---------
+
+//Коды действий API авторизации
+const ACTION_CODES = {
+ LOG_IN: 'LOG_IN',
+ LOG_OUT: 'LOG_OUT',
+ CHECK_SESSION: 'CHECK_SESSION',
+ SET_COMPANY: 'SET_COMPANY'
+};
+
+//Статусы ответа сервера
+const RESPONSE_STATES = {
+ OK: 'OK',
+ ERR: 'ERR'
+};
+
+//Сообщения об ошибках авторизации
+const ERROR_MESSAGES = {
+ NETWORK_ERROR: 'Ошибка соединения с сервером',
+ SERVER_ERROR: 'Ошибка сервера',
+ INVALID_RESPONSE: 'Некорректный ответ сервера',
+ SESSION_EXPIRED: 'Сессия истекла',
+ LOGIN_FAILED: 'Ошибка входа',
+ LOGOUT_FAILED: 'Ошибка выхода'
+};
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = {
+ ACTION_CODES,
+ RESPONSE_STATES,
+ ERROR_MESSAGES
+};
diff --git a/rn/app/src/config/authConfig.js b/rn/app/src/config/authConfig.js
new file mode 100644
index 0000000..b786584
--- /dev/null
+++ b/rn/app/src/config/authConfig.js
@@ -0,0 +1,44 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Конфигурация авторизации
+*/
+
+//---------
+//Константы
+//---------
+
+//Ключи настроек авторизации
+const AUTH_SETTINGS_KEYS = {
+ SERVER_URL: 'app_server_url',
+ HIDE_SERVER_URL: 'auth_hide_server_url',
+ IDLE_TIMEOUT: 'auth_idle_timeout',
+ DEVICE_ID: 'auth_device_id',
+ DEVICE_SECRET_KEY: 'auth_device_secret_key',
+ SAVED_LOGIN: 'auth_saved_login',
+ SAVED_PASSWORD: 'auth_saved_password',
+ SAVE_PASSWORD_ENABLED: 'auth_save_password_enabled'
+};
+
+//Значение времени простоя по умолчанию (минуты)
+const DEFAULT_IDLE_TIMEOUT = 30;
+
+//Ключи настроек подключения
+const CONNECTION_SETTINGS_KEYS = [
+ AUTH_SETTINGS_KEYS.SERVER_URL,
+ AUTH_SETTINGS_KEYS.HIDE_SERVER_URL,
+ AUTH_SETTINGS_KEYS.SAVED_LOGIN,
+ AUTH_SETTINGS_KEYS.SAVED_PASSWORD,
+ AUTH_SETTINGS_KEYS.SAVE_PASSWORD_ENABLED,
+ AUTH_SETTINGS_KEYS.DEVICE_ID,
+ AUTH_SETTINGS_KEYS.DEVICE_SECRET_KEY
+];
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = {
+ AUTH_SETTINGS_KEYS,
+ CONNECTION_SETTINGS_KEYS,
+ DEFAULT_IDLE_TIMEOUT
+};
diff --git a/rn/app/src/config/database.js b/rn/app/src/config/database.js
new file mode 100644
index 0000000..8854a51
--- /dev/null
+++ b/rn/app/src/config/database.js
@@ -0,0 +1,19 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Конфигурация локальной базы данных
+*/
+
+//---------
+//Константы
+//---------
+
+//Имя файла базы данных SQLite
+const DB_NAME = 'pretrip_inspections.db';
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = {
+ DB_NAME
+};
diff --git a/rn/app/src/config/dialogButtons.js b/rn/app/src/config/dialogButtons.js
new file mode 100644
index 0000000..567b90b
--- /dev/null
+++ b/rn/app/src/config/dialogButtons.js
@@ -0,0 +1,56 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Константы стилей кнопок диалогов подтверждения
+*/
+
+//---------------------
+//Подключение библиотек
+//---------------------
+
+const { APP_COLORS } = require('./theme');
+
+//---------
+//Константы
+//---------
+
+//Типы кнопок подтверждения в диалогах
+const DIALOG_BUTTON_TYPE = {
+ ERROR: 'error',
+ WARNING: 'warning'
+};
+
+//Стили кнопки "Отмена" (нейтральная) — общие для всех диалогов
+const DIALOG_CANCEL_BUTTON = {
+ id: 'cancel',
+ title: 'Отмена',
+ onPress: () => {}
+};
+
+//Фабрика: стили и текст для кнопки подтверждения в диалоге
+function getConfirmButtonOptions(type, title, onPress) {
+ const base = {
+ id: 'confirm',
+ title: title || 'Подтвердить',
+ onPress: typeof onPress === 'function' ? onPress : () => {}
+ };
+
+ if (type === DIALOG_BUTTON_TYPE.ERROR) {
+ base.buttonStyle = { backgroundColor: APP_COLORS.error };
+ base.textStyle = { color: APP_COLORS.white };
+ } else if (type === DIALOG_BUTTON_TYPE.WARNING) {
+ base.buttonStyle = { backgroundColor: APP_COLORS.warning };
+ base.textStyle = { color: APP_COLORS.white };
+ }
+
+ return base;
+}
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = {
+ DIALOG_BUTTON_TYPE,
+ DIALOG_CANCEL_BUTTON,
+ getConfirmButtonOptions
+};
diff --git a/rn/app/src/config/loadStatus.js b/rn/app/src/config/loadStatus.js
new file mode 100644
index 0000000..ea49599
--- /dev/null
+++ b/rn/app/src/config/loadStatus.js
@@ -0,0 +1,25 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Константы статусов загрузки данных
+*/
+
+//---------
+//Константы
+//---------
+
+//Статусы загрузки данных
+const LOAD_STATUS_IDLE = 'IDLE';
+const LOAD_STATUS_LOADING = 'LOADING';
+const LOAD_STATUS_DONE = 'DONE';
+const LOAD_STATUS_ERROR = 'ERROR';
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = {
+ LOAD_STATUS_IDLE,
+ LOAD_STATUS_LOADING,
+ LOAD_STATUS_DONE,
+ LOAD_STATUS_ERROR
+};
diff --git a/rn/app/src/config/menuConfig.js b/rn/app/src/config/menuConfig.js
new file mode 100644
index 0000000..4b040af
--- /dev/null
+++ b/rn/app/src/config/menuConfig.js
@@ -0,0 +1,33 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Конфигурация бокового меню
+*/
+
+//---------
+//Константы
+//---------
+
+//Ширина меню в процентах от экрана
+const MENU_WIDTH_PHONE_PERCENT = 70;
+const MENU_WIDTH_TABLET_PERCENT = 40;
+
+//Границы ширины меню (px)
+const MENU_MAX_WIDTH = 360;
+const MENU_MIN_WIDTH = 280;
+
+//Длительность анимации (мс)
+const ANIMATION_DURATION_OPEN = 250;
+const ANIMATION_DURATION_CLOSE = 200;
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = {
+ MENU_WIDTH_PHONE_PERCENT,
+ MENU_WIDTH_TABLET_PERCENT,
+ MENU_MAX_WIDTH,
+ MENU_MIN_WIDTH,
+ ANIMATION_DURATION_OPEN,
+ ANIMATION_DURATION_CLOSE
+};
diff --git a/rn/app/src/config/messages.js b/rn/app/src/config/messages.js
new file mode 100644
index 0000000..e3ac038
--- /dev/null
+++ b/rn/app/src/config/messages.js
@@ -0,0 +1,23 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Тексты сообщений приложения
+*/
+
+//---------
+//Константы
+//---------
+
+//Сообщение при потере связи с сервером и переходе в офлайн
+const CONNECTION_LOST_MESSAGE = 'Нет связи с сервером. Приложение переведено в режим офлайн.';
+
+//Заголовок сообщения при переходе в режим офлайн
+const OFFLINE_MODE_TITLE = 'Режим офлайн';
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = {
+ CONNECTION_LOST_MESSAGE,
+ OFFLINE_MODE_TITLE
+};
diff --git a/rn/app/src/config/messagingConfig.js b/rn/app/src/config/messagingConfig.js
new file mode 100644
index 0000000..bdd7031
--- /dev/null
+++ b/rn/app/src/config/messagingConfig.js
@@ -0,0 +1,31 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Константы типов сообщений и действий
+*/
+
+//---------
+//Константы
+//---------
+
+//Варианты отображения сообщения
+const APP_MESSAGE_VARIANT = {
+ INFO: 'INFO',
+ WARN: 'WARN',
+ ERR: 'ERR',
+ SUCCESS: 'SUCCESS'
+};
+
+//Типы действий редьюсера сообщений
+const MSG_AT = {
+ SHOW_MSG: 'SHOW_MSG',
+ HIDE_MSG: 'HIDE_MSG'
+};
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = {
+ APP_MESSAGE_VARIANT,
+ MSG_AT
+};
diff --git a/rn/app/src/config/responsiveConfig.js b/rn/app/src/config/responsiveConfig.js
new file mode 100644
index 0000000..6aeae9d
--- /dev/null
+++ b/rn/app/src/config/responsiveConfig.js
@@ -0,0 +1,23 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Базовые размеры для адаптивных утилит
+*/
+
+//---------
+//Константы
+//---------
+
+//Базовая ширина экрана (iPhone 11/12/13/14)
+const BASE_WIDTH = 375;
+
+//Базовая высота экрана
+const BASE_HEIGHT = 812;
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = {
+ BASE_WIDTH,
+ BASE_HEIGHT
+};
diff --git a/rn/app/src/config/routes.js b/rn/app/src/config/routes.js
new file mode 100644
index 0000000..4e0ff20
--- /dev/null
+++ b/rn/app/src/config/routes.js
@@ -0,0 +1,23 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Константы экранов навигации
+*/
+
+//---------
+//Константы
+//---------
+
+//Экраны приложения
+const SCREENS = {
+ MAIN: 'MAIN',
+ SETTINGS: 'SETTINGS',
+ AUTH: 'AUTH'
+};
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = {
+ SCREENS
+};
diff --git a/rn/app/src/config/storageKeys.js b/rn/app/src/config/storageKeys.js
new file mode 100644
index 0000000..d369cb4
--- /dev/null
+++ b/rn/app/src/config/storageKeys.js
@@ -0,0 +1,27 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Ключи и константы хранилища и шифрования
+*/
+
+//---------
+//Константы
+//---------
+
+//Ключ для хранения идентификатора устройства в localStorage (Web)
+const WEB_DEVICE_ID_KEY = 'pti_device_id';
+
+//Префикс для идентификации зашифрованных данных
+const ENCRYPTED_PREFIX = 'ENC:';
+
+//Длина генерируемого секретного ключа
+const SECRET_KEY_LENGTH = 64;
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = {
+ WEB_DEVICE_ID_KEY,
+ ENCRYPTED_PREFIX,
+ SECRET_KEY_LENGTH
+};
diff --git a/rn/app/src/config/theme.js b/rn/app/src/config/theme.js
index 911bdbe..e15bc1b 100644
--- a/rn/app/src/config/theme.js
+++ b/rn/app/src/config/theme.js
@@ -44,6 +44,7 @@ const APP_COLORS = {
borderSubtle: '#E2E8F0',
borderMedium: '#CBD5E1',
overlay: 'rgba(15, 23, 42, 0.5)',
+ shadow: '#000000',
//Семантические
info: '#3B82F6',
diff --git a/rn/app/src/database/SQLiteDatabase.js b/rn/app/src/database/SQLiteDatabase.js
index 5567df3..0e5e871 100644
--- a/rn/app/src/database/SQLiteDatabase.js
+++ b/rn/app/src/database/SQLiteDatabase.js
@@ -11,12 +11,7 @@ const { open } = require('react-native-quick-sqlite');
//Импорт утилиты для загрузки SQL файлов
const SQLFileLoader = require('./sql/SQLFileLoader');
-
-//---------
-//Константы
-//---------
-
-const DB_NAME = 'pretrip_inspections.db';
+const { DB_NAME } = require('../config/database'); //Имя базы данных
//-----------
//Тело модуля
@@ -72,6 +67,9 @@ class SQLiteDatabase {
await this.executeQuery(this.sqlQueries.CREATE_TABLE_INSPECTIONS);
console.log('Таблица inspections создана/проверена');
+ await this.executeQuery(this.sqlQueries.CREATE_TABLE_AUTH_SESSION);
+ console.log('Таблица auth_session создана/проверена');
+
await this.executeQuery(this.sqlQueries.CREATE_INDEX_INSPECTIONS_STATUS);
console.log('Индекс idx_inspections_status создан/проверен');
@@ -303,6 +301,75 @@ class SQLiteDatabase {
}
}
+ //Сохранение сессии авторизации
+ async setAuthSession(session) {
+ try {
+ const {
+ sessionId,
+ serverUrl,
+ userRn = null,
+ userCode = null,
+ userName = null,
+ companyRn = null,
+ companyName = null,
+ savePassword = false
+ } = session;
+
+ await this.executeQuery(this.sqlQueries.AUTH_SESSION_SET, [
+ sessionId,
+ serverUrl,
+ userRn,
+ userCode,
+ userName,
+ companyRn,
+ companyName,
+ savePassword ? 1 : 0
+ ]);
+ return true;
+ } catch (error) {
+ console.error('Ошибка сохранения сессии авторизации:', error);
+ throw error;
+ }
+ }
+
+ //Получение сессии авторизации
+ async getAuthSession() {
+ try {
+ const result = await this.executeQuery(this.sqlQueries.AUTH_SESSION_GET, []);
+
+ if (result.rows && result.rows.length > 0) {
+ const row = result.rows.item(0);
+ return {
+ sessionId: row.session_id,
+ serverUrl: row.server_url,
+ userRn: row.user_rn,
+ userCode: row.user_code,
+ userName: row.user_name,
+ companyRn: row.company_rn,
+ companyName: row.company_name,
+ savePassword: row.save_password === 1,
+ createdAt: row.created_at,
+ updatedAt: row.updated_at
+ };
+ }
+ return null;
+ } catch (error) {
+ console.error('Ошибка получения сессии авторизации:', error);
+ throw error;
+ }
+ }
+
+ //Очистка сессии авторизации
+ async clearAuthSession() {
+ try {
+ await this.executeQuery(this.sqlQueries.AUTH_SESSION_CLEAR, []);
+ return true;
+ } catch (error) {
+ console.error('Ошибка очистки сессии авторизации:', error);
+ throw error;
+ }
+ }
+
//Проверка существования таблицы
async checkTableExists(tableName) {
try {
diff --git a/rn/app/src/database/sql/SQLQueries.js b/rn/app/src/database/sql/SQLQueries.js
index 0cf5d8a..3a30a71 100644
--- a/rn/app/src/database/sql/SQLQueries.js
+++ b/rn/app/src/database/sql/SQLQueries.js
@@ -10,6 +10,7 @@
//Таблицы
const CREATE_TABLE_APP_SETTINGS = require('./settings/create_table_app_settings.sql');
const CREATE_TABLE_INSPECTIONS = require('./inspections/create_table_inspections.sql');
+const CREATE_TABLE_AUTH_SESSION = require('./auth/create_table_auth_session.sql');
//Индексы
const CREATE_INDEX_INSPECTIONS_STATUS = require('./inspections/create_index_inspections_status.sql');
@@ -31,6 +32,11 @@ const INSPECTIONS_DELETE = require('./inspections/delete_inspection.sql');
const INSPECTIONS_DELETE_ALL = require('./inspections/delete_all_inspections.sql');
const INSPECTIONS_COUNT = require('./inspections/count_inspections.sql');
+//Авторизация
+const AUTH_SESSION_SET = require('./auth/set_auth_session.sql');
+const AUTH_SESSION_GET = require('./auth/get_auth_session.sql');
+const AUTH_SESSION_CLEAR = require('./auth/clear_auth_session.sql');
+
//Утилиты
const UTILITY_CHECK_TABLE = require('./utility/check_table_exists.sql');
const UTILITY_DROP_TABLE = require('./utility/drop_table.sql');
@@ -44,6 +50,7 @@ const UTILITY_VACUUM = require('./utility/vacuum.sql');
const SQLQueries = {
CREATE_TABLE_APP_SETTINGS,
CREATE_TABLE_INSPECTIONS,
+ CREATE_TABLE_AUTH_SESSION,
CREATE_INDEX_INSPECTIONS_STATUS,
CREATE_INDEX_INSPECTIONS_CREATED,
SETTINGS_GET,
@@ -58,6 +65,9 @@ const SQLQueries = {
INSPECTIONS_DELETE,
INSPECTIONS_DELETE_ALL,
INSPECTIONS_COUNT,
+ AUTH_SESSION_SET,
+ AUTH_SESSION_GET,
+ AUTH_SESSION_CLEAR,
UTILITY_CHECK_TABLE,
UTILITY_DROP_TABLE,
UTILITY_VACUUM
diff --git a/rn/app/src/database/sql/auth/clear_auth_session.sql.js b/rn/app/src/database/sql/auth/clear_auth_session.sql.js
new file mode 100644
index 0000000..6057b71
--- /dev/null
+++ b/rn/app/src/database/sql/auth/clear_auth_session.sql.js
@@ -0,0 +1,19 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ SQL запрос: очистка сессии авторизации
+*/
+
+//-----------
+//Тело модуля
+//-----------
+
+const AUTH_SESSION_CLEAR = `
+-- Удаление сессии авторизации
+DELETE FROM auth_session WHERE id = 1;
+`;
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = AUTH_SESSION_CLEAR;
diff --git a/rn/app/src/database/sql/auth/create_table_auth_session.sql.js b/rn/app/src/database/sql/auth/create_table_auth_session.sql.js
new file mode 100644
index 0000000..3cc50f0
--- /dev/null
+++ b/rn/app/src/database/sql/auth/create_table_auth_session.sql.js
@@ -0,0 +1,31 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ SQL запрос: создание таблицы сессии авторизации
+*/
+
+//-----------
+//Тело модуля
+//-----------
+
+const CREATE_TABLE_AUTH_SESSION = `
+-- Таблица для хранения данных сессии авторизации
+CREATE TABLE IF NOT EXISTS auth_session (
+ id INTEGER PRIMARY KEY CHECK (id = 1),
+ session_id TEXT NOT NULL,
+ server_url TEXT NOT NULL,
+ user_rn TEXT,
+ user_code TEXT,
+ user_name TEXT,
+ company_rn TEXT,
+ company_name TEXT,
+ save_password INTEGER DEFAULT 0,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+`;
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = CREATE_TABLE_AUTH_SESSION;
diff --git a/rn/app/src/database/sql/auth/get_auth_session.sql.js b/rn/app/src/database/sql/auth/get_auth_session.sql.js
new file mode 100644
index 0000000..2bb90fd
--- /dev/null
+++ b/rn/app/src/database/sql/auth/get_auth_session.sql.js
@@ -0,0 +1,31 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ SQL запрос: получение сессии авторизации
+*/
+
+//-----------
+//Тело модуля
+//-----------
+
+const AUTH_SESSION_GET = `
+-- Получение сессии авторизации
+SELECT
+ session_id,
+ server_url,
+ user_rn,
+ user_code,
+ user_name,
+ company_rn,
+ company_name,
+ save_password,
+ created_at,
+ updated_at
+FROM auth_session
+WHERE id = 1;
+`;
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = AUTH_SESSION_GET;
diff --git a/rn/app/src/database/sql/auth/set_auth_session.sql.js b/rn/app/src/database/sql/auth/set_auth_session.sql.js
new file mode 100644
index 0000000..7caa11b
--- /dev/null
+++ b/rn/app/src/database/sql/auth/set_auth_session.sql.js
@@ -0,0 +1,31 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ SQL запрос: сохранение или обновление сессии авторизации
+*/
+
+//-----------
+//Тело модуля
+//-----------
+
+const AUTH_SESSION_SET = `
+-- Сохранение или обновление сессии авторизации
+INSERT OR REPLACE INTO auth_session (
+ id,
+ session_id,
+ server_url,
+ user_rn,
+ user_code,
+ user_name,
+ company_rn,
+ company_name,
+ save_password,
+ updated_at
+)
+VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP);
+`;
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = AUTH_SESSION_SET;
diff --git a/rn/app/src/database/sql/inspections/create_table_inspections.sql.js b/rn/app/src/database/sql/inspections/create_table_inspections.sql.js
index 84fa51c..125922a 100644
--- a/rn/app/src/database/sql/inspections/create_table_inspections.sql.js
+++ b/rn/app/src/database/sql/inspections/create_table_inspections.sql.js
@@ -23,4 +23,4 @@ CREATE TABLE IF NOT EXISTS inspections (
//Интерфейс модуля
//----------------
-module.exports = CREATE_TABLE_INSPECTIONS;
\ No newline at end of file
+module.exports = CREATE_TABLE_INSPECTIONS;
diff --git a/rn/app/src/hooks/useAppMessaging.js b/rn/app/src/hooks/useAppMessaging.js
index 850bfcd..10c97bf 100644
--- a/rn/app/src/hooks/useAppMessaging.js
+++ b/rn/app/src/hooks/useAppMessaging.js
@@ -8,7 +8,7 @@
//---------------------
const React = require('react'); //React и хуки
-const { APP_MESSAGE_VARIANT } = require('../components/common/AppMessage'); //Константы сообщений
+const { APP_MESSAGE_VARIANT, MSG_AT } = require('../config/messagingConfig'); //Типы сообщений и действий
//---------
//Константы
@@ -28,7 +28,7 @@ const INITIAL_STATE = {
headerStyle: null
};
-//Типы сообщений
+//Типы сообщений (алиасы для вариантов)
const MSG_TYPE = {
INFO: APP_MESSAGE_VARIANT.INFO,
WARN: APP_MESSAGE_VARIANT.WARN,
@@ -36,12 +36,6 @@ const MSG_TYPE = {
SUCCESS: APP_MESSAGE_VARIANT.SUCCESS
};
-//Типы действий
-const MSG_AT = {
- SHOW_MSG: 'SHOW_MSG',
- HIDE_MSG: 'HIDE_MSG'
-};
-
//-----------
//Тело модуля
//-----------
diff --git a/rn/app/src/hooks/useAppNavigation.js b/rn/app/src/hooks/useAppNavigation.js
index 9aac1b6..33177c6 100644
--- a/rn/app/src/hooks/useAppNavigation.js
+++ b/rn/app/src/hooks/useAppNavigation.js
@@ -8,16 +8,7 @@
//---------------------
const React = require('react'); //React и хуки
-
-//---------
-//Константы
-//---------
-
-//Экраны приложения
-const SCREENS = {
- MAIN: 'MAIN',
- SETTINGS: 'SETTINGS'
-};
+const { SCREENS } = require('../config/routes'); //Экраны навигации
//-----------
//Тело модуля
@@ -25,10 +16,11 @@ const SCREENS = {
//Хук навигации приложения
const useAppNavigation = () => {
+ //Начальный экран - AUTH (до определения статуса авторизации)
const [navigationState, setNavigationState] = React.useState({
- currentScreen: SCREENS.MAIN,
+ currentScreen: SCREENS.AUTH,
screenParams: {},
- history: [SCREENS.MAIN]
+ history: [SCREENS.AUTH]
});
//Навигация на экран
@@ -68,6 +60,15 @@ const useAppNavigation = () => {
});
}, []);
+ //Установка начального экрана (без добавления в историю)
+ const setInitialScreen = React.useCallback((screen, params = {}) => {
+ setNavigationState({
+ currentScreen: screen,
+ screenParams: params,
+ history: [screen]
+ });
+ }, []);
+
return {
SCREENS,
currentScreen: navigationState.currentScreen,
@@ -75,6 +76,7 @@ const useAppNavigation = () => {
navigate,
goBack,
reset,
+ setInitialScreen,
canGoBack: navigationState.history.length > 1
};
};
diff --git a/rn/app/src/hooks/useAuth.js b/rn/app/src/hooks/useAuth.js
new file mode 100644
index 0000000..81bd0dc
--- /dev/null
+++ b/rn/app/src/hooks/useAuth.js
@@ -0,0 +1,628 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Хук для управления авторизацией
+*/
+
+//---------------------
+//Подключение библиотек
+//---------------------
+
+const React = require('react');
+const { useAppLocalDbContext } = require('../components/layout/AppLocalDbProvider');
+const { AUTH_SETTINGS_KEYS, DEFAULT_IDLE_TIMEOUT } = require('../config/authConfig');
+const { ACTION_CODES, RESPONSE_STATES, ERROR_MESSAGES } = require('../config/authApi');
+const { generateSecretKey, encryptData, decryptData } = require('../utils/secureStorage');
+const { getPersistentDeviceId, isPersistentIdAvailable } = require('../utils/deviceId');
+
+//-----------
+//Тело модуля
+//-----------
+
+//Хук для управления авторизацией
+function useAuth() {
+ const { getSetting, setSetting, getAuthSession, setAuthSession, clearAuthSession, isDbReady } = useAppLocalDbContext();
+
+ //Состояние авторизации
+ const [session, setSession] = React.useState(null);
+ const [isAuthenticated, setIsAuthenticated] = React.useState(false);
+ const [isLoading, setIsLoading] = React.useState(false);
+ const [isInitialized, setIsInitialized] = React.useState(false);
+ const [error, setError] = React.useState(null);
+
+ //Ссылка для контроллера отмены запросов
+ const abortControllerRef = React.useRef(null);
+
+ //Флаг: сессия восстановлена из хранилища при открытии приложения
+ const sessionRestoredFromStorageRef = React.useRef(false);
+
+ //Загрузка или получение постоянного идентификатора устройства
+ const getDeviceId = React.useCallback(async () => {
+ //Сначала проверяем сохранённый идентификатор
+ let deviceId = await getSetting(AUTH_SETTINGS_KEYS.DEVICE_ID);
+
+ if (deviceId) {
+ //Проверяем доступность постоянного идентификатора
+ const persistentAvailable = await isPersistentIdAvailable();
+
+ if (persistentAvailable) {
+ //Получаем постоянный идентификатор для сравнения
+ const persistentId = await getPersistentDeviceId();
+
+ //Если постоянный ID отличается - обновляем на постоянный
+ if (persistentId && !deviceId.includes('-') && deviceId !== persistentId) {
+ deviceId = persistentId;
+ await setSetting(AUTH_SETTINGS_KEYS.DEVICE_ID, deviceId);
+ }
+ }
+
+ return deviceId;
+ }
+
+ //Генерируем новый идентификатор
+ deviceId = await getPersistentDeviceId();
+ await setSetting(AUTH_SETTINGS_KEYS.DEVICE_ID, deviceId);
+
+ return deviceId;
+ }, [getSetting, setSetting]);
+
+ //Загрузка или генерация уникального секретного ключа устройства
+ const getSecretKey = React.useCallback(async () => {
+ let secretKey = await getSetting(AUTH_SETTINGS_KEYS.DEVICE_SECRET_KEY);
+
+ if (!secretKey) {
+ secretKey = generateSecretKey();
+ await setSetting(AUTH_SETTINGS_KEYS.DEVICE_SECRET_KEY, secretKey);
+ }
+
+ return secretKey;
+ }, [getSetting, setSetting]);
+
+ //Выполнение запроса к серверу
+ const executeRequest = React.useCallback(async (serverUrl, payload) => {
+ //Отменяем предыдущий запрос если есть
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ }
+
+ const abortController = new AbortController();
+ abortControllerRef.current = abortController;
+
+ try {
+ const response = await fetch(serverUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(payload),
+ signal: abortController.signal
+ });
+
+ if (!response.ok) {
+ throw new Error(`${ERROR_MESSAGES.SERVER_ERROR}: ${response.status}`);
+ }
+
+ const data = await response.json();
+ return data;
+ } catch (fetchError) {
+ if (fetchError.name === 'AbortError') {
+ return null;
+ }
+
+ throw new Error(`${ERROR_MESSAGES.NETWORK_ERROR}: ${fetchError.message}`);
+ } finally {
+ abortControllerRef.current = null;
+ }
+ }, []);
+
+ //Сохранение credentials после успешной аутентификации
+ const saveCredentials = React.useCallback(
+ async (login, password, deviceId) => {
+ try {
+ //Получаем секретный ключ устройства
+ const secretKey = await getSecretKey();
+
+ //Шифруем пароль используя секретный ключ и deviceId как соль
+ const encryptedPassword = encryptData(password, secretKey, deviceId || '');
+
+ await setSetting(AUTH_SETTINGS_KEYS.SAVED_LOGIN, login);
+ await setSetting(AUTH_SETTINGS_KEYS.SAVED_PASSWORD, encryptedPassword);
+ await setSetting(AUTH_SETTINGS_KEYS.SAVE_PASSWORD_ENABLED, 'true');
+
+ return true;
+ } catch (saveError) {
+ console.error('Ошибка сохранения credentials:', saveError);
+ return false;
+ }
+ },
+ [setSetting, getSecretKey]
+ );
+
+ //Загрузка сохранённых credentials
+ const getSavedCredentials = React.useCallback(async () => {
+ try {
+ const savedLogin = await getSetting(AUTH_SETTINGS_KEYS.SAVED_LOGIN);
+
+ //Если нет сохранённого логина - возвращаем null
+ if (!savedLogin) {
+ return null;
+ }
+
+ const savePasswordEnabled = (await getSetting(AUTH_SETTINGS_KEYS.SAVE_PASSWORD_ENABLED)) === 'true';
+
+ //Если пароль не сохранялся - возвращаем только логин
+ if (!savePasswordEnabled) {
+ return {
+ login: savedLogin,
+ password: null,
+ savePasswordEnabled: false
+ };
+ }
+
+ const savedPassword = await getSetting(AUTH_SETTINGS_KEYS.SAVED_PASSWORD);
+
+ if (!savedPassword) {
+ return {
+ login: savedLogin,
+ password: null,
+ savePasswordEnabled: false
+ };
+ }
+
+ //Получаем deviceId и секретный ключ для расшифровки
+ const deviceId = await getDeviceId();
+ const secretKey = await getSecretKey();
+
+ //Расшифровываем пароль
+ const decryptedPassword = decryptData(savedPassword, secretKey, deviceId || '');
+
+ return {
+ login: savedLogin,
+ password: decryptedPassword,
+ savePasswordEnabled: true
+ };
+ } catch (loadError) {
+ console.error('Ошибка загрузки credentials:', loadError);
+ return null;
+ }
+ }, [getSetting, getDeviceId, getSecretKey]);
+
+ //Очистка сохранённых credentials
+ const clearSavedCredentials = React.useCallback(async () => {
+ try {
+ await setSetting(AUTH_SETTINGS_KEYS.SAVED_LOGIN, '');
+ await setSetting(AUTH_SETTINGS_KEYS.SAVED_PASSWORD, '');
+ await setSetting(AUTH_SETTINGS_KEYS.SAVE_PASSWORD_ENABLED, 'false');
+ return true;
+ } catch (clearError) {
+ console.error('Ошибка очистки credentials:', clearError);
+ return false;
+ }
+ }, [setSetting]);
+
+ //Вход в систему
+ const login = React.useCallback(
+ async ({ serverUrl, company, user, password, timeout, savePassword = false }) => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ //Получаем идентификатор устройства
+ const deviceId = await getDeviceId();
+
+ //Формируем запрос
+ const requestPayload = {
+ XREQUEST: {
+ XACTION: {
+ SCODE: ACTION_CODES.LOG_IN
+ },
+ XPAYLOAD: {
+ SUSER: user,
+ SPASSWORD: password,
+ STERMINAL: deviceId
+ }
+ }
+ };
+
+ //Добавляем организацию если указана
+ if (company) {
+ requestPayload.XREQUEST.XPAYLOAD.SCOMPANY = company;
+ }
+
+ //Добавляем таймаут (используем значение по умолчанию если не указан)
+ const timeoutValue = timeout && timeout > 0 ? timeout : DEFAULT_IDLE_TIMEOUT;
+ requestPayload.XREQUEST.XPAYLOAD.NTIMEOUT = timeoutValue;
+
+ //Выполняем запрос
+ const response = await executeRequest(serverUrl, requestPayload);
+
+ if (!response) {
+ return { success: false, error: ERROR_MESSAGES.NETWORK_ERROR };
+ }
+
+ //Проверяем ответ
+ if (!response.XRESPONSE) {
+ return { success: false, error: ERROR_MESSAGES.INVALID_RESPONSE };
+ }
+
+ const { SSTATE, XPAYLOAD } = response.XRESPONSE;
+
+ if (SSTATE !== RESPONSE_STATES.OK) {
+ return { success: false, error: XPAYLOAD?.SERROR || ERROR_MESSAGES.LOGIN_FAILED };
+ }
+
+ //Проверяем наличие списка организаций (требуется выбор)
+ if (XPAYLOAD.XCOMPANIES && Array.isArray(XPAYLOAD.XCOMPANIES) && XPAYLOAD.XCOMPANIES.length > 0 && !XPAYLOAD.XCOMPANY) {
+ //Преобразуем массив организаций
+ const organizations = XPAYLOAD.XCOMPANIES.map(item => item.XCOMPANY || item);
+
+ return {
+ success: true,
+ needSelectCompany: true,
+ sessionId: XPAYLOAD.SSESSION,
+ user: XPAYLOAD.XUSER,
+ organizations,
+ serverUrl,
+ savePassword,
+ //Передаём данные для сохранения credentials после выбора организации
+ loginCredentials: {
+ login: user,
+ password,
+ deviceId
+ }
+ };
+ }
+
+ //Сохраняем сессию
+ const sessionData = {
+ sessionId: XPAYLOAD.SSESSION,
+ serverUrl,
+ userRn: XPAYLOAD.XUSER?.NRN,
+ userCode: XPAYLOAD.XUSER?.SCODE,
+ userName: XPAYLOAD.XUSER?.SNAME,
+ companyRn: XPAYLOAD.XCOMPANY?.NRN,
+ companyName: XPAYLOAD.XCOMPANY?.SNAME,
+ savePassword
+ };
+
+ await setAuthSession(sessionData);
+ await setSetting(AUTH_SETTINGS_KEYS.SERVER_URL, serverUrl);
+ await setSetting(AUTH_SETTINGS_KEYS.SAVED_LOGIN, user);
+
+ if (savePassword) {
+ await saveCredentials(user, password, deviceId);
+ }
+
+ setSession(sessionData);
+ setIsAuthenticated(true);
+ sessionRestoredFromStorageRef.current = false;
+
+ return {
+ success: true,
+ needSelectCompany: false,
+ session: sessionData
+ };
+ } catch (loginError) {
+ const errorMessage = loginError.message || ERROR_MESSAGES.LOGIN_FAILED;
+ setError(errorMessage);
+ return { success: false, error: errorMessage };
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [executeRequest, getDeviceId, setAuthSession, setSetting, saveCredentials]
+ );
+
+ //Выбор организации после входа (SET_COMPANY)
+ const selectCompany = React.useCallback(
+ async ({ serverUrl, sessionId, user, company, savePassword, loginCredentials }) => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ //Формируем запрос SET_COMPANY
+ const requestPayload = {
+ XREQUEST: {
+ XACTION: {
+ SCODE: ACTION_CODES.SET_COMPANY
+ },
+ XPAYLOAD: {
+ SSESSION: sessionId,
+ NCOMPANY: company.NRN,
+ SCOMPANY: company.SNAME
+ }
+ }
+ };
+
+ //Выполняем запрос
+ const response = await executeRequest(serverUrl, requestPayload);
+
+ if (!response) {
+ return { success: false, error: ERROR_MESSAGES.NETWORK_ERROR };
+ }
+
+ //Проверяем ответ
+ if (!response.XRESPONSE) {
+ return { success: false, error: ERROR_MESSAGES.INVALID_RESPONSE };
+ }
+
+ const { SSTATE, XPAYLOAD } = response.XRESPONSE;
+
+ if (SSTATE !== RESPONSE_STATES.OK) {
+ return { success: false, error: XPAYLOAD?.SERROR || 'Ошибка установки организации' };
+ }
+
+ //Сохраняем сессию
+ const sessionData = {
+ sessionId,
+ serverUrl,
+ userRn: user?.NRN,
+ userCode: user?.SCODE,
+ userName: user?.SNAME,
+ companyRn: XPAYLOAD.XCOMPANY?.NRN || company.NRN,
+ companyName: XPAYLOAD.XCOMPANY?.SNAME || company.SNAME,
+ savePassword
+ };
+
+ await setAuthSession(sessionData);
+ await setSetting(AUTH_SETTINGS_KEYS.SERVER_URL, serverUrl);
+ const loginToSave = loginCredentials?.login || user?.SCODE || user?.SNAME || '';
+ if (loginToSave) {
+ await setSetting(AUTH_SETTINGS_KEYS.SAVED_LOGIN, loginToSave);
+ }
+
+ if (savePassword && loginCredentials) {
+ await saveCredentials(loginCredentials.login, loginCredentials.password, loginCredentials.deviceId);
+ }
+
+ setSession(sessionData);
+ setIsAuthenticated(true);
+ sessionRestoredFromStorageRef.current = false;
+
+ return {
+ success: true,
+ session: sessionData
+ };
+ } catch (selectError) {
+ const errorMessage = selectError.message || 'Ошибка установки организации';
+ setError(errorMessage);
+ return { success: false, error: errorMessage };
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [executeRequest, setAuthSession, setSetting, saveCredentials]
+ );
+
+ //Выход из системы
+ const logout = React.useCallback(
+ async (options = {}) => {
+ const { skipServerRequest = false } = options;
+
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const currentSession = session || (await getAuthSession());
+
+ //Запрос на сервер только при онлайн и если не отключено опцией
+ if (!skipServerRequest && currentSession?.sessionId && currentSession?.serverUrl) {
+ const requestPayload = {
+ XREQUEST: {
+ XACTION: {
+ SCODE: ACTION_CODES.LOG_OUT
+ },
+ XPAYLOAD: {
+ SSESSION: currentSession.sessionId
+ }
+ }
+ };
+
+ try {
+ await executeRequest(currentSession.serverUrl, requestPayload);
+ } catch (logoutError) {
+ console.warn('Ошибка при выходе из системы:', logoutError);
+ }
+ }
+
+ await clearAuthSession();
+ setSession(null);
+ setIsAuthenticated(false);
+
+ return { success: true };
+ } catch (logoutError) {
+ const errorMessage = logoutError.message || ERROR_MESSAGES.LOGOUT_FAILED;
+ setError(errorMessage);
+ return { success: false, error: errorMessage };
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [session, getAuthSession, executeRequest, clearAuthSession]
+ );
+
+ //Проверка сессии
+ const checkSession = React.useCallback(async () => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ //Получаем сохраненную сессию
+ const savedSession = await getAuthSession();
+
+ if (!savedSession?.sessionId || !savedSession?.serverUrl) {
+ setSession(null);
+ setIsAuthenticated(false);
+ return { success: false, isOffline: false, error: null };
+ }
+
+ //Формируем запрос
+ const requestPayload = {
+ XREQUEST: {
+ XACTION: {
+ SCODE: ACTION_CODES.CHECK_SESSION
+ },
+ XPAYLOAD: {
+ SSESSION: savedSession.sessionId
+ }
+ }
+ };
+
+ //Выполняем запрос
+ let response = null;
+ try {
+ response = await executeRequest(savedSession.serverUrl, requestPayload);
+ } catch (requestError) {
+ const isNetworkError = requestError.message && String(requestError.message).indexOf(ERROR_MESSAGES.NETWORK_ERROR) !== -1;
+ if (isNetworkError) {
+ setSession(savedSession);
+ setIsAuthenticated(true);
+ return { success: true, isOffline: true, session: savedSession };
+ }
+ throw requestError;
+ }
+
+ if (!response) {
+ setSession(savedSession);
+ setIsAuthenticated(true);
+ return { success: true, isOffline: true, session: savedSession };
+ }
+
+ //Проверяем ответ: если сервер ответил, но структура некорректна — не считаем оффлайн
+ if (!response.XRESPONSE) {
+ return { success: false, isOffline: false, error: ERROR_MESSAGES.INVALID_RESPONSE };
+ }
+
+ const { SSTATE, XPAYLOAD } = response.XRESPONSE;
+
+ if (SSTATE !== RESPONSE_STATES.OK) {
+ //Сессия недействительна - очищаем данные
+ await clearAuthSession();
+ setSession(null);
+ setIsAuthenticated(false);
+ return { success: false, isOffline: false, error: ERROR_MESSAGES.SESSION_EXPIRED };
+ }
+
+ //Проверяем необходимость выбора организации
+ if (XPAYLOAD.XCOMPANIES && !XPAYLOAD.XCOMPANY) {
+ const organizations = XPAYLOAD.XCOMPANIES.map(item => item.XCOMPANY || item);
+
+ return {
+ success: true,
+ isOffline: false,
+ needSelectCompany: true,
+ sessionId: XPAYLOAD.SSESSION || savedSession.sessionId,
+ user: XPAYLOAD.XUSER,
+ organizations,
+ serverUrl: savedSession.serverUrl,
+ savePassword: savedSession.savePassword
+ };
+ }
+
+ //Обновляем данные сессии
+ const updatedSession = {
+ sessionId: XPAYLOAD.SSESSION || savedSession.sessionId,
+ serverUrl: savedSession.serverUrl,
+ userRn: XPAYLOAD.XUSER?.NRN || savedSession.userRn,
+ userCode: XPAYLOAD.XUSER?.SCODE || savedSession.userCode,
+ userName: XPAYLOAD.XUSER?.SNAME || savedSession.userName,
+ companyRn: XPAYLOAD.XCOMPANY?.NRN || savedSession.companyRn,
+ companyName: XPAYLOAD.XCOMPANY?.SNAME || savedSession.companyName,
+ savePassword: savedSession.savePassword
+ };
+
+ await setAuthSession(updatedSession);
+ setSession(updatedSession);
+ setIsAuthenticated(true);
+
+ return { success: true, isOffline: false, session: updatedSession };
+ } catch (checkError) {
+ //Оффлайн только при ошибке сети; при прочих ошибках — не переключаем в оффлайн
+ const isNetworkError = checkError.message && String(checkError.message).indexOf(ERROR_MESSAGES.NETWORK_ERROR) !== -1;
+ const savedSession = await getAuthSession();
+
+ if (isNetworkError && savedSession?.sessionId) {
+ setSession(savedSession);
+ setIsAuthenticated(true);
+ return { success: true, isOffline: true, session: savedSession };
+ }
+
+ setSession(null);
+ setIsAuthenticated(false);
+ return { success: false, isOffline: false, error: checkError.message };
+ } finally {
+ setIsLoading(false);
+ }
+ }, [getAuthSession, executeRequest, clearAuthSession, setAuthSession]);
+
+ //Инициализация при готовности БД
+ React.useEffect(() => {
+ if (!isDbReady || isInitialized) {
+ return;
+ }
+
+ const initAuth = async () => {
+ try {
+ const savedSession = await getAuthSession();
+
+ if (savedSession?.sessionId) {
+ setSession(savedSession);
+ setIsAuthenticated(true);
+ sessionRestoredFromStorageRef.current = true;
+ }
+ } catch (initError) {
+ console.error('Ошибка инициализации авторизации:', initError);
+ } finally {
+ setIsInitialized(true);
+ }
+ };
+
+ initAuth();
+ }, [isDbReady, isInitialized, getAuthSession]);
+
+ //Получить и сбросить флаг «сессия восстановлена при открытии» (для проверки соединения только при холодном старте)
+ const getAndClearSessionRestoredFromStorage = React.useCallback(() => {
+ const value = sessionRestoredFromStorageRef.current;
+ sessionRestoredFromStorageRef.current = false;
+ return value;
+ }, []);
+
+ //Отмена запросов при размонтировании
+ React.useEffect(
+ () => () => {
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ }
+ },
+ []
+ );
+
+ return {
+ //Состояние
+ session,
+ isAuthenticated,
+ isLoading,
+ isInitialized,
+ error,
+
+ //Методы
+ login,
+ logout,
+ selectCompany,
+ checkSession,
+ getDeviceId,
+ getSavedCredentials,
+ clearSavedCredentials,
+ getAuthSession,
+ clearAuthSession,
+ getAndClearSessionRestoredFromStorage,
+
+ //Константы
+ AUTH_SETTINGS_KEYS
+ };
+}
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = useAuth;
diff --git a/rn/app/src/hooks/useHardwareBackPress.js b/rn/app/src/hooks/useHardwareBackPress.js
index 2a6da9d..dc07b2a 100644
--- a/rn/app/src/hooks/useHardwareBackPress.js
+++ b/rn/app/src/hooks/useHardwareBackPress.js
@@ -15,7 +15,15 @@ const { BackHandler, Platform } = require('react-native'); //BackHandler и пл
//-----------
//Хук обработки аппаратной кнопки "Назад"
-const useHardwareBackPress = (handler, deps = []) => {
+const useHardwareBackPress = handler => {
+ //Ref для хранения актуального обработчика
+ const handlerRef = React.useRef(handler);
+
+ //Обновление ref при изменении обработчика
+ React.useEffect(() => {
+ handlerRef.current = handler;
+ }, [handler]);
+
React.useEffect(() => {
//BackHandler работает только на Android
if (Platform.OS !== 'android') {
@@ -24,8 +32,8 @@ const useHardwareBackPress = (handler, deps = []) => {
//Обработчик нажатия кнопки "Назад"
const backHandler = () => {
- if (typeof handler === 'function') {
- return handler();
+ if (typeof handlerRef.current === 'function') {
+ return handlerRef.current();
}
return false;
};
@@ -37,8 +45,7 @@ const useHardwareBackPress = (handler, deps = []) => {
return () => {
subscription.remove();
};
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, deps);
+ }, []);
};
//----------------
diff --git a/rn/app/src/hooks/useLocalDb.js b/rn/app/src/hooks/useLocalDb.js
index 8d32e07..d56baba 100644
--- a/rn/app/src/hooks/useLocalDb.js
+++ b/rn/app/src/hooks/useLocalDb.js
@@ -250,6 +250,54 @@ function useLocalDb() {
[isDbReady]
);
+ //Сохранение сессии авторизации
+ const setAuthSession = React.useCallback(
+ async session => {
+ if (!isDbReady) {
+ console.warn('База данных не готова');
+ return false;
+ }
+
+ try {
+ return await SQLiteDatabase.setAuthSession(session);
+ } catch (setAuthSessionError) {
+ console.error('Ошибка сохранения сессии авторизации:', setAuthSessionError);
+ return false;
+ }
+ },
+ [isDbReady]
+ );
+
+ //Получение сессии авторизации
+ const getAuthSession = React.useCallback(async () => {
+ if (!isDbReady) {
+ console.warn('База данных не готова');
+ return null;
+ }
+
+ try {
+ return await SQLiteDatabase.getAuthSession();
+ } catch (getAuthSessionError) {
+ console.error('Ошибка получения сессии авторизации:', getAuthSessionError);
+ return null;
+ }
+ }, [isDbReady]);
+
+ //Очистка сессии авторизации
+ const clearAuthSession = React.useCallback(async () => {
+ if (!isDbReady) {
+ console.warn('База данных не готова');
+ return false;
+ }
+
+ try {
+ return await SQLiteDatabase.clearAuthSession();
+ } catch (clearAuthSessionError) {
+ console.error('Ошибка очистки сессии авторизации:', clearAuthSessionError);
+ return false;
+ }
+ }, [isDbReady]);
+
return {
isDbReady,
inspections,
@@ -263,7 +311,10 @@ function useLocalDb() {
clearSettings,
clearInspections,
vacuum,
- checkTableExists
+ checkTableExists,
+ setAuthSession,
+ getAuthSession,
+ clearAuthSession
};
}
diff --git a/rn/app/src/hooks/usePreTripInspections.js b/rn/app/src/hooks/usePreTripInspections.js
index e9111bd..54cdb53 100644
--- a/rn/app/src/hooks/usePreTripInspections.js
+++ b/rn/app/src/hooks/usePreTripInspections.js
@@ -15,17 +15,8 @@
const React = require('react'); //React и хуки
const useAppServer = require('./useAppServer'); //Хук для сервера приложений
const { useAppLocalDbContext } = require('../components/layout/AppLocalDbProvider'); //Контекст локальной БД
-const useAppMode = require('./useAppMode'); //Хук режима работы приложения
-
-//---------
-//Константы
-//---------
-
-//Статусы загрузки данных
-const LOAD_STATUS_IDLE = 'IDLE';
-const LOAD_STATUS_LOADING = 'LOADING';
-const LOAD_STATUS_DONE = 'DONE';
-const LOAD_STATUS_ERROR = 'ERROR';
+const { useAppModeContext } = require('../components/layout/AppModeProvider'); //Контекст режима работы
+const { LOAD_STATUS_IDLE, LOAD_STATUS_LOADING, LOAD_STATUS_DONE, LOAD_STATUS_ERROR } = require('../config/loadStatus'); //Статусы загрузки
//-----------
//Тело модуля
@@ -35,7 +26,7 @@ const LOAD_STATUS_ERROR = 'ERROR';
function usePreTripInspections() {
const { executeAction, isRespErr, getRespErrMessage, RESP_STATUS_OK } = useAppServer();
const { inspections, loadInspections, saveInspection, isDbReady } = useAppLocalDbContext();
- const { APP_MODE, mode } = useAppMode();
+ const { APP_MODE, mode } = useAppModeContext();
const [loadStatus, setLoadStatus] = React.useState(LOAD_STATUS_IDLE);
const [error, setError] = React.useState(null);
@@ -73,7 +64,7 @@ function usePreTripInspections() {
payload: {}
});
- //Ошибка сервера приложений - пробуем взять данные из локальной БД
+ //Ошибка запроса — используем локальные данные
if (isRespErr(serverResponse) || serverResponse.status !== RESP_STATUS_OK) {
const localInspections = await loadInspections();
setLoadStatus(localInspections.length > 0 ? LOAD_STATUS_DONE : LOAD_STATUS_ERROR);
@@ -110,7 +101,6 @@ function usePreTripInspections() {
await saveInspection(safeInspection);
//Если приложение в режиме ONLINE - отправляем данные на сервер приложений
- //TODO: вызов конкретного метода сервера
if (mode === APP_MODE.ONLINE) {
await executeAction({
path: 'api/pretrip/inspections/save',
@@ -138,10 +128,4 @@ function usePreTripInspections() {
//Интерфейс модуля
//----------------
-module.exports = {
- usePreTripInspections,
- LOAD_STATUS_IDLE,
- LOAD_STATUS_LOADING,
- LOAD_STATUS_DONE,
- LOAD_STATUS_ERROR
-};
+module.exports = usePreTripInspections;
diff --git a/rn/app/src/screens/AuthScreen.js b/rn/app/src/screens/AuthScreen.js
new file mode 100644
index 0000000..1440f91
--- /dev/null
+++ b/rn/app/src/screens/AuthScreen.js
@@ -0,0 +1,604 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Экран аутентификации
+*/
+
+//---------------------
+//Подключение библиотек
+//---------------------
+
+const React = require('react');
+const { ScrollView, View, KeyboardAvoidingView, Platform } = require('react-native');
+const AdaptiveView = require('../components/common/AdaptiveView');
+const AppText = require('../components/common/AppText');
+const AppInput = require('../components/common/AppInput');
+const AppButton = require('../components/common/AppButton');
+const AppSwitch = require('../components/common/AppSwitch');
+const PasswordInput = require('../components/common/PasswordInput');
+const AppHeader = require('../components/layout/AppHeader');
+const AppLogo = require('../components/common/AppLogo');
+const SideMenu = require('../components/menu/SideMenu');
+const LoadingOverlay = require('../components/common/LoadingOverlay');
+const OrganizationSelectDialog = require('../components/auth/OrganizationSelectDialog');
+const { useAppMessagingContext } = require('../components/layout/AppMessagingProvider');
+const { useAppModeContext } = require('../components/layout/AppModeProvider');
+const { useAppNavigationContext } = require('../components/layout/AppNavigationProvider');
+const { useAppLocalDbContext } = require('../components/layout/AppLocalDbProvider');
+const { useAppAuthContext } = require('../components/layout/AppAuthProvider');
+const { getAppInfo } = require('../utils/appInfo');
+const { isServerUrlFieldVisible } = require('../utils/loginFormUtils');
+const { normalizeServerUrl, validateServerUrl } = require('../utils/validation');
+const { AUTH_SETTINGS_KEYS } = require('../config/authConfig');
+const styles = require('../styles/screens/AuthScreen.styles');
+
+//-----------
+//Тело модуля
+//-----------
+
+//Экран аутентификации
+function AuthScreen() {
+ const { showError, showSuccess, showInfo } = useAppMessagingContext();
+ const { APP_MODE, mode, setOnline } = useAppModeContext();
+ const { navigate, goBack, canGoBack, reset, screenParams, SCREENS } = useAppNavigationContext();
+ const { getSetting, isDbReady, clearInspections } = useAppLocalDbContext();
+ const {
+ session,
+ login,
+ selectCompany,
+ isLoading,
+ getSavedCredentials,
+ getAuthSession,
+ clearAuthSession,
+ authFormData,
+ updateAuthFormData,
+ clearAuthFormData
+ } = useAppAuthContext();
+
+ //Состояние меню
+ const [menuVisible, setMenuVisible] = React.useState(false);
+
+ //Состояние формы
+ const [serverUrl, setServerUrl] = React.useState(authFormData.serverUrl);
+ const [username, setUsername] = React.useState(authFormData.username);
+ const [password, setPassword] = React.useState(authFormData.password);
+ const [savePassword, setSavePassword] = React.useState(authFormData.savePassword);
+ const [showPassword, setShowPassword] = React.useState(false);
+
+ //Состояние отображения
+ const [hideServerUrl, setHideServerUrl] = React.useState(false);
+ const [isSettingsLoaded, setIsSettingsLoaded] = React.useState(false);
+
+ //Флаг для предотвращения повторной загрузки
+ const initialLoadRef = React.useRef(false);
+
+ //Флаг однократной подстановки сохранённого логина при готовности БД
+ const savedLoginFilledRef = React.useRef(false);
+
+ //Состояние выбора организации
+ const [showOrgDialog, setShowOrgDialog] = React.useState(false);
+ const [organizations, setOrganizations] = React.useState([]);
+ const [pendingLoginData, setPendingLoginData] = React.useState(null);
+
+ //Определяем режим открытия экрана (из меню или при старте)
+ const isFromMenu = screenParams?.fromMenu === true;
+ const fromMenuKey = screenParams?.fromMenuKey;
+
+ //Ref для хранения актуальных значений формы (для синхронизации при размонтировании)
+ const formDataRef = React.useRef({ serverUrl, username, password, savePassword });
+
+ //Ref полей ввода для перехода фокуса по Enter
+ const serverInputRef = React.useRef(null);
+ const loginInputRef = React.useRef(null);
+ const passwordInputRef = React.useRef(null);
+
+ //Обновление ref при изменении значений формы
+ React.useEffect(() => {
+ formDataRef.current = { serverUrl, username, password, savePassword };
+ }, [serverUrl, username, password, savePassword]);
+
+ //Синхронизация данных формы с контекстом при размонтировании компонента
+ React.useEffect(() => {
+ return () => {
+ updateAuthFormData(formDataRef.current);
+ };
+ }, [updateAuthFormData]);
+
+ //При готовности БД один раз подставляем сохранённый логин в поле (если оно пустое)
+ React.useEffect(() => {
+ if (!isDbReady || savedLoginFilledRef.current) {
+ return;
+ }
+ savedLoginFilledRef.current = true;
+ let cancelled = false;
+ getSetting(AUTH_SETTINGS_KEYS.SAVED_LOGIN)
+ .then(val => {
+ if (!cancelled && val && typeof val === 'string' && val.trim()) {
+ setUsername(val.trim());
+ }
+ })
+ .catch(() => {});
+ return () => {
+ cancelled = true;
+ };
+ }, [isDbReady, getSetting]);
+
+ //Сброс формы и загрузка credentials при переходе по кнопке "Вход" из меню или при открытии экрана в оффлайн
+ //fromMenuKey в deps обеспечивает повторную загрузку при каждом новом переходе из меню
+ React.useEffect(() => {
+ const shouldLoadFromMenu = isFromMenu && isDbReady;
+ const shouldLoadOffline = mode === APP_MODE.OFFLINE && isDbReady;
+ if (!shouldLoadFromMenu && !shouldLoadOffline) {
+ return;
+ }
+
+ const loadCredentialsFromMenu = async () => {
+ try {
+ if (isFromMenu) {
+ clearAuthFormData();
+ }
+
+ //Загружаем сохранённый URL сервера
+ const savedServerUrl = await getSetting(AUTH_SETTINGS_KEYS.SERVER_URL);
+ const savedHideServerUrl = await getSetting(AUTH_SETTINGS_KEYS.HIDE_SERVER_URL);
+
+ if (savedServerUrl) {
+ setServerUrl(savedServerUrl);
+ }
+
+ setHideServerUrl(savedHideServerUrl === 'true' || savedHideServerUrl === true);
+
+ //Загружаем сохранённые credentials
+ const savedCredentials = await getSavedCredentials();
+
+ if (savedCredentials && savedCredentials.login) {
+ //Логин подставляем всегда
+ setUsername(savedCredentials.login);
+
+ //Пароль подставляем только если он был сохранён
+ if (savedCredentials.savePasswordEnabled && savedCredentials.password) {
+ setPassword(savedCredentials.password);
+ setSavePassword(true);
+ } else {
+ //Сбрасываем пароль
+ setPassword('');
+ setSavePassword(false);
+ }
+ } else {
+ //Нет сохранённых данных - сбрасываем форму
+ setUsername('');
+ setPassword('');
+ setSavePassword(false);
+ }
+
+ initialLoadRef.current = true;
+ } catch (loadError) {
+ console.error('Ошибка загрузки credentials из меню:', loadError);
+ setUsername('');
+ setPassword('');
+ setSavePassword(false);
+ } finally {
+ setIsSettingsLoaded(true);
+ }
+ };
+
+ loadCredentialsFromMenu();
+ }, [isFromMenu, isDbReady, fromMenuKey, mode, APP_MODE.OFFLINE, clearAuthFormData, getSetting, getSavedCredentials]);
+
+ //Резервная загрузка только логина (если поле пустое) при открытии в оффлайн или из меню
+ React.useEffect(() => {
+ if (!isDbReady || username !== '' || (!isFromMenu && mode !== APP_MODE.OFFLINE)) {
+ return;
+ }
+
+ const loadSavedLoginOnly = async () => {
+ try {
+ const savedLogin = await getSetting(AUTH_SETTINGS_KEYS.SAVED_LOGIN);
+ if (savedLogin && typeof savedLogin === 'string') {
+ setUsername(savedLogin.trim());
+ }
+ } catch (e) {
+ console.warn('Резервная загрузка логина:', e);
+ }
+ };
+
+ loadSavedLoginOnly();
+ }, [isDbReady, username, isFromMenu, mode, APP_MODE.OFFLINE, getSetting]);
+
+ //Загрузка настроек при готовности БД
+ //В т.ч. при работе в оффлайн подставляем сохранённый логин и при необходимости пароль
+ React.useEffect(() => {
+ //Пропускаем если БД не готова, уже загружали или открыто из меню
+ if (!isDbReady || initialLoadRef.current || isFromMenu) {
+ return;
+ }
+
+ initialLoadRef.current = true;
+
+ const loadSettings = async () => {
+ try {
+ const savedServerUrl = await getSetting(AUTH_SETTINGS_KEYS.SERVER_URL);
+ const savedHideServerUrl = await getSetting(AUTH_SETTINGS_KEYS.HIDE_SERVER_URL);
+
+ //Получаем текущие значения формы
+ const currentFormData = formDataRef.current;
+
+ if (savedServerUrl && !currentFormData.serverUrl) {
+ setServerUrl(savedServerUrl);
+ }
+
+ setHideServerUrl(savedHideServerUrl === 'true' || savedHideServerUrl === true);
+
+ //Загружаем сохранённые credentials (логин/пароль) для отображения в т.ч. в оффлайн
+ const savedCredentials = await getSavedCredentials();
+ if (savedCredentials && savedCredentials.login) {
+ setUsername(savedCredentials.login);
+ if (savedCredentials.savePasswordEnabled && savedCredentials.password) {
+ setPassword(savedCredentials.password);
+ setSavePassword(true);
+ } else {
+ setPassword('');
+ setSavePassword(false);
+ }
+ } else {
+ setUsername('');
+ setPassword('');
+ setSavePassword(false);
+ }
+ } catch (loadError) {
+ console.error('Ошибка загрузки настроек авторизации:', loadError);
+ } finally {
+ setIsSettingsLoaded(true);
+ }
+ };
+
+ loadSettings();
+ }, [isDbReady, isFromMenu, getSetting, getSavedCredentials]);
+
+ //Валидация формы
+ const validateForm = React.useCallback(() => {
+ if (!hideServerUrl || !serverUrl) {
+ const urlResult = validateServerUrl(serverUrl, { emptyMessage: 'Укажите адрес сервера' });
+ if (urlResult !== true) {
+ showError(urlResult);
+ return false;
+ }
+ }
+
+ if (!username.trim()) {
+ showError('Укажите логин');
+ return false;
+ }
+
+ if (!password.trim()) {
+ showError('Укажите пароль');
+ return false;
+ }
+
+ return true;
+ }, [hideServerUrl, serverUrl, username, password, showError]);
+
+ //Выполнение входа
+ const performLogin = React.useCallback(async () => {
+ let idleTimeout = null;
+ try {
+ idleTimeout = await getSetting(AUTH_SETTINGS_KEYS.IDLE_TIMEOUT);
+ } catch (settingError) {
+ console.warn('Ошибка получения таймаута:', settingError);
+ }
+
+ const result = await login({
+ serverUrl: serverUrl.trim(),
+ user: username.trim(),
+ password: password.trim(),
+ timeout: idleTimeout ? parseInt(idleTimeout, 10) : null,
+ savePassword
+ });
+
+ if (!result.success) {
+ showError(result.error || 'Ошибка входа');
+ return;
+ }
+
+ if (result.needSelectCompany) {
+ setOrganizations(result.organizations);
+ setPendingLoginData({
+ serverUrl: result.serverUrl,
+ sessionId: result.sessionId,
+ user: result.user,
+ savePassword: result.savePassword,
+ loginCredentials: result.loginCredentials
+ });
+ setShowOrgDialog(true);
+ return;
+ }
+
+ showSuccess('Вход выполнен успешно');
+ setOnline();
+ clearAuthFormData();
+ reset();
+ }, [login, serverUrl, username, password, savePassword, getSetting, showError, showSuccess, setOnline, clearAuthFormData, reset]);
+
+ //Обработчик входа: проверка смены параметров подключения, при необходимости — предупреждение и сброс локальных данных
+ const handleLogin = React.useCallback(async () => {
+ if (!validateForm()) {
+ return;
+ }
+
+ const currentNormalized = normalizeServerUrl(serverUrl);
+ const prevSession = await getAuthSession();
+ const savedServerUrl = await getSetting(AUTH_SETTINGS_KEYS.SERVER_URL).catch(() => '');
+ const prevServerUrl = prevSession?.serverUrl || savedServerUrl;
+ const prevNormalized = normalizeServerUrl(prevServerUrl);
+
+ const connectionParamsChanged = prevNormalized !== '' && currentNormalized !== prevNormalized;
+
+ if (connectionParamsChanged) {
+ showInfo('При смене параметров подключения все локальные данные будут сброшены. Продолжить?', {
+ title: 'Подтверждение входа',
+ buttons: [
+ {
+ id: 'cancel',
+ title: 'Отмена',
+ onPress: () => {}
+ },
+ {
+ id: 'confirm',
+ title: 'Продолжить',
+ onPress: async () => {
+ await clearAuthSession();
+ await clearInspections();
+ await performLogin();
+ }
+ }
+ ]
+ });
+ } else {
+ await performLogin();
+ }
+ }, [validateForm, serverUrl, getAuthSession, getSetting, showInfo, clearAuthSession, clearInspections, performLogin]);
+
+ //Обработчик выбора организации
+ const handleSelectOrganization = React.useCallback(
+ async org => {
+ setShowOrgDialog(false);
+
+ if (!pendingLoginData) {
+ return;
+ }
+
+ const result = await selectCompany({
+ serverUrl: pendingLoginData.serverUrl,
+ sessionId: pendingLoginData.sessionId,
+ user: pendingLoginData.user,
+ company: org,
+ savePassword: pendingLoginData.savePassword,
+ loginCredentials: pendingLoginData.loginCredentials
+ });
+
+ if (!result.success) {
+ showError(result.error || 'Ошибка выбора организации');
+ return;
+ }
+
+ //Успешный вход
+ showSuccess('Вход выполнен успешно');
+ setOnline();
+
+ //Очищаем временные данные
+ setPendingLoginData(null);
+ setOrganizations([]);
+
+ //Очищаем данные формы в контексте
+ clearAuthFormData();
+
+ //Сбрасываем навигацию
+ reset();
+ },
+ [pendingLoginData, selectCompany, showError, showSuccess, setOnline, clearAuthFormData, reset]
+ );
+
+ //Обработчик отмены выбора организации
+ const handleCancelOrganization = React.useCallback(() => {
+ setShowOrgDialog(false);
+ setPendingLoginData(null);
+ setOrganizations([]);
+ }, []);
+
+ //Обработчик кнопки назад
+ const handleBackPress = React.useCallback(() => {
+ if (canGoBack) {
+ goBack();
+ }
+ }, [canGoBack, goBack]);
+
+ //Обработчик переключения показа пароля
+ const handleTogglePassword = React.useCallback(value => {
+ setShowPassword(value);
+ }, []);
+
+ //Обработчик переключения сохранения пароля
+ const handleToggleSavePassword = React.useCallback(value => {
+ setSavePassword(value);
+ }, []);
+
+ //При Enter в поле «Сервер» — фокус на логин
+ const handleServerSubmitEditing = React.useCallback(() => {
+ if (loginInputRef.current) {
+ loginInputRef.current.focus();
+ }
+ }, []);
+
+ //При Enter в поле «Логин» — фокус на пароль
+ const handleLoginSubmitEditing = React.useCallback(() => {
+ if (passwordInputRef.current) {
+ passwordInputRef.current.focus();
+ }
+ }, []);
+
+ //При Enter в поле «Пароль» — нажатие кнопки «Войти»
+ const handlePasswordSubmitEditing = React.useCallback(() => {
+ handleLogin();
+ }, [handleLogin]);
+
+ //Обработчик открытия меню
+ const handleMenuOpen = React.useCallback(() => {
+ setMenuVisible(true);
+ }, []);
+
+ //Обработчик закрытия меню
+ const handleMenuClose = React.useCallback(() => {
+ setMenuVisible(false);
+ }, []);
+
+ //Обработчик перехода в настройки
+ const handleOpenSettings = React.useCallback(() => {
+ navigate(SCREENS.SETTINGS);
+ }, [navigate, SCREENS.SETTINGS]);
+
+ //Обработчик показа информации о приложении
+ const handleShowAbout = React.useCallback(() => {
+ const appInfo = getAppInfo({
+ mode,
+ serverUrl,
+ isDbReady
+ });
+
+ showInfo(appInfo, {
+ title: 'Информация о приложении'
+ });
+ }, [showInfo, mode, serverUrl, isDbReady]);
+
+ //Пункты бокового меню
+ const menuItems = React.useMemo(() => {
+ return [
+ {
+ id: 'settings',
+ title: 'Настройки',
+ onPress: handleOpenSettings
+ },
+ {
+ id: 'about',
+ title: 'О приложении',
+ onPress: handleShowAbout
+ }
+ ];
+ }, [handleOpenSettings, handleShowAbout]);
+
+ //Поле сервера показываем, если настройка выключена или сервер не указан
+ const shouldShowServerUrl = isServerUrlFieldVisible(hideServerUrl, serverUrl);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ Вход в приложение
+
+
+ {shouldShowServerUrl ? (
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = AuthScreen;
diff --git a/rn/app/src/screens/MainScreen.js b/rn/app/src/screens/MainScreen.js
index 1a22e8f..472f040 100644
--- a/rn/app/src/screens/MainScreen.js
+++ b/rn/app/src/screens/MainScreen.js
@@ -9,16 +9,22 @@
const React = require('react'); //React и хуки
const { View } = require('react-native'); //Базовые компоненты
-const { LOAD_STATUS_LOADING } = require('../hooks/usePreTripInspections'); //Константы загрузки
+const { LOAD_STATUS_LOADING } = require('../config/loadStatus'); //Статусы загрузки
const { useAppMessagingContext } = require('../components/layout/AppMessagingProvider'); //Контекст сообщений
const { useAppModeContext } = require('../components/layout/AppModeProvider'); //Контекст режима работы
const { useAppNavigationContext } = require('../components/layout/AppNavigationProvider'); //Контекст навигации
const { useAppLocalDbContext } = require('../components/layout/AppLocalDbProvider'); //Контекст локальной БД
const { useAppPreTripInspectionsContext } = require('../components/layout/AppPreTripInspectionsProvider'); //Контекст осмотров
+const { useAppAuthContext } = require('../components/layout/AppAuthProvider'); //Контекст авторизации
const AppHeader = require('../components/layout/AppHeader'); //Заголовок с меню
const SideMenu = require('../components/menu/SideMenu'); //Боковое меню
const InspectionList = require('../components/inspections/InspectionList'); //Список осмотров
+const LoadingOverlay = require('../components/common/LoadingOverlay'); //Оверлей загрузки
+const OrganizationSelectDialog = require('../components/auth/OrganizationSelectDialog'); //Диалог выбора организации
const { getAppInfo } = require('../utils/appInfo'); //Информация о приложении
+const { CONNECTION_LOST_MESSAGE, OFFLINE_MODE_TITLE } = require('../config/messages'); //Сообщения
+const { DIALOG_BUTTON_TYPE, DIALOG_CANCEL_BUTTON, getConfirmButtonOptions } = require('../config/dialogButtons'); //Кнопки диалогов
+const { APP_COLORS } = require('../config/theme'); //Цветовая схема
const styles = require('../styles/screens/MainScreen.styles'); //Стили экрана
//-----------
@@ -28,14 +34,31 @@ const styles = require('../styles/screens/MainScreen.styles'); //Стили эк
//Главный экран приложения
function MainScreen() {
const { inspections, loadStatus, error, isDbReady, refreshInspections } = useAppPreTripInspectionsContext();
- const { showInfo } = useAppMessagingContext();
- const { mode } = useAppModeContext();
- const { navigate, SCREENS } = useAppNavigationContext();
+ const { showInfo, showError, showSuccess } = useAppMessagingContext();
+ const { mode, setOnline, setOffline, setNotConnected } = useAppModeContext();
+ const { navigate, SCREENS, setInitialScreen } = useAppNavigationContext();
const { getSetting, isDbReady: isLocalDbReady } = useAppLocalDbContext();
+ const {
+ session,
+ isAuthenticated,
+ isInitialized,
+ logout,
+ checkSession,
+ selectCompany,
+ isLoading: isAuthLoading,
+ sessionChecked,
+ markSessionChecked,
+ getAndClearSessionRestoredFromStorage
+ } = useAppAuthContext();
const [menuVisible, setMenuVisible] = React.useState(false);
const [serverUrl, setServerUrl] = React.useState('');
+ //Состояние для диалога выбора организации при проверке сессии
+ const [showOrgDialog, setShowOrgDialog] = React.useState(false);
+ const [organizations, setOrganizations] = React.useState([]);
+ const [pendingSessionData, setPendingSessionData] = React.useState(null);
+
//Предотвращение повторной загрузки при монтировании
const initialLoadRef = React.useRef(false);
@@ -59,6 +82,65 @@ function MainScreen() {
loadServerUrl();
}, [isLocalDbReady, getSetting]);
+ //Проверка соединения только при открытии приложения и только если пользователь был авторизован и не выходил
+ React.useEffect(() => {
+ if (!isInitialized || sessionChecked) {
+ return;
+ }
+ if (!getAndClearSessionRestoredFromStorage()) {
+ return;
+ }
+
+ markSessionChecked();
+
+ const verifySession = async () => {
+ if (!isAuthenticated) {
+ return;
+ }
+
+ const result = await checkSession();
+
+ if (!result.success) {
+ //Сессия недействительна
+ showInfo('Сессия истекла. Выполните повторный вход.');
+ return;
+ }
+
+ //Проверяем необходимость выбора организации
+ if (result.needSelectCompany) {
+ setOrganizations(result.organizations);
+ setPendingSessionData({
+ serverUrl: result.serverUrl,
+ sessionId: result.sessionId,
+ user: result.user,
+ savePassword: result.savePassword
+ });
+ setShowOrgDialog(true);
+ return;
+ }
+
+ //Устанавливаем режим работы на основе проверки сервера
+ if (result.isOffline) {
+ setOffline();
+ showInfo(CONNECTION_LOST_MESSAGE, { title: OFFLINE_MODE_TITLE });
+ } else {
+ setOnline();
+ }
+ };
+
+ verifySession();
+ }, [
+ isInitialized,
+ isAuthenticated,
+ sessionChecked,
+ markSessionChecked,
+ getAndClearSessionRestoredFromStorage,
+ checkSession,
+ setOnline,
+ setOffline,
+ showInfo
+ ]);
+
//Первичная загрузка данных
React.useEffect(() => {
//Выходим, если БД не готова или уже загружали
@@ -69,6 +151,44 @@ function MainScreen() {
refreshInspections();
}, [isDbReady, refreshInspections]);
+ //Обработчик выбора организации при проверке сессии
+ const handleSelectOrganization = React.useCallback(
+ async org => {
+ setShowOrgDialog(false);
+
+ if (!pendingSessionData) {
+ return;
+ }
+
+ const result = await selectCompany({
+ serverUrl: pendingSessionData.serverUrl,
+ sessionId: pendingSessionData.sessionId,
+ user: pendingSessionData.user,
+ company: org,
+ savePassword: pendingSessionData.savePassword
+ });
+
+ if (!result.success) {
+ showError(result.error || 'Ошибка выбора организации');
+ return;
+ }
+
+ setOnline();
+ setPendingSessionData(null);
+ setOrganizations([]);
+ },
+ [pendingSessionData, selectCompany, showError, setOnline]
+ );
+
+ //Обработчик отмены выбора организации
+ const handleCancelOrganization = React.useCallback(() => {
+ setShowOrgDialog(false);
+ setPendingSessionData(null);
+ setOrganizations([]);
+ //При отмене остаемся в оффлайн режиме
+ setOffline();
+ }, [setOffline]);
+
//Обработчик открытия меню
const handleMenuOpen = React.useCallback(() => {
setMenuVisible(true);
@@ -92,24 +212,81 @@ function MainScreen() {
});
}, [showInfo, mode, serverUrl, isLocalDbReady]);
+ //Обработчик перехода на экран входа
+ const handleLogin = React.useCallback(() => {
+ navigate(SCREENS.AUTH, { fromMenu: true, fromMenuKey: Date.now() });
+ }, [navigate, SCREENS.AUTH]);
+
+ //Обработчик перехода в настройки
+ const handleOpenSettings = React.useCallback(() => {
+ navigate(SCREENS.SETTINGS);
+ }, [navigate, SCREENS.SETTINGS]);
+
+ //Обработчик подтверждения выхода (для диалога)
+ const performLogout = React.useCallback(async () => {
+ const result = await logout({ skipServerRequest: mode === 'OFFLINE' });
+
+ if (result.success) {
+ showSuccess('Выход выполнен');
+ setNotConnected();
+ setInitialScreen(SCREENS.AUTH);
+ } else {
+ showError(result.error || 'Ошибка выхода');
+ }
+ }, [logout, mode, showSuccess, showError, setNotConnected, setInitialScreen, SCREENS.AUTH]);
+
+ //Обработчик выхода из приложения
+ const handleLogout = React.useCallback(() => {
+ const confirmButton = getConfirmButtonOptions(DIALOG_BUTTON_TYPE.ERROR, 'Выйти', performLogout);
+
+ showInfo('Вы уверены, что хотите выйти?', {
+ title: 'Подтверждение выхода',
+ buttons: [DIALOG_CANCEL_BUTTON, confirmButton]
+ });
+ }, [showInfo, performLogout]);
+
//Пункты бокового меню
- const menuItems = React.useMemo(
- () => [
+ const menuItems = React.useMemo(() => {
+ const items = [
{
id: 'settings',
title: 'Настройки',
- onPress: () => {
- navigate(SCREENS.SETTINGS);
- }
+ onPress: handleOpenSettings
},
{
id: 'about',
title: 'О приложении',
onPress: handleShowAbout
}
- ],
- [navigate, handleShowAbout, SCREENS.SETTINGS]
- );
+ ];
+
+ //Добавляем разделитель перед кнопками авторизации
+ items.push({
+ id: 'divider',
+ type: 'divider'
+ });
+
+ //Кнопка "Вход" для оффлайн режима
+ if (mode === 'OFFLINE') {
+ items.push({
+ id: 'login',
+ title: 'Вход',
+ onPress: handleLogin
+ });
+ }
+
+ //Кнопка "Выход" для онлайн/оффлайн режима (когда есть сессия)
+ if ((mode === 'ONLINE' || mode === 'OFFLINE') && isAuthenticated) {
+ items.push({
+ id: 'logout',
+ title: 'Выход',
+ onPress: handleLogout,
+ textStyle: { color: APP_COLORS.error }
+ });
+ }
+
+ return items;
+ }, [handleOpenSettings, handleShowAbout, handleLogin, handleLogout, mode, isAuthenticated]);
return (
@@ -124,7 +301,25 @@ function MainScreen() {
/>
-
+
+
+
+
+
);
}
diff --git a/rn/app/src/screens/SettingsScreen.js b/rn/app/src/screens/SettingsScreen.js
index b53a9b9..1442b3f 100644
--- a/rn/app/src/screens/SettingsScreen.js
+++ b/rn/app/src/screens/SettingsScreen.js
@@ -12,6 +12,7 @@ const { ScrollView, View, Pressable } = require('react-native');
const AdaptiveView = require('../components/common/AdaptiveView');
const AppText = require('../components/common/AppText');
const AppButton = require('../components/common/AppButton');
+const AppSwitch = require('../components/common/AppSwitch');
const CopyButton = require('../components/common/CopyButton');
const InputDialog = require('../components/common/InputDialog');
const AppHeader = require('../components/layout/AppHeader');
@@ -19,8 +20,11 @@ const { useAppMessagingContext } = require('../components/layout/AppMessagingPro
const { useAppModeContext } = require('../components/layout/AppModeProvider');
const { useAppNavigationContext } = require('../components/layout/AppNavigationProvider');
const { useAppLocalDbContext } = require('../components/layout/AppLocalDbProvider');
-const { APP_COLORS } = require('../config/theme');
+const { useAppAuthContext } = require('../components/layout/AppAuthProvider');
+const { AUTH_SETTINGS_KEYS, DEFAULT_IDLE_TIMEOUT } = require('../config/authConfig');
+const { DIALOG_BUTTON_TYPE, DIALOG_CANCEL_BUTTON, getConfirmButtonOptions } = require('../config/dialogButtons');
const { getAppInfo, getModeLabel } = require('../utils/appInfo');
+const { validateServerUrlAllowEmpty, validateIdleTimeout } = require('../utils/validation');
const styles = require('../styles/screens/SettingsScreen.styles');
//-----------
@@ -29,18 +33,23 @@ const styles = require('../styles/screens/SettingsScreen.styles');
function SettingsScreen() {
const { showInfo, showError, showSuccess } = useAppMessagingContext();
- const { mode, setOnline, setNotConnected } = useAppModeContext();
+ const { APP_MODE, mode, setNotConnected } = useAppModeContext();
const { goBack, canGoBack } = useAppNavigationContext();
const { getSetting, setSetting, clearSettings, clearInspections, vacuum, isDbReady } = useAppLocalDbContext();
+ const { session, isAuthenticated, getDeviceId } = useAppAuthContext();
const [serverUrl, setServerUrl] = React.useState('');
+ const [hideServerUrl, setHideServerUrl] = React.useState(false);
+ const [idleTimeout, setIdleTimeout] = React.useState('');
+ const [deviceId, setDeviceId] = React.useState('');
const [isLoading, setIsLoading] = React.useState(false);
const [isServerUrlDialogVisible, setIsServerUrlDialogVisible] = React.useState(false);
+ const [isIdleTimeoutDialogVisible, setIsIdleTimeoutDialogVisible] = React.useState(false);
//Предотвращение повторной загрузки настроек
const settingsLoadedRef = React.useRef(false);
- //Загрузка сохраненного URL сервера при готовности БД
+ //Загрузка сохраненных настроек при готовности БД
React.useEffect(() => {
//Выходим, если БД не готова или уже загрузили настройки
if (!isDbReady || settingsLoadedRef.current) {
@@ -52,10 +61,27 @@ function SettingsScreen() {
const loadSettings = async () => {
setIsLoading(true);
try {
- const savedUrl = await getSetting('app_server_url');
+ const savedUrl = await getSetting(AUTH_SETTINGS_KEYS.SERVER_URL);
if (savedUrl) {
setServerUrl(savedUrl);
}
+
+ const savedHideServerUrl = await getSetting(AUTH_SETTINGS_KEYS.HIDE_SERVER_URL);
+ setHideServerUrl(savedHideServerUrl === 'true' || savedHideServerUrl === true);
+
+ const savedIdleTimeout = await getSetting(AUTH_SETTINGS_KEYS.IDLE_TIMEOUT);
+ if (savedIdleTimeout) {
+ setIdleTimeout(savedIdleTimeout);
+ } else {
+ //Устанавливаем значение по умолчанию
+ const defaultValue = String(DEFAULT_IDLE_TIMEOUT);
+ setIdleTimeout(defaultValue);
+ await setSetting(AUTH_SETTINGS_KEYS.IDLE_TIMEOUT, defaultValue);
+ }
+
+ //Получаем или генерируем идентификатор устройства
+ const currentDeviceId = await getDeviceId();
+ setDeviceId(currentDeviceId || '');
} catch (error) {
console.error('Ошибка загрузки настроек:', error);
showError('Не удалось загрузить настройки');
@@ -65,52 +91,56 @@ function SettingsScreen() {
};
loadSettings();
- }, [isDbReady, getSetting, showError]);
+ }, [isDbReady, getSetting, setSetting, showError, getDeviceId]);
- //Открытие диалога ввода URL сервера
+ //Доступность редактирования URL сервера (только в режиме "Не подключено")
+ const isServerUrlEditable = React.useMemo(
+ () => !isLoading && isDbReady && mode === APP_MODE.NOT_CONNECTED,
+ [isLoading, isDbReady, mode, APP_MODE.NOT_CONNECTED]
+ );
+
+ //Стиль поля URL сервера при нажатии
+ const getServerUrlFieldPressableStyle = React.useCallback(
+ ({ pressed }) => [
+ styles.serverUrlField,
+ isServerUrlEditable ? (pressed ? styles.serverUrlFieldPressed : null) : styles.serverUrlFieldDisabled
+ ],
+ [isServerUrlEditable]
+ );
+
+ //Стиль поля времени простоя при нажатии
+ const getIdleTimeoutFieldPressableStyle = React.useCallback(
+ ({ pressed }) => [styles.serverUrlField, pressed && styles.serverUrlFieldPressed],
+ []
+ );
+
+ //Открытие диалога ввода URL сервера (только в режиме "Не подключено")
const handleOpenServerUrlDialog = React.useCallback(() => {
+ if (!isServerUrlEditable) {
+ return;
+ }
setIsServerUrlDialogVisible(true);
- }, []);
+ }, [isServerUrlEditable]);
//Закрытие диалога ввода URL сервера
const handleCloseServerUrlDialog = React.useCallback(() => {
setIsServerUrlDialogVisible(false);
}, []);
- //Валидатор URL сервера
- const validateServerUrl = React.useCallback(url => {
- if (!url || !url.trim()) {
- return 'Введите адрес сервера';
- }
-
- try {
- const parsedUrl = new URL(url.trim());
- if (!parsedUrl.protocol.startsWith('http')) {
- return 'Используйте http:// или https:// протокол';
- }
- } catch (error) {
- return 'Некорректный формат URL';
- }
-
- return true;
- }, []);
-
//Сохранение настроек сервера
const handleSaveServerUrl = React.useCallback(
async url => {
setIsServerUrlDialogVisible(false);
setIsLoading(true);
+ const valueToSave = url != null ? String(url).trim() : '';
+
try {
- const success = await setSetting('app_server_url', url);
+ const success = await setSetting(AUTH_SETTINGS_KEYS.SERVER_URL, valueToSave);
if (success) {
- setServerUrl(url);
+ setServerUrl(valueToSave);
showSuccess('Настройки сервера сохранены');
-
- if (mode === 'NOT_CONNECTED') {
- setOnline();
- }
} else {
showError('Не удалось сохранить настройки');
}
@@ -121,76 +151,140 @@ function SettingsScreen() {
setIsLoading(false);
},
- [mode, setOnline, setSetting, showError, showSuccess]
+ [setSetting, showError, showSuccess]
);
+ //Переключатель скрытия URL сервера в окне логина
+ const handleToggleHideServerUrl = React.useCallback(
+ async value => {
+ try {
+ const success = await setSetting(AUTH_SETTINGS_KEYS.HIDE_SERVER_URL, value ? 'true' : 'false');
+
+ if (success) {
+ setHideServerUrl(value);
+ showSuccess('Настройка сохранена');
+ } else {
+ showError('Не удалось сохранить настройку');
+ }
+ } catch (error) {
+ console.error('Ошибка сохранения настройки:', error);
+ showError('Не удалось сохранить настройку');
+ }
+ },
+ [setSetting, showSuccess, showError]
+ );
+
+ //Открытие диалога ввода времени простоя
+ const handleOpenIdleTimeoutDialog = React.useCallback(() => {
+ setIsIdleTimeoutDialogVisible(true);
+ }, []);
+
+ //Закрытие диалога ввода времени простоя
+ const handleCloseIdleTimeoutDialog = React.useCallback(() => {
+ setIsIdleTimeoutDialogVisible(false);
+ }, []);
+
+ //Сохранение времени простоя
+ const handleSaveIdleTimeout = React.useCallback(
+ async value => {
+ setIsIdleTimeoutDialogVisible(false);
+ setIsLoading(true);
+
+ try {
+ const trimmedValue = value ? value.trim() : '';
+ const success = await setSetting(AUTH_SETTINGS_KEYS.IDLE_TIMEOUT, trimmedValue);
+
+ if (success) {
+ setIdleTimeout(trimmedValue);
+ showSuccess('Время простоя сохранено');
+ } else {
+ showError('Не удалось сохранить настройку');
+ }
+ } catch (error) {
+ console.error('Ошибка сохранения времени простоя:', error);
+ showError('Не удалось сохранить настройку');
+ }
+
+ setIsLoading(false);
+ },
+ [setSetting, showSuccess, showError]
+ );
+
+ //Выполнение очистки кэша (для диалога подтверждения)
+ const performClearCache = React.useCallback(async () => {
+ try {
+ const success = await clearInspections();
+ if (success) {
+ showSuccess('Кэш успешно очищен');
+ } else {
+ showError('Не удалось очистить кэш');
+ }
+ } catch (error) {
+ console.error('Ошибка очистки кэша:', error);
+ showError('Не удалось очистить кэш');
+ }
+ }, [showSuccess, showError, clearInspections]);
+
//Очистка кэша (осмотров)
- const handleClearCache = React.useCallback(async () => {
+ const handleClearCache = React.useCallback(() => {
+ const confirmButton = getConfirmButtonOptions(DIALOG_BUTTON_TYPE.ERROR, 'Очистить', performClearCache);
+
showInfo('Очистить кэш приложения?', {
title: 'Подтверждение',
- buttons: [
- {
- id: 'cancel',
- title: 'Отмена',
- onPress: () => { }
- },
- {
- id: 'confirm',
- title: 'Очистить',
- onPress: async () => {
- try {
- const success = await clearInspections();
- if (success) {
- showSuccess('Кэш успешно очищен');
- } else {
- showError('Не удалось очистить кэш');
- }
- } catch (error) {
- console.error('Ошибка очистки кэша:', error);
- showError('Не удалось очистить кэш');
- }
- },
- buttonStyle: { backgroundColor: APP_COLORS.error },
- textStyle: { color: APP_COLORS.white }
- }
- ]
+ buttons: [DIALOG_CANCEL_BUTTON, confirmButton]
});
- }, [showInfo, showSuccess, showError, clearInspections]);
+ }, [showInfo, performClearCache]);
+
+ //Выполнение сброса настроек (для диалога подтверждения)
+ //Подключён (онлайн/офлайн): сбрасываем только непричастные к подключению настройки; не подключён: полный сброс
+ const performResetSettings = React.useCallback(async () => {
+ try {
+ const defaultValue = String(DEFAULT_IDLE_TIMEOUT);
+
+ if (mode === APP_MODE.NOT_CONNECTED) {
+ const success = await clearSettings();
+ if (success) {
+ setServerUrl('');
+ setHideServerUrl(false);
+ setIdleTimeout(defaultValue);
+ await setSetting(AUTH_SETTINGS_KEYS.IDLE_TIMEOUT, defaultValue);
+ setNotConnected();
+ showSuccess('Настройки сброшены');
+ } else {
+ showError('Не удалось сбросить настройки');
+ }
+ } else {
+ //Подключён (онлайн или офлайн): сбрасываем только время простоя
+ await setSetting(AUTH_SETTINGS_KEYS.IDLE_TIMEOUT, defaultValue);
+ setIdleTimeout(defaultValue);
+ showSuccess('Настройки сброшены');
+ }
+ } catch (error) {
+ console.error('Ошибка сброса настроек:', error);
+ showError('Не удалось сбросить настройки');
+ }
+ }, [
+ mode,
+ APP_MODE.NOT_CONNECTED,
+ setServerUrl,
+ setHideServerUrl,
+ setIdleTimeout,
+ setNotConnected,
+ clearSettings,
+ setSetting,
+ showSuccess,
+ showError
+ ]);
+
+ //Сброс настроек: при подключении (онлайн/офлайн) — без настроек подключения; при отсутствии подключения — все настройки
+ const handleResetSettings = React.useCallback(() => {
+ const confirmButton = getConfirmButtonOptions(DIALOG_BUTTON_TYPE.WARNING, 'Сбросить', performResetSettings);
- //Сброс всех настроек
- const handleResetSettings = React.useCallback(async () => {
showInfo('Сбросить все настройки к значениям по умолчанию?', {
title: 'Подтверждение сброса',
- buttons: [
- {
- id: 'cancel',
- title: 'Отмена',
- onPress: () => { }
- },
- {
- id: 'confirm',
- title: 'Сбросить',
- onPress: async () => {
- try {
- const success = await clearSettings();
- if (success) {
- setServerUrl('');
- setNotConnected();
- showSuccess('Настройки сброшены');
- } else {
- showError('Не удалось сбросить настройки');
- }
- } catch (error) {
- console.error('Ошибка сброса настроек:', error);
- showError('Не удалось сбросить настройки');
- }
- },
- buttonStyle: { backgroundColor: APP_COLORS.warning },
- textStyle: { color: APP_COLORS.white }
- }
- ]
+ buttons: [DIALOG_CANCEL_BUTTON, confirmButton]
});
- }, [showInfo, showSuccess, showError, setNotConnected, clearSettings]);
+ }, [showInfo, performResetSettings]);
//Оптимизация базы данных
const handleOptimizeDb = React.useCallback(async () => {
@@ -240,11 +334,42 @@ function SettingsScreen() {
showError('Не удалось скопировать адрес');
}, [showError]);
+ //Обработчик копирования идентификатора устройства
+ const handleCopyDeviceId = React.useCallback(() => {
+ showSuccess('Идентификатор устройства скопирован');
+ }, [showSuccess]);
+
return (
+ {isAuthenticated && session ? (
+
+
+ Информация о подключении
+
+
+
+
+ Имя пользователя:
+
+
+ {session.userName || session.userCode || 'Не указано'}
+
+
+
+
+
+ Организация:
+
+
+ {session.companyName || 'Не указана'}
+
+
+
+ ) : null}
+
Сервер приложений
@@ -255,13 +380,13 @@ function SettingsScreen() {
- [styles.serverUrlField, pressed && styles.serverUrlFieldPressed]}
- onPress={handleOpenServerUrlDialog}
- disabled={isLoading || !isDbReady}
- >
+
{serverUrl || 'Нажмите для ввода адреса'}
@@ -269,12 +394,48 @@ function SettingsScreen() {
{serverUrl ? (
-
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+ Системные настройки
+
+
+
+ Максимальное время простоя (минут)
+
+
+
+
+ {idleTimeout || String(DEFAULT_IDLE_TIMEOUT)}
+
+
+
+
+ Идентификатор устройства
+
+
+
+
+
+ {deviceId || 'Загрузка...'}
+
+
+
+ {deviceId ? (
+
) : null}
@@ -334,12 +495,7 @@ function SettingsScreen() {
{serverUrl || 'Не настроен'}
{serverUrl ? (
-
+
) : null}
@@ -366,7 +522,21 @@ function SettingsScreen() {
cancelText="Отмена"
onConfirm={handleSaveServerUrl}
onCancel={handleCloseServerUrlDialog}
- validator={validateServerUrl}
+ validator={validateServerUrlAllowEmpty}
+ />
+
+
);
diff --git a/rn/app/src/styles/auth/OrganizationSelectDialog.styles.js b/rn/app/src/styles/auth/OrganizationSelectDialog.styles.js
new file mode 100644
index 0000000..71d8992
--- /dev/null
+++ b/rn/app/src/styles/auth/OrganizationSelectDialog.styles.js
@@ -0,0 +1,142 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Стили диалога выбора организации
+*/
+
+//---------------------
+//Подключение библиотек
+//---------------------
+
+const { StyleSheet } = require('react-native');
+const { APP_COLORS } = require('../../config/theme');
+const { UI } = require('../../config/appConfig');
+const { responsiveSpacing, heightPercentage } = require('../../utils/responsive');
+
+//-----------
+//Тело модуля
+//-----------
+
+const styles = StyleSheet.create({
+ overlay: {
+ flex: 1,
+ backgroundColor: APP_COLORS.overlay,
+ justifyContent: 'center',
+ alignItems: 'center',
+ padding: UI.PADDING
+ },
+ dialog: {
+ backgroundColor: APP_COLORS.surface,
+ borderRadius: UI.BORDER_RADIUS,
+ width: '100%',
+ maxWidth: 400,
+ maxHeight: heightPercentage(80),
+ flexDirection: 'column'
+ },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingHorizontal: UI.PADDING,
+ paddingTop: UI.PADDING,
+ paddingBottom: responsiveSpacing(3),
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: APP_COLORS.borderSubtle,
+ position: 'relative'
+ },
+ title: {
+ textAlign: 'center',
+ color: APP_COLORS.textPrimary,
+ flex: 1,
+ paddingHorizontal: 40
+ },
+ closeButton: {
+ position: 'absolute',
+ right: UI.PADDING,
+ top: UI.PADDING,
+ width: 32,
+ height: 32,
+ borderRadius: 16,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ closeButtonPressed: {
+ backgroundColor: APP_COLORS.surfaceAlt
+ },
+ closeIcon: {
+ width: 16,
+ height: 16,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ closeIconLine1: {
+ position: 'absolute',
+ width: 16,
+ height: 2,
+ backgroundColor: APP_COLORS.textSecondary,
+ borderRadius: 1,
+ transform: [{ rotate: '45deg' }]
+ },
+ closeIconLine2: {
+ position: 'absolute',
+ width: 16,
+ height: 2,
+ backgroundColor: APP_COLORS.textSecondary,
+ borderRadius: 1,
+ transform: [{ rotate: '-45deg' }]
+ },
+ list: {
+ flexGrow: 0,
+ flexShrink: 1
+ },
+ listContent: {
+ paddingVertical: responsiveSpacing(2)
+ },
+ organizationItem: {
+ paddingHorizontal: UI.PADDING,
+ paddingVertical: responsiveSpacing(3),
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: APP_COLORS.borderSubtle
+ },
+ organizationItemSelected: {
+ backgroundColor: APP_COLORS.primaryExtraLight
+ },
+ organizationItemPressed: {
+ backgroundColor: APP_COLORS.surfaceAlt
+ },
+ organizationName: {
+ fontSize: UI.FONT_SIZE_MD,
+ color: APP_COLORS.textPrimary
+ },
+ organizationNameSelected: {
+ color: APP_COLORS.primary,
+ fontWeight: '600'
+ },
+ footer: {
+ flexDirection: 'row',
+ padding: UI.PADDING,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: APP_COLORS.borderSubtle,
+ gap: responsiveSpacing(3),
+ flexShrink: 0
+ },
+ cancelButton: {
+ flex: 1,
+ backgroundColor: APP_COLORS.surfaceAlt
+ },
+ cancelButtonText: {
+ color: APP_COLORS.textPrimary
+ },
+ confirmButton: {
+ flex: 1,
+ backgroundColor: APP_COLORS.primary
+ },
+ confirmButtonText: {
+ color: APP_COLORS.white
+ }
+});
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = styles;
diff --git a/rn/app/src/styles/common/AppLogo.styles.js b/rn/app/src/styles/common/AppLogo.styles.js
index d72bc1b..d0d5878 100644
--- a/rn/app/src/styles/common/AppLogo.styles.js
+++ b/rn/app/src/styles/common/AppLogo.styles.js
@@ -9,122 +9,51 @@
const { StyleSheet } = require('react-native'); //StyleSheet React Native
const { responsiveSize } = require('../../utils/responsive'); //Адаптивные утилиты
+const { LOGO_SIZE_KEYS } = require('../../config/appAssets'); //Ключи размеров логотипа
//-----------
//Тело модуля
//-----------
-//Цвета иконки
-const TEAL_BACKGROUND = '#50AF95';
-const ROBOT_WHITE = '#FFFFFF';
+//Размеры контейнера для вариантов логотипа
+const SIZES = Object.freeze({
+ [LOGO_SIZE_KEYS.SMALL]: responsiveSize(32),
+ [LOGO_SIZE_KEYS.MEDIUM]: responsiveSize(40),
+ [LOGO_SIZE_KEYS.LARGE]: responsiveSize(56)
+});
-//Размеры для разных вариантов
-const SIZES = {
- small: responsiveSize(32),
- medium: responsiveSize(40),
- large: responsiveSize(56)
-};
+//Радиус скругления контейнера по размерам
+const BORDER_RADIUS = Object.freeze({
+ [LOGO_SIZE_KEYS.SMALL]: responsiveSize(6),
+ [LOGO_SIZE_KEYS.MEDIUM]: responsiveSize(8),
+ [LOGO_SIZE_KEYS.LARGE]: responsiveSize(10)
+});
//Стили логотипа
const styles = StyleSheet.create({
container: {
alignItems: 'center',
justifyContent: 'center',
- backgroundColor: TEAL_BACKGROUND,
overflow: 'hidden'
},
containerSmall: {
- width: SIZES.small,
- height: SIZES.small,
- borderRadius: responsiveSize(6)
+ width: SIZES[LOGO_SIZE_KEYS.SMALL],
+ height: SIZES[LOGO_SIZE_KEYS.SMALL],
+ borderRadius: BORDER_RADIUS[LOGO_SIZE_KEYS.SMALL]
},
containerMedium: {
- width: SIZES.medium,
- height: SIZES.medium,
- borderRadius: responsiveSize(8)
+ width: SIZES[LOGO_SIZE_KEYS.MEDIUM],
+ height: SIZES[LOGO_SIZE_KEYS.MEDIUM],
+ borderRadius: BORDER_RADIUS[LOGO_SIZE_KEYS.MEDIUM]
},
containerLarge: {
- width: SIZES.large,
- height: SIZES.large,
- borderRadius: responsiveSize(10)
+ width: SIZES[LOGO_SIZE_KEYS.LARGE],
+ height: SIZES[LOGO_SIZE_KEYS.LARGE],
+ borderRadius: BORDER_RADIUS[LOGO_SIZE_KEYS.LARGE]
},
- robotContainer: {
- alignItems: 'center',
- justifyContent: 'center'
- },
- antennasContainer: {
- flexDirection: 'row',
- justifyContent: 'center',
- marginBottom: responsiveSize(-1)
- },
- antenna: {
- backgroundColor: ROBOT_WHITE,
- borderRadius: responsiveSize(2)
- },
- antennaLeft: {
- transform: [{ rotate: '-30deg' }],
- marginRight: responsiveSize(4)
- },
- antennaRight: {
- transform: [{ rotate: '30deg' }],
- marginLeft: responsiveSize(4)
- },
- antennaSmall: {
- width: responsiveSize(1.5),
- height: responsiveSize(4)
- },
- antennaMedium: {
- width: responsiveSize(2),
- height: responsiveSize(5)
- },
- antennaLarge: {
- width: responsiveSize(2.5),
- height: responsiveSize(7)
- },
- head: {
- backgroundColor: ROBOT_WHITE,
- borderTopLeftRadius: responsiveSize(100),
- borderTopRightRadius: responsiveSize(100),
- borderBottomLeftRadius: responsiveSize(4),
- borderBottomRightRadius: responsiveSize(4),
- justifyContent: 'center',
- alignItems: 'center'
- },
- headSmall: {
- width: responsiveSize(18),
- height: responsiveSize(10),
- paddingTop: responsiveSize(3)
- },
- headMedium: {
- width: responsiveSize(22),
- height: responsiveSize(12),
- paddingTop: responsiveSize(4)
- },
- headLarge: {
- width: responsiveSize(32),
- height: responsiveSize(18),
- paddingTop: responsiveSize(5)
- },
- eyesContainer: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- width: '50%'
- },
- eye: {
- backgroundColor: TEAL_BACKGROUND,
- borderRadius: responsiveSize(100)
- },
- eyeSmall: {
- width: responsiveSize(2),
- height: responsiveSize(2)
- },
- eyeMedium: {
- width: responsiveSize(2.5),
- height: responsiveSize(2.5)
- },
- eyeLarge: {
- width: responsiveSize(4),
- height: responsiveSize(4)
+ image: {
+ width: '100%',
+ height: '100%'
}
});
diff --git a/rn/app/src/styles/common/AppMessage.styles.js b/rn/app/src/styles/common/AppMessage.styles.js
index c8516e2..0b111c8 100644
--- a/rn/app/src/styles/common/AppMessage.styles.js
+++ b/rn/app/src/styles/common/AppMessage.styles.js
@@ -7,8 +7,8 @@
//Подключение библиотек
//---------------------
-const { StyleSheet } = require("react-native"); //StyleSheet React Native
-const { APP_COLORS } = require("../../config/theme"); //Цветовая схема приложения
+const { StyleSheet } = require('react-native'); //StyleSheet React Native
+const { APP_COLORS } = require('../../config/theme'); //Цветовая схема приложения
//-----------
//Тело модуля
@@ -19,12 +19,12 @@ const styles = StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: APP_COLORS.overlay,
- alignItems: "center",
- justifyContent: "center",
+ alignItems: 'center',
+ justifyContent: 'center',
padding: 24
},
container: {
- width: "100%",
+ width: '100%',
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 12,
@@ -55,15 +55,15 @@ const styles = StyleSheet.create({
borderLeftColor: APP_COLORS.success
},
header: {
- flexDirection: "row",
- alignItems: "center",
- justifyContent: "space-between",
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
marginBottom: 8
},
title: {
flex: 1,
fontSize: 16,
- fontWeight: "600",
+ fontWeight: '600',
color: APP_COLORS.textPrimary,
marginRight: 8
},
@@ -71,8 +71,8 @@ const styles = StyleSheet.create({
width: 28,
height: 28,
borderRadius: 14,
- alignItems: "center",
- justifyContent: "center"
+ alignItems: 'center',
+ justifyContent: 'center'
},
closeButtonText: {
fontSize: 20,
@@ -87,8 +87,8 @@ const styles = StyleSheet.create({
color: APP_COLORS.textSecondary
},
buttonsRow: {
- flexDirection: "row",
- justifyContent: "flex-end",
+ flexDirection: 'row',
+ justifyContent: 'flex-end',
gap: 8
},
buttonBase: {
@@ -102,7 +102,7 @@ const styles = StyleSheet.create({
},
buttonText: {
fontSize: 14,
- fontWeight: "500",
+ fontWeight: '500',
color: APP_COLORS.white
}
});
@@ -112,4 +112,3 @@ const styles = StyleSheet.create({
//----------------
module.exports = styles;
-
diff --git a/rn/app/src/styles/common/AppSwitch.styles.js b/rn/app/src/styles/common/AppSwitch.styles.js
new file mode 100644
index 0000000..f05796f
--- /dev/null
+++ b/rn/app/src/styles/common/AppSwitch.styles.js
@@ -0,0 +1,42 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Стили компонента переключателя
+*/
+
+//---------------------
+//Подключение библиотек
+//---------------------
+
+const { StyleSheet } = require('react-native');
+const { APP_COLORS } = require('../../config/theme');
+const { responsiveSpacing } = require('../../utils/responsive');
+
+//-----------
+//Тело модуля
+//-----------
+
+const styles = StyleSheet.create({
+ container: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingVertical: responsiveSpacing(2)
+ },
+ containerDisabled: {
+ opacity: 0.5
+ },
+ label: {
+ flex: 1,
+ marginRight: responsiveSpacing(3),
+ color: APP_COLORS.textPrimary
+ },
+ labelDisabled: {
+ color: APP_COLORS.textTertiary
+ }
+});
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = styles;
diff --git a/rn/app/src/styles/common/LoadingOverlay.styles.js b/rn/app/src/styles/common/LoadingOverlay.styles.js
new file mode 100644
index 0000000..0606083
--- /dev/null
+++ b/rn/app/src/styles/common/LoadingOverlay.styles.js
@@ -0,0 +1,55 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Стили компонента оверлея загрузки
+*/
+
+//---------------------
+//Подключение библиотек
+//---------------------
+
+const { StyleSheet } = require('react-native');
+const { APP_COLORS } = require('../../config/theme');
+const { UI } = require('../../config/appConfig');
+const { responsiveSpacing } = require('../../utils/responsive');
+
+//-----------
+//Тело модуля
+//-----------
+
+const styles = StyleSheet.create({
+ overlay: {
+ flex: 1,
+ backgroundColor: APP_COLORS.overlay,
+ justifyContent: 'center',
+ alignItems: 'center'
+ },
+ container: {
+ backgroundColor: APP_COLORS.surface,
+ borderRadius: UI.BORDER_RADIUS,
+ padding: responsiveSpacing(6),
+ alignItems: 'center',
+ minWidth: 150,
+ shadowColor: APP_COLORS.shadow,
+ shadowOffset: {
+ width: 0,
+ height: 2
+ },
+ shadowOpacity: 0.25,
+ shadowRadius: 4,
+ elevation: 5
+ },
+ indicator: {
+ color: APP_COLORS.primary
+ },
+ message: {
+ marginTop: responsiveSpacing(4),
+ color: APP_COLORS.textPrimary,
+ textAlign: 'center'
+ }
+});
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = styles;
diff --git a/rn/app/src/styles/common/PasswordInput.styles.js b/rn/app/src/styles/common/PasswordInput.styles.js
new file mode 100644
index 0000000..e63a5fe
--- /dev/null
+++ b/rn/app/src/styles/common/PasswordInput.styles.js
@@ -0,0 +1,92 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Стили компонента ввода пароля
+*/
+
+//---------------------
+//Подключение библиотек
+//---------------------
+
+const { StyleSheet } = require('react-native');
+const { APP_COLORS } = require('../../config/theme');
+const { UI } = require('../../config/appConfig');
+const { responsiveSpacing } = require('../../utils/responsive');
+
+//-----------
+//Тело модуля
+//-----------
+
+const styles = StyleSheet.create({
+ container: {
+ marginBottom: responsiveSpacing(4)
+ },
+ label: {
+ marginBottom: responsiveSpacing(2),
+ color: APP_COLORS.textPrimary
+ },
+ inputContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ borderWidth: 1,
+ borderColor: APP_COLORS.borderMedium,
+ borderRadius: UI.BORDER_RADIUS,
+ backgroundColor: APP_COLORS.white,
+ minHeight: UI.INPUT_HEIGHT
+ },
+ inputContainerFocused: {
+ borderColor: APP_COLORS.primary,
+ borderWidth: 2,
+ shadowColor: APP_COLORS.primary,
+ shadowOffset: { width: 0, height: 0 },
+ shadowOpacity: 0.2,
+ shadowRadius: 4,
+ elevation: 2
+ },
+ inputContainerError: {
+ borderColor: APP_COLORS.error
+ },
+ inputContainerDisabled: {
+ backgroundColor: APP_COLORS.surfaceAlt
+ },
+ input: {
+ flex: 1,
+ paddingHorizontal: responsiveSpacing(3),
+ paddingVertical: responsiveSpacing(2.5),
+ fontSize: UI.FONT_SIZE_MD,
+ color: APP_COLORS.textPrimary,
+ includeFontPadding: false,
+ textAlignVertical: 'center'
+ },
+ inputDisabled: {
+ color: APP_COLORS.textTertiary
+ },
+ placeholder: {
+ color: APP_COLORS.textTertiary
+ },
+ toggleButton: {
+ paddingHorizontal: responsiveSpacing(3),
+ paddingVertical: responsiveSpacing(2),
+ justifyContent: 'center',
+ alignItems: 'center'
+ },
+ toggleButtonText: {
+ color: APP_COLORS.primary,
+ fontSize: UI.FONT_SIZE_SM
+ },
+ toggleButtonTextDisabled: {
+ color: APP_COLORS.textTertiary
+ },
+ helperText: {
+ marginTop: responsiveSpacing(1),
+ color: APP_COLORS.textSecondary
+ },
+ helperTextError: {
+ color: APP_COLORS.error
+ }
+});
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = styles;
diff --git a/rn/app/src/styles/inspections/InspectionItem.styles.js b/rn/app/src/styles/inspections/InspectionItem.styles.js
index 7b276e8..4f6ed43 100644
--- a/rn/app/src/styles/inspections/InspectionItem.styles.js
+++ b/rn/app/src/styles/inspections/InspectionItem.styles.js
@@ -7,8 +7,8 @@
//Подключение библиотек
//---------------------
-const { StyleSheet } = require("react-native"); //StyleSheet React Native
-const { APP_COLORS } = require("../../config/theme"); //Цветовая схема приложения
+const { StyleSheet } = require('react-native'); //StyleSheet React Native
+const { APP_COLORS } = require('../../config/theme'); //Цветовая схема приложения
//-----------
//Тело модуля
@@ -25,12 +25,12 @@ const styles = StyleSheet.create({
},
title: {
fontSize: 16,
- fontWeight: "500",
+ fontWeight: '500',
marginBottom: 4
},
metaRow: {
- flexDirection: "row",
- justifyContent: "space-between"
+ flexDirection: 'row',
+ justifyContent: 'space-between'
},
meta: {
fontSize: 12,
@@ -43,4 +43,3 @@ const styles = StyleSheet.create({
//----------------
module.exports = styles;
-
diff --git a/rn/app/src/styles/inspections/InspectionList.styles.js b/rn/app/src/styles/inspections/InspectionList.styles.js
index a09f225..ebd3ced 100644
--- a/rn/app/src/styles/inspections/InspectionList.styles.js
+++ b/rn/app/src/styles/inspections/InspectionList.styles.js
@@ -7,8 +7,8 @@
//Подключение библиотек
//---------------------
-const { StyleSheet } = require("react-native"); //StyleSheet React Native
-const { APP_COLORS } = require("../../config/theme"); //Цветовая схема приложения
+const { StyleSheet } = require('react-native'); //StyleSheet React Native
+const { APP_COLORS } = require('../../config/theme'); //Цветовая схема приложения
//-----------
//Тело модуля
@@ -18,23 +18,26 @@ const { APP_COLORS } = require("../../config/theme"); //Цветовая схе
const styles = StyleSheet.create({
centerContainer: {
flex: 1,
- alignItems: "center",
- justifyContent: "center",
+ alignItems: 'center',
+ justifyContent: 'center',
paddingHorizontal: 32
},
centerText: {
marginTop: 12,
- textAlign: "center",
+ textAlign: 'center',
color: APP_COLORS.textSecondary
},
errorText: {
marginTop: 8,
- textAlign: "center",
+ textAlign: 'center',
color: APP_COLORS.error,
fontSize: 12
},
centerButton: {
marginTop: 16
+ },
+ indicator: {
+ color: APP_COLORS.primary
}
});
@@ -43,4 +46,3 @@ const styles = StyleSheet.create({
//----------------
module.exports = styles;
-
diff --git a/rn/app/src/styles/layout/AppAuthProvider.styles.js b/rn/app/src/styles/layout/AppAuthProvider.styles.js
new file mode 100644
index 0000000..6205a27
--- /dev/null
+++ b/rn/app/src/styles/layout/AppAuthProvider.styles.js
@@ -0,0 +1,17 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Стили провайдера авторизации (провайдер без визуальной разметки)
+*/
+
+//---------
+//Константы
+//---------
+
+//Провайдер не рендерит собственных View — стили не требуются
+const styles = {};
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = styles;
diff --git a/rn/app/src/styles/layout/AppErrorBoundary.styles.js b/rn/app/src/styles/layout/AppErrorBoundary.styles.js
index cb40931..0456458 100644
--- a/rn/app/src/styles/layout/AppErrorBoundary.styles.js
+++ b/rn/app/src/styles/layout/AppErrorBoundary.styles.js
@@ -7,8 +7,8 @@
//Подключение библиотек
//---------------------
-const { StyleSheet } = require("react-native"); //StyleSheet React Native
-const { APP_COLORS } = require("../../config/theme"); //Цветовая схема приложения
+const { StyleSheet } = require('react-native'); //StyleSheet React Native
+const { APP_COLORS } = require('../../config/theme'); //Цветовая схема приложения
//-----------
//Тело модуля
@@ -18,13 +18,13 @@ const { APP_COLORS } = require("../../config/theme"); //Цветовая схе
const styles = StyleSheet.create({
container: {
flex: 1,
- alignItems: "center",
- justifyContent: "center",
+ alignItems: 'center',
+ justifyContent: 'center',
backgroundColor: APP_COLORS.background,
padding: 24
},
card: {
- width: "100%",
+ width: '100%',
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 12,
@@ -40,7 +40,7 @@ const styles = StyleSheet.create({
},
title: {
fontSize: 18,
- fontWeight: "600",
+ fontWeight: '600',
color: APP_COLORS.error,
marginBottom: 8
},
@@ -50,8 +50,8 @@ const styles = StyleSheet.create({
marginBottom: 16
},
buttonRow: {
- flexDirection: "row",
- justifyContent: "flex-end"
+ flexDirection: 'row',
+ justifyContent: 'flex-end'
}
});
@@ -60,4 +60,3 @@ const styles = StyleSheet.create({
//----------------
module.exports = styles;
-
diff --git a/rn/app/src/styles/layout/AppHeader.styles.js b/rn/app/src/styles/layout/AppHeader.styles.js
index f27d684..b318452 100644
--- a/rn/app/src/styles/layout/AppHeader.styles.js
+++ b/rn/app/src/styles/layout/AppHeader.styles.js
@@ -10,7 +10,7 @@
const { StyleSheet } = require('react-native'); //StyleSheet React Native
const { APP_COLORS } = require('../../config/theme'); //Цветовая схема приложения
const { UI } = require('../../config/appConfig'); //Конфигурация UI
-const { responsiveSize, responsiveWidth, responsiveSpacing } = require('../../utils/responsive'); //Адаптивные утилиты
+const { responsiveSize, responsiveSpacing } = require('../../utils/responsive'); //Адаптивные утилиты
//-----------
//Тело модуля
@@ -91,59 +91,6 @@ const styles = StyleSheet.create({
borderRadius: responsiveSize(1),
transform: [{ rotate: '45deg' }, { translateY: responsiveSize(2.5) }]
},
- controls: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: responsiveSpacing(2)
- },
- modeContainer: {
- paddingHorizontal: responsiveSpacing(3),
- paddingVertical: responsiveSpacing(1.5),
- borderRadius: responsiveSize(20),
- minWidth: responsiveWidth(100),
- alignItems: 'center',
- justifyContent: 'center'
- },
- modePressed: {
- opacity: 0.8
- },
- modeOnline: {
- backgroundColor: APP_COLORS.primary + '15',
- borderWidth: 1,
- borderColor: APP_COLORS.primary
- },
- modeOffline: {
- backgroundColor: APP_COLORS.warning + '15',
- borderWidth: 1,
- borderColor: APP_COLORS.warning
- },
- modeNotConnected: {
- backgroundColor: APP_COLORS.textSecondary + '15',
- borderWidth: 1,
- borderColor: APP_COLORS.textSecondary
- },
- modeUnknown: {
- backgroundColor: APP_COLORS.error + '15',
- borderWidth: 1,
- borderColor: APP_COLORS.error
- },
- modeText: {
- fontSize: UI.FONT_SIZE_XS,
- fontWeight: '600',
- includeFontPadding: false
- },
- modeTextOnline: {
- color: APP_COLORS.primary
- },
- modeTextOffline: {
- color: APP_COLORS.warning
- },
- modeTextNotConnected: {
- color: APP_COLORS.textSecondary
- },
- modeTextUnknown: {
- color: APP_COLORS.error
- },
menuButton: {
width: responsiveSize(40),
height: responsiveSize(40),
diff --git a/rn/app/src/styles/layout/AppLocalDbProvider.styles.js b/rn/app/src/styles/layout/AppLocalDbProvider.styles.js
new file mode 100644
index 0000000..60b0271
--- /dev/null
+++ b/rn/app/src/styles/layout/AppLocalDbProvider.styles.js
@@ -0,0 +1,17 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Стили провайдера локальной БД (провайдер без визуальной разметки)
+*/
+
+//---------
+//Константы
+//---------
+
+//Провайдер не рендерит собственных View — стили не требуются
+const styles = {};
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = styles;
diff --git a/rn/app/src/styles/layout/AppMessagingProvider.styles.js b/rn/app/src/styles/layout/AppMessagingProvider.styles.js
new file mode 100644
index 0000000..a0da49a
--- /dev/null
+++ b/rn/app/src/styles/layout/AppMessagingProvider.styles.js
@@ -0,0 +1,17 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Стили провайдера сообщений (провайдер без визуальной разметки)
+*/
+
+//---------
+//Константы
+//---------
+
+//Провайдер не рендерит собственных View — стили не требуются
+const styles = {};
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = styles;
diff --git a/rn/app/src/styles/layout/AppModeProvider.styles.js b/rn/app/src/styles/layout/AppModeProvider.styles.js
new file mode 100644
index 0000000..bfda788
--- /dev/null
+++ b/rn/app/src/styles/layout/AppModeProvider.styles.js
@@ -0,0 +1,17 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Стили провайдера режима работы (провайдер без визуальной разметки)
+*/
+
+//---------
+//Константы
+//---------
+
+//Провайдер не рендерит собственных View — стили не требуются
+const styles = {};
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = styles;
diff --git a/rn/app/src/styles/layout/AppNavigationProvider.styles.js b/rn/app/src/styles/layout/AppNavigationProvider.styles.js
new file mode 100644
index 0000000..7d5d38f
--- /dev/null
+++ b/rn/app/src/styles/layout/AppNavigationProvider.styles.js
@@ -0,0 +1,17 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Стили провайдера навигации (провайдер без визуальной разметки)
+*/
+
+//---------
+//Константы
+//---------
+
+//Провайдер не рендерит собственных View — стили не требуются
+const styles = {};
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = styles;
diff --git a/rn/app/src/styles/layout/AppPreTripInspectionsProvider.styles.js b/rn/app/src/styles/layout/AppPreTripInspectionsProvider.styles.js
new file mode 100644
index 0000000..dad8841
--- /dev/null
+++ b/rn/app/src/styles/layout/AppPreTripInspectionsProvider.styles.js
@@ -0,0 +1,17 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Стили провайдера предрейсовых осмотров (провайдер без визуальной разметки)
+*/
+
+//---------
+//Константы
+//---------
+
+//Провайдер не рендерит собственных View — стили не требуются
+const styles = {};
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = styles;
diff --git a/rn/app/src/styles/layout/AppServerProvider.styles.js b/rn/app/src/styles/layout/AppServerProvider.styles.js
new file mode 100644
index 0000000..e4def18
--- /dev/null
+++ b/rn/app/src/styles/layout/AppServerProvider.styles.js
@@ -0,0 +1,17 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Стили провайдера сервера приложений (провайдер без визуальной разметки)
+*/
+
+//---------
+//Константы
+//---------
+
+//Провайдер не рендерит собственных View — стили не требуются
+const styles = {};
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = styles;
diff --git a/rn/app/src/styles/layout/AppShell.styles.js b/rn/app/src/styles/layout/AppShell.styles.js
index b5ee3af..a5e2510 100644
--- a/rn/app/src/styles/layout/AppShell.styles.js
+++ b/rn/app/src/styles/layout/AppShell.styles.js
@@ -7,8 +7,8 @@
//Подключение библиотек
//---------------------
-const { StyleSheet } = require("react-native"); //StyleSheet React Native
-const { APP_COLORS } = require("../../config/theme"); //Цветовая схема приложения
+const { StyleSheet } = require('react-native'); //StyleSheet React Native
+const { APP_COLORS } = require('../../config/theme'); //Цветовая схема приложения
//-----------
//Тело модуля
@@ -27,4 +27,3 @@ const styles = StyleSheet.create({
//----------------
module.exports = styles;
-
diff --git a/rn/app/src/styles/menu/MenuUserInfo.styles.js b/rn/app/src/styles/menu/MenuUserInfo.styles.js
new file mode 100644
index 0000000..ed8cde5
--- /dev/null
+++ b/rn/app/src/styles/menu/MenuUserInfo.styles.js
@@ -0,0 +1,82 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Стили компонента информации о пользователе в меню
+*/
+
+//---------------------
+//Подключение библиотек
+//---------------------
+
+const { StyleSheet } = require('react-native');
+const { APP_COLORS } = require('../../config/theme');
+const { UI } = require('../../config/appConfig');
+const { responsiveSpacing, responsiveSize } = require('../../utils/responsive');
+
+//-----------
+//Тело модуля
+//-----------
+
+const styles = StyleSheet.create({
+ container: {
+ paddingHorizontal: UI.PADDING,
+ paddingVertical: responsiveSpacing(3),
+ borderBottomWidth: 1,
+ borderBottomColor: APP_COLORS.borderSubtle,
+ backgroundColor: APP_COLORS.surfaceAlt
+ },
+ modeContainer: {
+ alignSelf: 'flex-start',
+ paddingHorizontal: responsiveSpacing(3),
+ paddingVertical: responsiveSpacing(1.5),
+ borderRadius: UI.BORDER_RADIUS,
+ marginBottom: responsiveSpacing(2)
+ },
+ modeOnline: {
+ backgroundColor: APP_COLORS.success + '20'
+ },
+ modeOffline: {
+ backgroundColor: APP_COLORS.warning + '20'
+ },
+ modeNotConnected: {
+ backgroundColor: APP_COLORS.textSecondary + '20'
+ },
+ modeText: {
+ fontSize: responsiveSize(13),
+ fontWeight: '600'
+ },
+ modeTextOnline: {
+ color: APP_COLORS.success
+ },
+ modeTextOffline: {
+ color: APP_COLORS.warning
+ },
+ modeTextNotConnected: {
+ color: APP_COLORS.textSecondary
+ },
+ userInfo: {
+ marginTop: responsiveSpacing(1)
+ },
+ userRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginBottom: responsiveSpacing(1)
+ },
+ userLabel: {
+ fontSize: responsiveSize(12),
+ color: APP_COLORS.textSecondary,
+ marginRight: responsiveSpacing(1),
+ flexShrink: 0
+ },
+ userValue: {
+ fontSize: responsiveSize(13),
+ color: APP_COLORS.textPrimary,
+ fontWeight: '500',
+ flex: 1
+ }
+});
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = styles;
diff --git a/rn/app/src/styles/screens/AuthScreen.styles.js b/rn/app/src/styles/screens/AuthScreen.styles.js
new file mode 100644
index 0000000..081a604
--- /dev/null
+++ b/rn/app/src/styles/screens/AuthScreen.styles.js
@@ -0,0 +1,70 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Стили экрана аутентификации
+*/
+
+//---------------------
+//Подключение библиотек
+//---------------------
+
+const { StyleSheet } = require('react-native');
+const { APP_COLORS } = require('../../config/theme');
+const { UI } = require('../../config/appConfig');
+const { responsiveSpacing } = require('../../utils/responsive');
+
+//-----------
+//Тело модуля
+//-----------
+
+const styles = StyleSheet.create({
+ keyboardAvoidingView: {
+ flex: 1
+ },
+ scrollView: {
+ flex: 1
+ },
+ scrollContent: {
+ flexGrow: 1,
+ paddingHorizontal: UI.PADDING,
+ paddingBottom: responsiveSpacing(8)
+ },
+ logoContainer: {
+ alignItems: 'center',
+ paddingTop: responsiveSpacing(6),
+ paddingBottom: responsiveSpacing(4)
+ },
+ formContainer: {
+ backgroundColor: APP_COLORS.surface,
+ borderRadius: UI.BORDER_RADIUS,
+ padding: UI.PADDING,
+ borderWidth: 1,
+ borderColor: APP_COLORS.borderSubtle
+ },
+ title: {
+ textAlign: 'center',
+ marginBottom: responsiveSpacing(6),
+ color: APP_COLORS.textPrimary
+ },
+ switchContainer: {
+ marginBottom: responsiveSpacing(4)
+ },
+ loginButton: {
+ backgroundColor: APP_COLORS.primary,
+ marginTop: responsiveSpacing(2)
+ },
+ loginButtonText: {
+ color: APP_COLORS.white,
+ fontWeight: '600'
+ },
+ hint: {
+ textAlign: 'center',
+ marginTop: responsiveSpacing(4),
+ color: APP_COLORS.textTertiary
+ }
+});
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = styles;
diff --git a/rn/app/src/styles/screens/SettingsScreen.styles.js b/rn/app/src/styles/screens/SettingsScreen.styles.js
index 4648bb1..1bab914 100644
--- a/rn/app/src/styles/screens/SettingsScreen.styles.js
+++ b/rn/app/src/styles/screens/SettingsScreen.styles.js
@@ -6,6 +6,7 @@
//---------------------
//Подключение библиотек
//---------------------
+
const { StyleSheet } = require('react-native');
const { APP_COLORS } = require('../../config/theme');
const { UI } = require('../../config/appConfig');
@@ -39,6 +40,9 @@ const styles = StyleSheet.create({
marginBottom: responsiveSpacing(2),
color: APP_COLORS.textSecondary
},
+ fieldLabelMarginTop: {
+ marginTop: responsiveSpacing(4)
+ },
serverUrlRow: {
flexDirection: 'row',
alignItems: 'center'
@@ -57,16 +61,33 @@ const styles = StyleSheet.create({
borderColor: APP_COLORS.primary,
backgroundColor: APP_COLORS.primaryExtraLight
},
+ serverUrlFieldDisabled: {
+ backgroundColor: APP_COLORS.surfaceAlt,
+ borderColor: APP_COLORS.borderSubtle
+ },
serverUrlText: {
fontSize: UI.FONT_SIZE_MD,
color: APP_COLORS.textPrimary
},
+ serverUrlTextDisabled: {
+ color: APP_COLORS.textTertiary
+ },
serverUrlPlaceholder: {
color: APP_COLORS.textTertiary
},
serverUrlCopyButton: {
marginLeft: responsiveSpacing(2)
},
+ deviceIdField: {
+ backgroundColor: APP_COLORS.surfaceAlt
+ },
+ helperText: {
+ marginTop: responsiveSpacing(2),
+ color: APP_COLORS.textTertiary
+ },
+ switchRow: {
+ marginTop: responsiveSpacing(3)
+ },
actionButton: {
marginTop: responsiveSpacing(3)
},
@@ -124,4 +145,5 @@ const styles = StyleSheet.create({
//----------------
//Интерфейс модуля
//----------------
+
module.exports = styles;
diff --git a/rn/app/src/utils/deviceId.js b/rn/app/src/utils/deviceId.js
new file mode 100644
index 0000000..beda211
--- /dev/null
+++ b/rn/app/src/utils/deviceId.js
@@ -0,0 +1,227 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Утилита для получения постоянного идентификатора устройства
+*/
+
+//---------------------
+//Подключение библиотек
+//---------------------
+
+const { Platform, NativeModules } = require('react-native');
+const { WEB_DEVICE_ID_KEY } = require('../config/storageKeys'); //Ключи хранилища
+
+//-----------------------
+//Вспомогательные функции
+//-----------------------
+
+//Генерация хэша из строки
+const hashString = str => {
+ let hash = 0;
+
+ for (let i = 0; i < str.length; i++) {
+ const char = str.charCodeAt(i);
+ hash = (hash * 31 + char) % 2147483647;
+ }
+
+ return Math.abs(hash).toString(36);
+};
+
+//Генерация случайной части идентификатора
+const generateRandomPart = () => {
+ const timestamp = Date.now().toString(36);
+ const random = Math.random().toString(36).substring(2, 10);
+ return `${timestamp}${random}`;
+};
+
+//Получение fingerprint для Web
+const getWebFingerprint = () => {
+ if (typeof navigator === 'undefined' || typeof window === 'undefined') {
+ return null;
+ }
+
+ const components = [
+ navigator.userAgent || '',
+ navigator.language || '',
+ navigator.platform || '',
+ new Date().getTimezoneOffset().toString(),
+ window.screen?.width?.toString() || '',
+ window.screen?.height?.toString() || '',
+ window.screen?.colorDepth?.toString() || '',
+ navigator.hardwareConcurrency?.toString() || '',
+ navigator.maxTouchPoints?.toString() || ''
+ ];
+
+ return hashString(components.join('|'));
+};
+
+//Получение идентификатора для Web с сохранением в localStorage
+const getWebDeviceId = () => {
+ //Проверяем доступность localStorage через window
+ const storage = typeof window !== 'undefined' ? window.localStorage : null;
+
+ if (!storage) {
+ //localStorage недоступен - генерируем на основе fingerprint
+ const fingerprint = getWebFingerprint();
+ return `WEB-${fingerprint || generateRandomPart()}`;
+ }
+
+ //Пробуем получить сохранённый идентификатор
+ let deviceId = storage.getItem(WEB_DEVICE_ID_KEY);
+
+ if (!deviceId) {
+ //Генерируем новый идентификатор
+ const fingerprint = getWebFingerprint();
+ deviceId = `WEB-${fingerprint}-${generateRandomPart()}`;
+ storage.setItem(WEB_DEVICE_ID_KEY, deviceId);
+ }
+
+ return deviceId;
+};
+
+//Получение ANDROID_ID через нативный модуль
+const getAndroidId = async () => {
+ try {
+ //Пробуем получить через PlatformConstants
+ if (NativeModules.PlatformConstants?.getAndroidID) {
+ const androidId = await NativeModules.PlatformConstants.getAndroidID();
+
+ if (androidId) {
+ return androidId;
+ }
+ }
+
+ //Пробуем получить через другие доступные модули
+ if (NativeModules.RNDeviceInfo?.getAndroidId) {
+ const androidId = await NativeModules.RNDeviceInfo.getAndroidId();
+
+ if (androidId) {
+ return androidId;
+ }
+ }
+
+ //Пробуем получить из констант платформы
+ const constants = Platform.constants || {};
+
+ if (constants.Fingerprint) {
+ return hashString(constants.Fingerprint);
+ }
+
+ return null;
+ } catch (error) {
+ console.warn('Не удалось получить Android ID:', error);
+ return null;
+ }
+};
+
+//Получение identifierForVendor для iOS
+const getIosId = async () => {
+ try {
+ //Пробуем получить через доступные нативные модули
+ if (NativeModules.RNDeviceInfo?.getUniqueId) {
+ const uniqueId = await NativeModules.RNDeviceInfo.getUniqueId();
+
+ if (uniqueId) {
+ return uniqueId;
+ }
+ }
+
+ if (NativeModules.SettingsManager?.settings?.AppleLocale) {
+ //Используем комбинацию доступных данных для fingerprint
+ const settings = NativeModules.SettingsManager.settings;
+ const components = [settings.AppleLocale || '', settings.AppleLanguages?.join(',') || '', Platform.constants?.osVersion || ''];
+
+ return hashString(components.join('|'));
+ }
+
+ return null;
+ } catch (error) {
+ console.warn('Не удалось получить iOS ID:', error);
+ return null;
+ }
+};
+
+//Генерация fingerprint для мобильных платформ
+const getMobileFingerprint = () => {
+ const constants = Platform.constants || {};
+
+ const components = [
+ Platform.OS,
+ Platform.Version?.toString() || '',
+ constants.Brand || '',
+ constants.Model || '',
+ constants.Manufacturer || '',
+ constants.osVersion || '',
+ constants.systemName || ''
+ ];
+
+ return hashString(components.filter(Boolean).join('|'));
+};
+
+//-----------
+//Тело модуля
+//-----------
+
+//Получение постоянного идентификатора устройства
+const getPersistentDeviceId = async () => {
+ //Web платформа
+ if (Platform.OS === 'web') {
+ return getWebDeviceId();
+ }
+
+ //Android платформа
+ if (Platform.OS === 'android') {
+ const androidId = await getAndroidId();
+
+ if (androidId) {
+ return `AND-${androidId}`.toUpperCase();
+ }
+
+ //Fallback на fingerprint
+ const fingerprint = getMobileFingerprint();
+ return `AND-${fingerprint}-${generateRandomPart()}`.toUpperCase();
+ }
+
+ //iOS платформа
+ if (Platform.OS === 'ios') {
+ const iosId = await getIosId();
+
+ if (iosId) {
+ return `IOS-${iosId}`.toUpperCase();
+ }
+
+ //Fallback на fingerprint
+ const fingerprint = getMobileFingerprint();
+ return `IOS-${fingerprint}-${generateRandomPart()}`.toUpperCase();
+ }
+
+ //Неизвестная платформа
+ return `UNK-${generateRandomPart()}`.toUpperCase();
+};
+
+//Проверка доступности постоянного идентификатора
+const isPersistentIdAvailable = async () => {
+ if (Platform.OS === 'web') {
+ return typeof window !== 'undefined' && !!window.localStorage;
+ }
+
+ if (Platform.OS === 'android') {
+ const androidId = await getAndroidId();
+ return !!androidId;
+ }
+
+ if (Platform.OS === 'ios') {
+ const iosId = await getIosId();
+ return !!iosId;
+ }
+
+ return false;
+};
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = {
+ getPersistentDeviceId,
+ isPersistentIdAvailable
+};
diff --git a/rn/app/src/utils/loginFormUtils.js b/rn/app/src/utils/loginFormUtils.js
new file mode 100644
index 0000000..e2ee6da
--- /dev/null
+++ b/rn/app/src/utils/loginFormUtils.js
@@ -0,0 +1,22 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Утилиты формы входа (логин): видимость полей, правила отображения
+*/
+
+//-----------
+//Тело модуля
+//-----------
+
+//Определяет, нужно ли показывать поле ввода адреса сервера
+function isServerUrlFieldVisible(hideServerUrl, serverUrl) {
+ const hasServerUrl = Boolean(serverUrl && String(serverUrl).trim());
+ return !hideServerUrl || !hasServerUrl;
+}
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = {
+ isServerUrlFieldVisible
+};
diff --git a/rn/app/src/utils/responsive.js b/rn/app/src/utils/responsive.js
index e9b9da9..4302614 100644
--- a/rn/app/src/utils/responsive.js
+++ b/rn/app/src/utils/responsive.js
@@ -8,15 +8,12 @@
//---------------------
const { Dimensions, Platform, PixelRatio } = require('react-native'); //Размеры экрана и платформа
+const { BASE_WIDTH, BASE_HEIGHT } = require('../config/responsiveConfig'); //Базовые размеры для адаптивности
//-----------
//Тело модуля
//-----------
-//Константы для базовых размеров
-const BASE_WIDTH = 375; // iPhone 11/12/13/14
-const BASE_HEIGHT = 812;
-
//Получение размеров экрана
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
diff --git a/rn/app/src/utils/secureStorage.js b/rn/app/src/utils/secureStorage.js
new file mode 100644
index 0000000..3965426
--- /dev/null
+++ b/rn/app/src/utils/secureStorage.js
@@ -0,0 +1,170 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Утилиты безопасного хранения данных
+*/
+
+//---------------------
+//Подключение библиотек
+//---------------------
+
+const { ENCRYPTED_PREFIX, SECRET_KEY_LENGTH } = require('../config/storageKeys'); //Ключи и константы хранилища
+
+//-----------------------
+//Вспомогательные функции
+//-----------------------
+
+//Вычисление XOR двух байтов через арифметические операции
+const xorByte = (a, b) => {
+ let result = 0;
+ let bit = 1;
+
+ //Обрабатываем каждый из 8 бит
+ for (let i = 0; i < 8; i++) {
+ const bitA = Math.floor(a / bit) % 2;
+ const bitB = Math.floor(b / bit) % 2;
+
+ //XOR: результат 1 только если биты разные
+ if (bitA !== bitB) {
+ result += bit;
+ }
+
+ bit *= 2;
+ }
+
+ return result;
+};
+
+//Генерация хэша ключа на основе секретного ключа и соли
+const generateKeyHash = (secretKey, salt) => {
+ const combined = secretKey + salt;
+ const hash = [];
+
+ for (let i = 0; i < combined.length; i++) {
+ hash.push(combined.charCodeAt(i) % 256);
+ }
+
+ //Расширяем хэш до 256 байт для лучшего распределения
+ while (hash.length < 256) {
+ const newByte = (hash[hash.length - 1] + hash[hash.length - 2] + hash.length) % 256;
+ hash.push(newByte);
+ }
+
+ return hash;
+};
+
+//XOR-шифрование строки с использованием ключа
+const xorEncrypt = (text, keyHash) => {
+ const result = [];
+
+ for (let i = 0; i < text.length; i++) {
+ const charCode = text.charCodeAt(i);
+ const keyByte = keyHash[i % keyHash.length];
+ result.push(xorByte(charCode, keyByte));
+ }
+
+ //Конвертируем в hex-строку
+ return result.map(byte => byte.toString(16).padStart(2, '0')).join('');
+};
+
+//XOR-расшифрование строки
+const xorDecrypt = (encryptedHex, keyHash) => {
+ const bytes = [];
+
+ //Разбираем hex-строку на байты
+ for (let i = 0; i < encryptedHex.length; i += 2) {
+ bytes.push(parseInt(encryptedHex.substr(i, 2), 16));
+ }
+
+ //Расшифровываем XOR
+ const result = [];
+ for (let i = 0; i < bytes.length; i++) {
+ const keyByte = keyHash[i % keyHash.length];
+ result.push(String.fromCharCode(xorByte(bytes[i], keyByte)));
+ }
+
+ return result.join('');
+};
+
+//-----------
+//Тело модуля
+//-----------
+
+//Генерация уникального секретного ключа для устройства
+const generateSecretKey = () => {
+ const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%&*()-_=+[]{}|;:,.<>?';
+ const timestamp = Date.now().toString(36);
+ let key = '';
+
+ //Генерируем случайную часть ключа
+ for (let i = 0; i < SECRET_KEY_LENGTH; i++) {
+ const randomIndex = Math.floor(Math.random() * characters.length);
+ key += characters[randomIndex];
+ }
+
+ //Добавляем временную метку для уникальности
+ return `${key}_${timestamp}`;
+};
+
+//Шифрование чувствительных данных
+const encryptData = (data, secretKey, salt = '') => {
+ if (!data) {
+ return '';
+ }
+
+ if (!secretKey) {
+ console.error('Ошибка шифрования: секретный ключ не предоставлен');
+ return '';
+ }
+
+ try {
+ const keyHash = generateKeyHash(secretKey, salt);
+ const encrypted = xorEncrypt(data, keyHash);
+ return ENCRYPTED_PREFIX + encrypted;
+ } catch (error) {
+ console.error('Ошибка шифрования:', error);
+ return '';
+ }
+};
+
+//Расшифрование данных
+const decryptData = (encryptedData, secretKey, salt = '') => {
+ if (!encryptedData) {
+ return '';
+ }
+
+ if (!secretKey) {
+ console.error('Ошибка расшифровки: секретный ключ не предоставлен');
+ return '';
+ }
+
+ //Проверяем префикс
+ if (!encryptedData.startsWith(ENCRYPTED_PREFIX)) {
+ //Данные не зашифрованы - возвращаем как есть (для обратной совместимости)
+ return encryptedData;
+ }
+
+ try {
+ const encryptedHex = encryptedData.slice(ENCRYPTED_PREFIX.length);
+ const keyHash = generateKeyHash(secretKey, salt);
+ return xorDecrypt(encryptedHex, keyHash);
+ } catch (error) {
+ console.error('Ошибка расшифровки:', error);
+ return '';
+ }
+};
+
+//Проверка зашифрованы ли данные
+const isEncrypted = data => {
+ return data && data.startsWith(ENCRYPTED_PREFIX);
+};
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = {
+ generateSecretKey,
+ encryptData,
+ decryptData,
+ isEncrypted
+};
diff --git a/rn/app/src/utils/validation.js b/rn/app/src/utils/validation.js
new file mode 100644
index 0000000..ea2b457
--- /dev/null
+++ b/rn/app/src/utils/validation.js
@@ -0,0 +1,100 @@
+/*
+ Предрейсовые осмотры - мобильное приложение
+ Универсальные валидаторы для форм (URL, числа и т.д.)
+*/
+
+//---------------------
+//Подключение библиотек
+//---------------------
+
+//---------
+//Константы
+//---------
+
+//Сообщения об ошибках валидации (единый источник для масштабирования)
+const VALIDATION_MESSAGES = {
+ SERVER_URL_EMPTY: 'Введите адрес сервера',
+ SERVER_URL_PROTOCOL: 'Используйте http:// или https:// протокол',
+ SERVER_URL_INVALID: 'Некорректный формат адреса сервера',
+ SERVER_URL_INVALID_SHORT: 'Некорректный формат URL',
+ IDLE_TIMEOUT_EMPTY: 'Введите время простоя',
+ IDLE_TIMEOUT_MIN: 'Введите положительное число (минимум 1 минута)',
+ IDLE_TIMEOUT_MAX: 'Максимальное значение: 1440 минут (24 часа)'
+};
+
+//-----------
+//Тело модуля
+//-----------
+
+//Нормализация URL сервера для сравнения (без завершающих слэшей)
+function normalizeServerUrl(url) {
+ if (!url || typeof url !== 'string') {
+ return '';
+ }
+ return url.trim().replace(/\/+$/, '') || '';
+}
+
+//Валидация URL сервера. Возвращает true при успехе, иначе строку с ошибкой
+function validateServerUrl(url, options) {
+ const opts = options || {};
+ const emptyMessage = opts.emptyMessage || VALIDATION_MESSAGES.SERVER_URL_EMPTY;
+ const invalidMessage = opts.invalidMessage || VALIDATION_MESSAGES.SERVER_URL_INVALID_SHORT;
+ const allowEmpty = opts.allowEmpty === true;
+
+ if (!url || !String(url).trim()) {
+ if (allowEmpty) {
+ return true;
+ }
+ return emptyMessage;
+ }
+
+ try {
+ const parsedUrl = new URL(String(url).trim());
+ if (!parsedUrl.protocol.startsWith('http')) {
+ return VALIDATION_MESSAGES.SERVER_URL_PROTOCOL;
+ }
+ return true;
+ } catch (e) {
+ return invalidMessage;
+ }
+}
+
+//Валидация URL сервера с допуском пустого значения
+function validateServerUrlAllowEmpty(url) {
+ return validateServerUrl(url, { allowEmpty: true });
+}
+
+//Валидация времени простоя (минуты). Возвращает true при успехе, иначе строку с ошибкой
+function validateIdleTimeout(value, options) {
+ const opts = options || {};
+ const min = opts.min !== undefined ? opts.min : 1;
+ const max = opts.max !== undefined ? opts.max : 1440;
+ const emptyMessage = opts.emptyMessage || VALIDATION_MESSAGES.IDLE_TIMEOUT_EMPTY;
+ const minMessage = opts.minMessage || VALIDATION_MESSAGES.IDLE_TIMEOUT_MIN;
+ const maxMessage = opts.maxMessage || VALIDATION_MESSAGES.IDLE_TIMEOUT_MAX;
+
+ if (!value || !String(value).trim()) {
+ return emptyMessage;
+ }
+
+ const num = parseInt(String(value).trim(), 10);
+ if (isNaN(num) || num < min) {
+ return minMessage;
+ }
+ if (num > max) {
+ return maxMessage;
+ }
+ return true;
+}
+
+//----------------
+//Интерфейс модуля
+//----------------
+
+module.exports = {
+ VALIDATION_MESSAGES,
+ normalizeServerUrl,
+ validateServerUrl,
+ validateServerUrlAllowEmpty,
+ validateIdleTimeout
+};