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) => ( - - handleItemPress(item)} - isDestructive={item.isDestructive} - disabled={item.disabled} - style={item.style} - textStyle={item.textStyle} - /> - {item.showDivider && index < items.length - 1 && } - - ))} + {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 +};