Добавлен экран аутентификации, общеиспользуемые константы вынесены в отдельные модули

This commit is contained in:
boa604 2026-02-25 15:46:03 +03:00
parent 561763dc74
commit 5da91dacbc
81 changed files with 4800 additions and 655 deletions

View File

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

View File

@ -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 (
<View style={styles.closeIcon}>
<View style={styles.closeIconLine1} />
<View style={styles.closeIconLine2} />
</View>
);
}
//Стиль элемента организации
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 (
<Pressable style={styleFn} onPress={handlePress} delayPressIn={0}>
<AppText style={[styles.organizationName, isSelected && styles.organizationNameSelected]} numberOfLines={2}>
{item.SNAME}
</AppText>
</Pressable>
);
}
//Диалог выбора организации
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 <OrganizationItem item={item} onSelect={handleSelectItem} isSelected={!!isSelected} />;
},
[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 (
<Modal visible={visible} transparent={true} animationType="fade" statusBarTranslucent={true} onRequestClose={handleCancel}>
<View style={styles.overlay}>
<View style={styles.dialog}>
<View style={styles.header}>
<AppText style={styles.title} variant="h3" weight="semibold">
{title}
</AppText>
<Pressable style={getDialogCloseButtonPressableStyle} onPress={handleCancel} hitSlop={8}>
<CloseIcon />
</Pressable>
</View>
<FlatList
data={organizations}
renderItem={renderItem}
keyExtractor={keyExtractor}
extraData={selectedOrg}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={true}
contentContainerStyle={styles.listContent}
style={styles.list}
/>
<View style={footerStyle}>
<AppButton title="Отмена" onPress={handleCancel} style={styles.cancelButton} textStyle={styles.cancelButtonText} />
<AppButton
title="Выбрать"
onPress={handleConfirm}
disabled={!selectedOrg}
style={styles.confirmButton}
textStyle={styles.confirmButtonText}
/>
</View>
</View>
</View>
</Modal>
);
}
//----------------
//Интерфейс модуля
//----------------
module.exports = OrganizationSelectDialog;

View File

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

View File

@ -16,8 +16,9 @@ const styles = require('../../styles/common/AppInput.styles'); //Стили вв
//Тело модуля //Тело модуля
//----------- //-----------
//Адаптивный компонент ввода //Адаптивный компонент ввода (с поддержкой ref для фокуса)
function AppInput({ const AppInput = React.forwardRef(function AppInput(
{
label, label,
value, value,
onChangeText, onChangeText,
@ -32,21 +33,31 @@ function AppInput({
inputStyle, inputStyle,
labelStyle, labelStyle,
...restProps ...restProps
}) { },
ref
) {
const [isFocused, setIsFocused] = React.useState(false); 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 ( return (
<View style={[styles.container, style]}> <View style={[styles.container, style]}>
{label && ( {label ? (
<AppText style={[styles.label, labelStyle]} variant="caption" weight="medium"> <AppText style={[styles.label, labelStyle]} variant="caption" weight="medium">
{label} {label}
</AppText> </AppText>
)} ) : null}
<TextInput <TextInput
ref={ref}
style={[styles.input, isFocused && styles.inputFocused, error && styles.inputError, disabled && styles.inputDisabled, inputStyle]} style={[styles.input, isFocused && styles.inputFocused, error && styles.inputError, disabled && styles.inputDisabled, inputStyle]}
value={value} value={value}
onChangeText={onChangeText} onChangeText={onChangeText}
@ -55,6 +66,7 @@ function AppInput({
secureTextEntry={secureTextEntry} secureTextEntry={secureTextEntry}
keyboardType={keyboardType} keyboardType={keyboardType}
autoCapitalize={autoCapitalize} autoCapitalize={autoCapitalize}
autoFocus={false}
editable={!disabled} editable={!disabled}
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
@ -64,14 +76,14 @@ function AppInput({
{...restProps} {...restProps}
/> />
{(error || helperText) && ( {error || helperText ? (
<AppText style={[styles.helperText, error && styles.helperTextError]} variant="caption"> <AppText style={[styles.helperText, error && styles.helperTextError]} variant="caption">
{error || helperText} {error || helperText}
</AppText> </AppText>
)} ) : null}
</View> </View>
); );
} });
//---------------- //----------------
//Интерфейс модуля //Интерфейс модуля

View File

@ -8,59 +8,43 @@
//--------------------- //---------------------
const React = require('react'); //React 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'); //Стили логотипа const styles = require('../../styles/common/AppLogo.styles'); //Стили логотипа
//----------- //-----------
//Тело модуля //Тело модуля
//----------- //-----------
//Иконка/логотип приложения //Возвращает стили контейнера в зависимости от размера
function AppLogo({ size = 'medium', style }) { function getContainerStyleBySize(size) {
//Выбор стилей в зависимости от размера
const getSizeStyles = React.useCallback(() => {
switch (size) { switch (size) {
case 'small': case LOGO_SIZE_KEYS.SMALL:
return { return styles.containerSmall;
container: styles.containerSmall, case LOGO_SIZE_KEYS.LARGE:
head: styles.headSmall, return styles.containerLarge;
eye: styles.eyeSmall, case LOGO_SIZE_KEYS.MEDIUM:
antenna: styles.antennaSmall
};
case 'large':
return {
container: styles.containerLarge,
head: styles.headLarge,
eye: styles.eyeLarge,
antenna: styles.antennaLarge
};
default: default:
return { return styles.containerMedium;
container: styles.containerMedium, }
head: styles.headMedium,
eye: styles.eyeMedium,
antenna: styles.antennaMedium
};
} }
}, [size]);
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 ( return (
<View style={[styles.container, sizeStyles.container, style]}> <View style={[styles.container, containerSizeStyle, style]}>
<View style={styles.robotContainer}> <Image source={APP_LOGO} style={styles.image} resizeMode="contain" accessibilityLabel="Логотип приложения" />
<View style={styles.antennasContainer}>
<View style={[styles.antenna, styles.antennaLeft, sizeStyles.antenna]} />
<View style={[styles.antenna, styles.antennaRight, sizeStyles.antenna]} />
</View>
<View style={[styles.head, sizeStyles.head]}>
<View style={styles.eyesContainer}>
<View style={[styles.eye, sizeStyles.eye]} />
<View style={[styles.eye, sizeStyles.eye]} />
</View>
</View>
</View>
</View> </View>
); );
} }

View File

@ -7,43 +7,41 @@
//Подключение библиотек //Подключение библиотек
//--------------------- //---------------------
const React = require("react"); //React и хуки const React = require('react'); //React и хуки
const { Modal, View, Text, Pressable } = require("react-native"); //Базовые компоненты const { Modal, View, Text, Pressable } = require('react-native'); //Базовые компоненты
const styles = require("../../styles/common/AppMessage.styles"); //Стили сообщения const { APP_MESSAGE_VARIANT } = require('../../config/messagingConfig'); //Типы сообщений
const styles = require('../../styles/common/AppMessage.styles'); //Стили сообщения
//---------
//Константы
//---------
//Типы сообщений
const APP_MESSAGE_VARIANT = {
INFO: "INFO",
WARN: "WARN",
ERR: "ERR",
SUCCESS: "SUCCESS"
};
//----------- //-----------
//Тело модуля //Тело модуля
//----------- //-----------
//Стиль кнопки сообщения при нажатии
function getMessageButtonPressableStyle(stylesRef, buttonStyle) {
return function styleFn({ pressed }) {
return [stylesRef.buttonBase, pressed && stylesRef.buttonPressed, buttonStyle];
};
}
//Кнопка сообщения //Кнопка сообщения
function AppMessageButton({ title, onPress, onDismiss, buttonStyle, textStyle }) { function AppMessageButton({ title, onPress, onDismiss, buttonStyle, textStyle }) {
//Обработчик нажатия - вызывает onPress и закрывает диалог //Обработчик нажатия - вызывает onPress и закрывает диалог
const handlePress = React.useCallback(() => { const handlePress = React.useCallback(() => {
//Сначала закрываем диалог //Сначала закрываем диалог
if (typeof onDismiss === "function") { if (typeof onDismiss === 'function') {
onDismiss(); onDismiss();
} }
//Затем выполняем действие кнопки //Затем выполняем действие кнопки
if (typeof onPress === "function") { if (typeof onPress === 'function') {
onPress(); onPress();
} }
}, [onPress, onDismiss]); }, [onPress, onDismiss]);
const getPressableStyle = React.useMemo(() => getMessageButtonPressableStyle(styles, buttonStyle), [buttonStyle]);
return ( return (
<Pressable style={({ pressed }) => [styles.buttonBase, pressed && styles.buttonPressed, buttonStyle]} onPress={handlePress}> <Pressable style={getPressableStyle} onPress={handlePress}>
<Text style={[styles.buttonText, textStyle]}>{title}</Text> <Text style={[styles.buttonText, textStyle]}>{title}</Text>
</Pressable> </Pressable>
); );
@ -81,28 +79,16 @@ function AppMessage({
//Обработчик закрытия //Обработчик закрытия
const handleClose = React.useCallback(() => { const handleClose = React.useCallback(() => {
if (typeof onRequestClose === "function") onRequestClose(); if (typeof onRequestClose === 'function') onRequestClose();
}, [onRequestClose]); }, [onRequestClose]);
return ( return (
<Modal <Modal animationType="fade" transparent={true} visible={!!visible} onRequestClose={handleClose}>
animationType="fade"
transparent={true}
visible={!!visible}
onRequestClose={handleClose}
>
<View style={styles.backdrop}> <View style={styles.backdrop}>
<View style={[styles.container, containerVariantStyle, containerStyle]}> <View style={[styles.container, containerVariantStyle, containerStyle]}>
<View style={[styles.header, headerStyle]}> <View style={[styles.header, headerStyle]}>
<Text style={[styles.title, titleStyle]}> <Text style={[styles.title, titleStyle]}>{title || ''}</Text>
{title || ""} <Pressable accessibilityRole="button" accessibilityLabel="Закрыть сообщение" onPress={handleClose} style={styles.closeButton}>
</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel="Закрыть сообщение"
onPress={handleClose}
style={styles.closeButton}
>
<Text style={styles.closeButtonText}>×</Text> <Text style={styles.closeButtonText}>×</Text>
</Pressable> </Pressable>
</View> </View>
@ -137,4 +123,3 @@ module.exports = {
AppMessage, AppMessage,
APP_MESSAGE_VARIANT APP_MESSAGE_VARIANT
}; };

View File

@ -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 (
<Pressable style={[styles.container, disabled && styles.containerDisabled, style]} onPress={handlePress} disabled={disabled}>
{label ? (
<AppText style={[styles.label, disabled && styles.labelDisabled, labelStyle]} numberOfLines={2}>
{label}
</AppText>
) : null}
<Switch
value={value}
onValueChange={handleValueChange}
disabled={disabled}
trackColor={{
false: APP_COLORS.borderMedium,
true: APP_COLORS.primaryLight
}}
thumbColor={value ? APP_COLORS.primary : APP_COLORS.white}
ios_backgroundColor={APP_COLORS.borderMedium}
/>
</Pressable>
);
}
//----------------
//Интерфейс модуля
//----------------
module.exports = AppSwitch;

View File

@ -16,7 +16,7 @@ const styles = require('../../styles/common/CopyButton.styles'); //Стили к
//Тело модуля //Тело модуля
//----------- //-----------
//Иконка копирования (два прямоугольника) //Иконка копирования
function CopyIcon() { function CopyIcon() {
return ( return (
<View style={styles.iconContainer}> <View style={styles.iconContainer}>
@ -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 }) { function CopyButton({ value, onCopy, onError, disabled = false, style }) {
//Обработчик нажатия //Обработчик нажатия
@ -49,11 +56,13 @@ function CopyButton({ value, onCopy, onError, disabled = false, style }) {
} }
}, [value, disabled, onCopy, onError]); }, [value, disabled, onCopy, onError]);
const getPressableStyle = React.useMemo(() => getCopyButtonPressableStyle(styles, disabled, style), [disabled, style]);
return ( return (
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Копировать в буфер обмена" accessibilityLabel="Копировать в буфер обмена"
style={({ pressed }) => [styles.button, pressed && !disabled && styles.buttonPressed, disabled && styles.buttonDisabled, style]} style={getPressableStyle}
onPress={handlePress} onPress={handlePress}
disabled={disabled} disabled={disabled}
> >

View File

@ -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 (
<Modal visible={visible} transparent={true} animationType="fade" statusBarTranslucent={true}>
<View style={styles.overlay}>
<View style={styles.container}>
<ActivityIndicator size="large" color={styles.indicator.color} />
{message ? (
<AppText style={styles.message} variant="body">
{message}
</AppText>
) : null}
</View>
</View>
</Modal>
);
}
//----------------
//Интерфейс модуля
//----------------
module.exports = LoadingOverlay;

View File

@ -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 (
<View style={[styles.container, style]}>
{label ? (
<AppText style={[styles.label, labelStyle]} variant="caption" weight="medium">
{label}
</AppText>
) : null}
<Pressable
style={[
styles.inputContainer,
isFocused && styles.inputContainerFocused,
error && styles.inputContainerError,
disabled && styles.inputContainerDisabled
]}
onPress={handleContainerPress}
disabled={disabled}
>
<TextInput
ref={setRef}
style={[styles.input, disabled && styles.inputDisabled, inputStyle]}
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor={styles.placeholder.color}
secureTextEntry={!showPassword}
keyboardType="default"
autoCapitalize="none"
autoCorrect={false}
autoFocus={false}
editable={!disabled}
onFocus={handleFocus}
onBlur={handleBlur}
accessible={true}
accessibilityLabel={label || placeholder}
accessibilityRole="text"
{...restProps}
/>
<Pressable style={styles.toggleButton} onPress={handleTogglePassword} disabled={disabled}>
<AppText style={[styles.toggleButtonText, disabled && styles.toggleButtonTextDisabled]}>
{showPassword ? 'Скрыть' : 'Показать'}
</AppText>
</Pressable>
</Pressable>
{error || helperText ? (
<AppText style={[styles.helperText, error && styles.helperTextError]} variant="caption">
{error || helperText}
</AppText>
) : null}
</View>
);
});
//----------------
//Интерфейс модуля
//----------------
module.exports = PasswordInput;

View File

@ -7,10 +7,10 @@
//Подключение библиотек //Подключение библиотек
//--------------------- //---------------------
const React = require("react"); //React const React = require('react'); //React
const { View } = require("react-native"); //Базовые компоненты const { View } = require('react-native'); //Базовые компоненты
const AppText = require("../common/AppText"); //Общий текстовый компонент const AppText = require('../common/AppText'); //Общий текстовый компонент
const styles = require("../../styles/inspections/InspectionItem.styles"); //Стили элемента const styles = require('../../styles/inspections/InspectionItem.styles'); //Стили элемента
//----------- //-----------
//Тело модуля //Тело модуля
@ -34,4 +34,3 @@ function InspectionItem({ item }) {
//---------------- //----------------
module.exports = InspectionItem; module.exports = InspectionItem;

View File

@ -33,7 +33,7 @@ function InspectionList({ inspections, isLoading, error, onRefresh }) {
if (!hasData && isLoading) { if (!hasData && isLoading) {
return ( return (
<View style={styles.centerContainer}> <View style={styles.centerContainer}>
<ActivityIndicator size="small" color="#2563EB" /> <ActivityIndicator size="small" color={styles.indicator.color} />
<AppText style={styles.centerText}>Загружаем данные...</AppText> <AppText style={styles.centerText}>Загружаем данные...</AppText>
</View> </View>
); );

View File

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

View File

@ -17,20 +17,24 @@ const styles = require('../../styles/layout/AppErrorBoundary.styles'); //Сти
//Тело модуля //Тело модуля
//----------- //-----------
//Компонент страницы ошибки //Обработчик перезагрузки приложения
function AppErrorPage({ error, onReload }) { function handleErrorReload(onReload) {
const message = error && error.message ? String(error.message) : 'Произошла непредвиденная ошибка приложения.';
const handleReload = () => {
if (typeof onReload === 'function') { if (typeof onReload === 'function') {
onReload(); onReload();
return; return;
} }
//Попытка перезагрузки для Web
if (typeof window !== 'undefined' && window.location && typeof window.location.reload === 'function') { if (typeof window !== 'undefined' && window.location && typeof window.location.reload === 'function') {
window.location.reload(); window.location.reload();
} }
}; }
//Компонент страницы ошибки
function AppErrorPage({ error, onReload }) {
const message = error && error.message ? String(error.message) : 'Произошла непредвиденная ошибка приложения.';
const handleReload = React.useCallback(() => {
handleErrorReload(onReload);
}, [onReload]);
return ( return (
<View style={styles.container}> <View style={styles.container}>

View File

@ -13,52 +13,12 @@ const AppText = require('../common/AppText'); //Общий текстовый к
const AppLogo = require('../common/AppLogo'); //Логотип приложения const AppLogo = require('../common/AppLogo'); //Логотип приложения
const { useAppModeContext } = require('./AppModeProvider'); //Контекст режима работы const { useAppModeContext } = require('./AppModeProvider'); //Контекст режима работы
const { useAppNavigationContext } = require('./AppNavigationProvider'); //Контекст навигации const { useAppNavigationContext } = require('./AppNavigationProvider'); //Контекст навигации
const { useAppMessagingContext } = require('./AppMessagingProvider'); //Контекст сообщений
const { getModeLabel, getModeDescription } = require('../../utils/appInfo'); //Утилиты информации
const styles = require('../../styles/layout/AppHeader.styles'); //Стили заголовка 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 (
<Pressable style={({ pressed }) => [styles.modeContainer, styleConfig.color, pressed && styles.modePressed]} onPress={onPress}>
<AppText style={[styles.modeText, styleConfig.textColor]}>{label}</AppText>
</Pressable>
);
}
//Иконка стрелки назад //Иконка стрелки назад
function BackArrowIcon() { function BackArrowIcon() {
return ( return (
@ -69,33 +29,28 @@ function BackArrowIcon() {
); );
} }
//Стиль кнопки назад при нажатии
function getBackButtonPressableStyle(stylesRef) {
return function styleFn({ pressed }) {
return [stylesRef.backButton, pressed && stylesRef.backButtonPressed];
};
}
//Кнопка назад //Кнопка назад
function BackButton({ onPress }) { function BackButton({ onPress }) {
const getPressableStyle = React.useMemo(() => getBackButtonPressableStyle(styles), []);
return ( return (
<Pressable <Pressable accessibilityRole="button" accessibilityLabel="Назад" style={getPressableStyle} onPress={onPress}>
accessibilityRole="button"
accessibilityLabel="Назад"
style={({ pressed }) => [styles.backButton, pressed && styles.backButtonPressed]}
onPress={onPress}
>
<BackArrowIcon /> <BackArrowIcon />
</Pressable> </Pressable>
); );
} }
//Заголовок приложения //Заголовок приложения
function AppHeader({ function AppHeader({ title, subtitle, showMenuButton = true, onMenuPress, showBackButton = false, onBackPress }) {
title,
subtitle,
showMenuButton = true,
onMenuPress,
showModeIndicator = true,
showBackButton = false,
onBackPress
}) {
const { mode } = useAppModeContext(); const { mode } = useAppModeContext();
const { currentScreen, SCREENS } = useAppNavigationContext(); const { currentScreen, SCREENS } = useAppNavigationContext();
const { showInfo } = useAppMessagingContext();
//Получение заголовка экрана //Получение заголовка экрана
const getTitle = React.useCallback(() => { const getTitle = React.useCallback(() => {
@ -125,15 +80,12 @@ function AppHeader({
} }
}, [subtitle, currentScreen, mode, SCREENS.MAIN, SCREENS.SETTINGS]); }, [subtitle, currentScreen, mode, SCREENS.MAIN, SCREENS.SETTINGS]);
//Обработчик нажатия на индикатор режима (универсальный для всех экранов) //Стиль кнопки меню при нажатии
const handleModeIndicatorPress = React.useCallback(() => { const getMenuButtonPressableStyle = React.useMemo(() => {
const modeLabel = getModeLabel(mode); return function styleFn({ pressed }) {
const modeDescription = getModeDescription(mode); return [styles.menuButton, pressed && styles.menuButtonPressed];
};
showInfo(`Текущий режим: ${modeLabel}`, { }, []);
message: modeDescription
});
}, [mode, showInfo]);
//Отрисовка левой части шапки (логотип или кнопка назад) //Отрисовка левой части шапки (логотип или кнопка назад)
const renderLeftSection = () => { const renderLeftSection = () => {
@ -160,11 +112,8 @@ function AppHeader({
) : null} ) : null}
</View> </View>
<View style={styles.controls}>
{showModeIndicator ? <ModeIndicator mode={mode} onPress={handleModeIndicatorPress} /> : null}
{showMenuButton ? ( {showMenuButton ? (
<Pressable style={({ pressed }) => [styles.menuButton, pressed && styles.menuButtonPressed]} onPress={onMenuPress}> <Pressable style={getMenuButtonPressableStyle} onPress={onMenuPress}>
<View style={styles.menuButtonIcon}> <View style={styles.menuButtonIcon}>
<View style={styles.menuButtonIconLine} /> <View style={styles.menuButtonIconLine} />
<View style={styles.menuButtonIconLine} /> <View style={styles.menuButtonIconLine} />
@ -174,7 +123,6 @@ function AppHeader({
) : null} ) : null}
</View> </View>
</View> </View>
</View>
); );
} }

View File

@ -36,7 +36,10 @@ function AppLocalDbProvider({ children }) {
clearSettings: api.clearSettings, clearSettings: api.clearSettings,
clearInspections: api.clearInspections, clearInspections: api.clearInspections,
vacuum: api.vacuum, vacuum: api.vacuum,
checkTableExists: api.checkTableExists checkTableExists: api.checkTableExists,
setAuthSession: api.setAuthSession,
getAuthSession: api.getAuthSession,
clearAuthSession: api.clearAuthSession
}), }),
[ [
api.isDbReady, api.isDbReady,
@ -51,7 +54,10 @@ function AppLocalDbProvider({ children }) {
api.clearSettings, api.clearSettings,
api.clearInspections, api.clearInspections,
api.vacuum, api.vacuum,
api.checkTableExists api.checkTableExists,
api.setAuthSession,
api.getAuthSession,
api.clearAuthSession
] ]
); );

View File

@ -38,7 +38,7 @@ function AppNavigationProvider({ children }) {
}, [canGoBack, goBack]); }, [canGoBack, goBack]);
//Подключаем обработчик кнопки "Назад" //Подключаем обработчик кнопки "Назад"
useHardwareBackPress(handleHardwareBackPress, [handleHardwareBackPress]); useHardwareBackPress(handleHardwareBackPress);
//Мемоизация значения контекста с перечислением отдельных свойств //Мемоизация значения контекста с перечислением отдельных свойств
const value = React.useMemo( const value = React.useMemo(
@ -49,6 +49,7 @@ function AppNavigationProvider({ children }) {
navigate: navigationApi.navigate, navigate: navigationApi.navigate,
goBack: navigationApi.goBack, goBack: navigationApi.goBack,
reset: navigationApi.reset, reset: navigationApi.reset,
setInitialScreen: navigationApi.setInitialScreen,
canGoBack: navigationApi.canGoBack canGoBack: navigationApi.canGoBack
}), }),
[ [
@ -58,6 +59,7 @@ function AppNavigationProvider({ children }) {
navigationApi.navigate, navigationApi.navigate,
navigationApi.goBack, navigationApi.goBack,
navigationApi.reset, navigationApi.reset,
navigationApi.setInitialScreen,
navigationApi.canGoBack navigationApi.canGoBack
] ]
); );

View File

@ -8,7 +8,7 @@
//--------------------- //---------------------
const React = require('react'); //React и хуки const React = require('react'); //React и хуки
const { usePreTripInspections } = require('../../hooks/usePreTripInspections'); //Хук предметной области const usePreTripInspections = require('../../hooks/usePreTripInspections'); //Хук предметной области
//----------- //-----------
//Тело модуля //Тело модуля

View File

@ -11,6 +11,9 @@ const React = require('react'); //React и хуки
const { useColorScheme } = require('react-native'); //Определение темы устройства const { useColorScheme } = require('react-native'); //Определение темы устройства
const { SafeAreaProvider } = require('react-native-safe-area-context'); //Провайдер безопасной области const { SafeAreaProvider } = require('react-native-safe-area-context'); //Провайдер безопасной области
const AppShell = require('./AppShell'); //Оболочка приложения const AppShell = require('./AppShell'); //Оболочка приложения
const { useAppNavigationContext } = require('./AppNavigationProvider'); //Контекст навигации
const { useAppAuthContext } = require('./AppAuthProvider'); //Контекст авторизации
const { useAppLocalDbContext } = require('./AppLocalDbProvider'); //Контекст локальной БД
//----------- //-----------
//Тело модуля //Тело модуля
@ -21,6 +24,28 @@ function AppRoot() {
const colorScheme = useColorScheme(); const colorScheme = useColorScheme();
const isDarkMode = colorScheme === 'dark'; 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 ( return (
<SafeAreaProvider> <SafeAreaProvider>
<AppShell isDarkMode={isDarkMode} /> <AppShell isDarkMode={isDarkMode} />

View File

@ -12,6 +12,7 @@ const { StatusBar, Platform } = require('react-native'); //Базовые ком
const { useAppNavigationContext } = require('./AppNavigationProvider'); //Контекст навигации const { useAppNavigationContext } = require('./AppNavigationProvider'); //Контекст навигации
const MainScreen = require('../../screens/MainScreen'); //Главный экран const MainScreen = require('../../screens/MainScreen'); //Главный экран
const SettingsScreen = require('../../screens/SettingsScreen'); //Экран настроек const SettingsScreen = require('../../screens/SettingsScreen'); //Экран настроек
const AuthScreen = require('../../screens/AuthScreen'); //Экран авторизации
const AdaptiveView = require('../common/AdaptiveView'); //Адаптивный контейнер const AdaptiveView = require('../common/AdaptiveView'); //Адаптивный контейнер
const styles = require('../../styles/layout/AppShell.styles'); //Стили оболочки const styles = require('../../styles/layout/AppShell.styles'); //Стили оболочки
@ -29,10 +30,12 @@ function AppShell({ isDarkMode }) {
return <MainScreen />; return <MainScreen />;
case SCREENS.SETTINGS: case SCREENS.SETTINGS:
return <SettingsScreen />; return <SettingsScreen />;
case SCREENS.AUTH:
return <AuthScreen />;
default: default:
return <MainScreen />; return <MainScreen />;
} }
}, [currentScreen, SCREENS.MAIN, SCREENS.SETTINGS]); }, [currentScreen, SCREENS.MAIN, SCREENS.SETTINGS, SCREENS.AUTH]);
//Определяем цвет status bar в зависимости от темы //Определяем цвет status bar в зависимости от темы
const statusBarStyle = isDarkMode ? 'light-content' : 'dark-content'; const statusBarStyle = isDarkMode ? 'light-content' : 'dark-content';

View File

@ -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 }) { function MenuHeader({ title, onClose, showCloseButton = true, style }) {
const handleClose = React.useCallback(() => { const handleClose = React.useCallback(() => {
@ -24,18 +31,15 @@ function MenuHeader({ title, onClose, showCloseButton = true, style }) {
} }
}, [onClose]); }, [onClose]);
const getCloseButtonPressableStyle = React.useMemo(() => getMenuCloseButtonPressableStyle(styles), []);
return ( return (
<View style={[styles.header, style]}> <View style={[styles.header, style]}>
<AppText style={styles.title} numberOfLines={2}> <AppText style={styles.title} numberOfLines={2}>
{title || 'Меню'} {title || 'Меню'}
</AppText> </AppText>
{showCloseButton ? ( {showCloseButton ? (
<Pressable <Pressable accessibilityRole="button" accessibilityLabel="Закрыть меню" onPress={handleClose} style={getCloseButtonPressableStyle}>
accessibilityRole="button"
accessibilityLabel="Закрыть меню"
onPress={handleClose}
style={({ pressed }) => [styles.closeButton, pressed && styles.closeButtonPressed]}
>
<View style={styles.closeButtonIcon}> <View style={styles.closeButtonIcon}>
<AppText style={styles.closeButtonText}>×</AppText> <AppText style={styles.closeButtonText}>×</AppText>
</View> </View>

View File

@ -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 }) { function MenuItem({ title, icon, onPress, isDestructive = false, disabled = false, style, textStyle }) {
const handlePress = React.useCallback(() => { const handlePress = React.useCallback(() => {
@ -24,18 +37,13 @@ function MenuItem({ title, icon, onPress, isDestructive = false, disabled = fals
} }
}, [disabled, onPress]); }, [disabled, onPress]);
const getPressableStyle = React.useMemo(
() => getMenuItemPressableStyle(styles, isDestructive, disabled, style),
[isDestructive, disabled, style]
);
return ( return (
<Pressable <Pressable style={getPressableStyle} onPress={handlePress} disabled={disabled}>
style={({ pressed }) => [
styles.menuItem,
pressed && !disabled && styles.menuItemPressed,
isDestructive && styles.menuItemDestructive,
disabled && styles.menuItemDisabled,
style
]}
onPress={handlePress}
disabled={disabled}
>
<View style={styles.menuItemContent}> <View style={styles.menuItemContent}>
{icon ? <View style={styles.menuItemIcon}>{icon}</View> : null} {icon ? <View style={styles.menuItemIcon}>{icon}</View> : null}
<AppText <AppText

View File

@ -18,6 +18,28 @@ const styles = require('../../styles/menu/MenuList.styles'); //Стили спи
//Тело модуля //Тело модуля
//----------- //-----------
//Строка меню с элементом
function MenuItemRow({ item, index, items, onItemPress }) {
const handlePress = React.useCallback(() => {
onItemPress(item);
}, [item, onItemPress]);
return (
<View>
<MenuItem
title={item.title}
icon={item.icon}
onPress={handlePress}
isDestructive={item.isDestructive}
disabled={item.disabled}
style={item.style}
textStyle={item.textStyle}
/>
{item.showDivider && index < items.length - 1 && <MenuDivider />}
</View>
);
}
//Список элементов меню //Список элементов меню
function MenuList({ items = [], onClose, style }) { function MenuList({ items = [], onClose, style }) {
const handleItemPress = React.useCallback( const handleItemPress = React.useCallback(
@ -38,20 +60,15 @@ function MenuList({ items = [], onClose, style }) {
return ( return (
<ScrollView style={[styles.scrollView, style]} showsVerticalScrollIndicator={false} bounces={false}> <ScrollView style={[styles.scrollView, style]} showsVerticalScrollIndicator={false} bounces={false}>
{items.map((item, index) => ( {items.map((item, index) => {
<View key={item.id || `menu-item-${index}`}> //Элемент-разделитель
<MenuItem if (item.type === 'divider') {
title={item.title} return <MenuDivider key={item.id || `menu-divider-${index}`} />;
icon={item.icon} }
onPress={() => handleItemPress(item)}
isDestructive={item.isDestructive} //Обычный элемент меню
disabled={item.disabled} return <MenuItemRow key={item.id || `menu-item-${index}`} item={item} index={index} items={items} onItemPress={handleItemPress} />;
style={item.style} })}
textStyle={item.textStyle}
/>
{item.showDivider && index < items.length - 1 && <MenuDivider />}
</View>
))}
</ScrollView> </ScrollView>
); );
} }

View File

@ -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 (
<View style={styles.container}>
<View style={[styles.modeContainer, getModeStyle()]}>
<AppText style={[styles.modeText, getModeTextStyle()]}>{getModeLabel(mode)}</AppText>
</View>
{isConnected && (username || organization) ? (
<View style={styles.userInfo}>
{username ? (
<View style={styles.userRow}>
<AppText style={styles.userLabel}>Пользователь:</AppText>
<AppText style={styles.userValue} numberOfLines={1}>
{username}
</AppText>
</View>
) : null}
{organization ? (
<View style={styles.userRow}>
<AppText style={styles.userLabel}>Организация:</AppText>
<AppText style={styles.userValue} numberOfLines={1}>
{organization}
</AppText>
</View>
) : null}
</View>
) : null}
</View>
);
}
//----------------
//Интерфейс модуля
//----------------
module.exports = MenuUserInfo;

View File

@ -11,24 +11,23 @@ const React = require('react'); //React
const { Modal, View, Animated, Pressable } = require('react-native'); //Базовые компоненты const { Modal, View, Animated, Pressable } = require('react-native'); //Базовые компоненты
const { useSafeAreaInsets } = require('react-native-safe-area-context'); //Отступы безопасной области const { useSafeAreaInsets } = require('react-native-safe-area-context'); //Отступы безопасной области
const MenuHeader = require('./MenuHeader'); //Заголовок меню const MenuHeader = require('./MenuHeader'); //Заголовок меню
const MenuUserInfo = require('./MenuUserInfo'); //Информация о пользователе
const MenuList = require('./MenuList'); //Список элементов меню const MenuList = require('./MenuList'); //Список элементов меню
const { widthPercentage, isTablet } = require('../../utils/responsive'); //Адаптивные утилиты 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 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 calculateMenuWidth = () => {
const percent = isTablet() ? MENU_WIDTH_TABLET_PERCENT : MENU_WIDTH_PHONE_PERCENT; 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(); const insets = useSafeAreaInsets();
@ -123,13 +124,7 @@ function SideMenu({ visible, onClose, items = [], title = 'Меню', headerStyl
}, [visible, modalVisible, openMenu, closeMenu]); }, [visible, modalVisible, openMenu, closeMenu]);
return ( return (
<Modal <Modal visible={modalVisible} transparent={true} animationType="none" statusBarTranslucent={true} onRequestClose={handleRequestClose}>
visible={modalVisible}
transparent={true}
animationType="none"
statusBarTranslucent={true}
onRequestClose={handleRequestClose}
>
<View style={styles.modalContainer}> <View style={styles.modalContainer}>
<Animated.View style={[styles.backdrop, { opacity: backdropOpacity }]}> <Animated.View style={[styles.backdrop, { opacity: backdropOpacity }]}>
<Pressable style={styles.backdropPressable} onPress={handleBackdropPress} /> <Pressable style={styles.backdropPressable} onPress={handleBackdropPress} />
@ -148,6 +143,8 @@ function SideMenu({ visible, onClose, items = [], title = 'Меню', headerStyl
> >
<MenuHeader title={title} onClose={onClose} style={[styles.header, headerStyle]} /> <MenuHeader title={title} onClose={onClose} style={[styles.header, headerStyle]} />
<MenuUserInfo mode={mode} username={username} organization={organization} isConnected={isConnected} />
<MenuList items={items} onClose={onClose} style={contentStyle} /> <MenuList items={items} onClose={onClose} style={contentStyle} />
</Animated.View> </Animated.View>
</View> </View>

View File

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

View File

@ -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 = { const UI = {
//Отступы по умолчанию (адаптивные) //Отступы по умолчанию (адаптивные)
@ -89,8 +65,6 @@ const COMPATIBILITY = {
//---------------- //----------------
module.exports = { module.exports = {
SYSTEM,
LOCAL_DB,
UI, UI,
COMPATIBILITY COMPATIBILITY
}; };

View File

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

View File

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

View File

@ -0,0 +1,19 @@
/*
Предрейсовые осмотры - мобильное приложение
Конфигурация локальной базы данных
*/
//---------
//Константы
//---------
//Имя файла базы данных SQLite
const DB_NAME = 'pretrip_inspections.db';
//----------------
//Интерфейс модуля
//----------------
module.exports = {
DB_NAME
};

View File

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

View File

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

View File

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

View File

@ -0,0 +1,23 @@
/*
Предрейсовые осмотры - мобильное приложение
Тексты сообщений приложения
*/
//---------
//Константы
//---------
//Сообщение при потере связи с сервером и переходе в офлайн
const CONNECTION_LOST_MESSAGE = 'Нет связи с сервером. Приложение переведено в режим офлайн.';
//Заголовок сообщения при переходе в режим офлайн
const OFFLINE_MODE_TITLE = 'Режим офлайн';
//----------------
//Интерфейс модуля
//----------------
module.exports = {
CONNECTION_LOST_MESSAGE,
OFFLINE_MODE_TITLE
};

View File

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

View File

@ -0,0 +1,23 @@
/*
Предрейсовые осмотры - мобильное приложение
Базовые размеры для адаптивных утилит
*/
//---------
//Константы
//---------
//Базовая ширина экрана (iPhone 11/12/13/14)
const BASE_WIDTH = 375;
//Базовая высота экрана
const BASE_HEIGHT = 812;
//----------------
//Интерфейс модуля
//----------------
module.exports = {
BASE_WIDTH,
BASE_HEIGHT
};

View File

@ -0,0 +1,23 @@
/*
Предрейсовые осмотры - мобильное приложение
Константы экранов навигации
*/
//---------
//Константы
//---------
//Экраны приложения
const SCREENS = {
MAIN: 'MAIN',
SETTINGS: 'SETTINGS',
AUTH: 'AUTH'
};
//----------------
//Интерфейс модуля
//----------------
module.exports = {
SCREENS
};

View File

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

View File

@ -44,6 +44,7 @@ const APP_COLORS = {
borderSubtle: '#E2E8F0', borderSubtle: '#E2E8F0',
borderMedium: '#CBD5E1', borderMedium: '#CBD5E1',
overlay: 'rgba(15, 23, 42, 0.5)', overlay: 'rgba(15, 23, 42, 0.5)',
shadow: '#000000',
//Семантические //Семантические
info: '#3B82F6', info: '#3B82F6',

View File

@ -11,12 +11,7 @@ const { open } = require('react-native-quick-sqlite');
//Импорт утилиты для загрузки SQL файлов //Импорт утилиты для загрузки SQL файлов
const SQLFileLoader = require('./sql/SQLFileLoader'); const SQLFileLoader = require('./sql/SQLFileLoader');
const { DB_NAME } = require('../config/database'); //Имя базы данных
//---------
//Константы
//---------
const DB_NAME = 'pretrip_inspections.db';
//----------- //-----------
//Тело модуля //Тело модуля
@ -72,6 +67,9 @@ class SQLiteDatabase {
await this.executeQuery(this.sqlQueries.CREATE_TABLE_INSPECTIONS); await this.executeQuery(this.sqlQueries.CREATE_TABLE_INSPECTIONS);
console.log('Таблица 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); await this.executeQuery(this.sqlQueries.CREATE_INDEX_INSPECTIONS_STATUS);
console.log('Индекс idx_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) { async checkTableExists(tableName) {
try { try {

View File

@ -10,6 +10,7 @@
//Таблицы //Таблицы
const CREATE_TABLE_APP_SETTINGS = require('./settings/create_table_app_settings.sql'); const CREATE_TABLE_APP_SETTINGS = require('./settings/create_table_app_settings.sql');
const CREATE_TABLE_INSPECTIONS = require('./inspections/create_table_inspections.sql'); const CREATE_TABLE_INSPECTIONS = require('./inspections/create_table_inspections.sql');
const CREATE_TABLE_AUTH_SESSION = require('./auth/create_table_auth_session.sql');
//Индексы //Индексы
const CREATE_INDEX_INSPECTIONS_STATUS = require('./inspections/create_index_inspections_status.sql'); const CREATE_INDEX_INSPECTIONS_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_DELETE_ALL = require('./inspections/delete_all_inspections.sql');
const INSPECTIONS_COUNT = require('./inspections/count_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_CHECK_TABLE = require('./utility/check_table_exists.sql');
const UTILITY_DROP_TABLE = require('./utility/drop_table.sql'); const UTILITY_DROP_TABLE = require('./utility/drop_table.sql');
@ -44,6 +50,7 @@ const UTILITY_VACUUM = require('./utility/vacuum.sql');
const SQLQueries = { const SQLQueries = {
CREATE_TABLE_APP_SETTINGS, CREATE_TABLE_APP_SETTINGS,
CREATE_TABLE_INSPECTIONS, CREATE_TABLE_INSPECTIONS,
CREATE_TABLE_AUTH_SESSION,
CREATE_INDEX_INSPECTIONS_STATUS, CREATE_INDEX_INSPECTIONS_STATUS,
CREATE_INDEX_INSPECTIONS_CREATED, CREATE_INDEX_INSPECTIONS_CREATED,
SETTINGS_GET, SETTINGS_GET,
@ -58,6 +65,9 @@ const SQLQueries = {
INSPECTIONS_DELETE, INSPECTIONS_DELETE,
INSPECTIONS_DELETE_ALL, INSPECTIONS_DELETE_ALL,
INSPECTIONS_COUNT, INSPECTIONS_COUNT,
AUTH_SESSION_SET,
AUTH_SESSION_GET,
AUTH_SESSION_CLEAR,
UTILITY_CHECK_TABLE, UTILITY_CHECK_TABLE,
UTILITY_DROP_TABLE, UTILITY_DROP_TABLE,
UTILITY_VACUUM UTILITY_VACUUM

View File

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

View File

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

View File

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

View File

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

View File

@ -8,7 +8,7 @@
//--------------------- //---------------------
const React = require('react'); //React и хуки 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 headerStyle: null
}; };
//Типы сообщений //Типы сообщений (алиасы для вариантов)
const MSG_TYPE = { const MSG_TYPE = {
INFO: APP_MESSAGE_VARIANT.INFO, INFO: APP_MESSAGE_VARIANT.INFO,
WARN: APP_MESSAGE_VARIANT.WARN, WARN: APP_MESSAGE_VARIANT.WARN,
@ -36,12 +36,6 @@ const MSG_TYPE = {
SUCCESS: APP_MESSAGE_VARIANT.SUCCESS SUCCESS: APP_MESSAGE_VARIANT.SUCCESS
}; };
//Типы действий
const MSG_AT = {
SHOW_MSG: 'SHOW_MSG',
HIDE_MSG: 'HIDE_MSG'
};
//----------- //-----------
//Тело модуля //Тело модуля
//----------- //-----------

View File

@ -8,16 +8,7 @@
//--------------------- //---------------------
const React = require('react'); //React и хуки const React = require('react'); //React и хуки
const { SCREENS } = require('../config/routes'); //Экраны навигации
//---------
//Константы
//---------
//Экраны приложения
const SCREENS = {
MAIN: 'MAIN',
SETTINGS: 'SETTINGS'
};
//----------- //-----------
//Тело модуля //Тело модуля
@ -25,10 +16,11 @@ const SCREENS = {
//Хук навигации приложения //Хук навигации приложения
const useAppNavigation = () => { const useAppNavigation = () => {
//Начальный экран - AUTH (до определения статуса авторизации)
const [navigationState, setNavigationState] = React.useState({ const [navigationState, setNavigationState] = React.useState({
currentScreen: SCREENS.MAIN, currentScreen: SCREENS.AUTH,
screenParams: {}, 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 { return {
SCREENS, SCREENS,
currentScreen: navigationState.currentScreen, currentScreen: navigationState.currentScreen,
@ -75,6 +76,7 @@ const useAppNavigation = () => {
navigate, navigate,
goBack, goBack,
reset, reset,
setInitialScreen,
canGoBack: navigationState.history.length > 1 canGoBack: navigationState.history.length > 1
}; };
}; };

628
rn/app/src/hooks/useAuth.js Normal file
View File

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

View File

@ -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(() => { React.useEffect(() => {
//BackHandler работает только на Android //BackHandler работает только на Android
if (Platform.OS !== 'android') { if (Platform.OS !== 'android') {
@ -24,8 +32,8 @@ const useHardwareBackPress = (handler, deps = []) => {
//Обработчик нажатия кнопки "Назад" //Обработчик нажатия кнопки "Назад"
const backHandler = () => { const backHandler = () => {
if (typeof handler === 'function') { if (typeof handlerRef.current === 'function') {
return handler(); return handlerRef.current();
} }
return false; return false;
}; };
@ -37,8 +45,7 @@ const useHardwareBackPress = (handler, deps = []) => {
return () => { return () => {
subscription.remove(); subscription.remove();
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps }, []);
}, deps);
}; };
//---------------- //----------------

View File

@ -250,6 +250,54 @@ function useLocalDb() {
[isDbReady] [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 { return {
isDbReady, isDbReady,
inspections, inspections,
@ -263,7 +311,10 @@ function useLocalDb() {
clearSettings, clearSettings,
clearInspections, clearInspections,
vacuum, vacuum,
checkTableExists checkTableExists,
setAuthSession,
getAuthSession,
clearAuthSession
}; };
} }

View File

@ -15,17 +15,8 @@
const React = require('react'); //React и хуки const React = require('react'); //React и хуки
const useAppServer = require('./useAppServer'); //Хук для сервера приложений const useAppServer = require('./useAppServer'); //Хук для сервера приложений
const { useAppLocalDbContext } = require('../components/layout/AppLocalDbProvider'); //Контекст локальной БД const { useAppLocalDbContext } = require('../components/layout/AppLocalDbProvider'); //Контекст локальной БД
const useAppMode = require('./useAppMode'); //Хук режима работы приложения const { useAppModeContext } = require('../components/layout/AppModeProvider'); //Контекст режима работы
const { LOAD_STATUS_IDLE, LOAD_STATUS_LOADING, LOAD_STATUS_DONE, LOAD_STATUS_ERROR } = require('../config/loadStatus'); //Статусы загрузки
//---------
//Константы
//---------
//Статусы загрузки данных
const LOAD_STATUS_IDLE = 'IDLE';
const LOAD_STATUS_LOADING = 'LOADING';
const LOAD_STATUS_DONE = 'DONE';
const LOAD_STATUS_ERROR = 'ERROR';
//----------- //-----------
//Тело модуля //Тело модуля
@ -35,7 +26,7 @@ const LOAD_STATUS_ERROR = 'ERROR';
function usePreTripInspections() { function usePreTripInspections() {
const { executeAction, isRespErr, getRespErrMessage, RESP_STATUS_OK } = useAppServer(); const { executeAction, isRespErr, getRespErrMessage, RESP_STATUS_OK } = useAppServer();
const { inspections, loadInspections, saveInspection, isDbReady } = useAppLocalDbContext(); 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 [loadStatus, setLoadStatus] = React.useState(LOAD_STATUS_IDLE);
const [error, setError] = React.useState(null); const [error, setError] = React.useState(null);
@ -73,7 +64,7 @@ function usePreTripInspections() {
payload: {} payload: {}
}); });
//Ошибка сервера приложений - пробуем взять данные из локальной БД //Ошибка запроса — используем локальные данные
if (isRespErr(serverResponse) || serverResponse.status !== RESP_STATUS_OK) { if (isRespErr(serverResponse) || serverResponse.status !== RESP_STATUS_OK) {
const localInspections = await loadInspections(); const localInspections = await loadInspections();
setLoadStatus(localInspections.length > 0 ? LOAD_STATUS_DONE : LOAD_STATUS_ERROR); setLoadStatus(localInspections.length > 0 ? LOAD_STATUS_DONE : LOAD_STATUS_ERROR);
@ -110,7 +101,6 @@ function usePreTripInspections() {
await saveInspection(safeInspection); await saveInspection(safeInspection);
//Если приложение в режиме ONLINE - отправляем данные на сервер приложений //Если приложение в режиме ONLINE - отправляем данные на сервер приложений
//TODO: вызов конкретного метода сервера
if (mode === APP_MODE.ONLINE) { if (mode === APP_MODE.ONLINE) {
await executeAction({ await executeAction({
path: 'api/pretrip/inspections/save', path: 'api/pretrip/inspections/save',
@ -138,10 +128,4 @@ function usePreTripInspections() {
//Интерфейс модуля //Интерфейс модуля
//---------------- //----------------
module.exports = { module.exports = usePreTripInspections;
usePreTripInspections,
LOAD_STATUS_IDLE,
LOAD_STATUS_LOADING,
LOAD_STATUS_DONE,
LOAD_STATUS_ERROR
};

View File

@ -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 (
<AdaptiveView padding={false}>
<AppHeader showBackButton={canGoBack} onBackPress={handleBackPress} onMenuPress={handleMenuOpen} />
<KeyboardAvoidingView
style={styles.keyboardAvoidingView}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 20}
>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
<View style={styles.logoContainer}>
<AppLogo size="large" />
</View>
<View style={styles.formContainer}>
<AppText style={styles.title} variant="h2" weight="bold">
Вход в приложение
</AppText>
{shouldShowServerUrl ? (
<AppInput
ref={serverInputRef}
key="server-input"
label="Сервер"
value={serverUrl}
onChangeText={setServerUrl}
placeholder="https://example.com/api"
keyboardType="url"
autoCapitalize="none"
autoCorrect={false}
disabled={isLoading}
blurOnSubmit={false}
returnKeyType="next"
onSubmitEditing={handleServerSubmitEditing}
/>
) : null}
<AppInput
ref={loginInputRef}
key="login-input"
label="Логин"
value={username}
onChangeText={setUsername}
placeholder="Введите логин"
autoCapitalize="none"
autoCorrect={false}
disabled={isLoading || isFromMenu}
blurOnSubmit={false}
returnKeyType="next"
onSubmitEditing={handleLoginSubmitEditing}
/>
<PasswordInput
ref={passwordInputRef}
key="password-input"
label="Пароль"
value={password}
onChangeText={setPassword}
placeholder="Введите пароль"
showPassword={showPassword}
onTogglePassword={handleTogglePassword}
disabled={isLoading}
blurOnSubmit={true}
returnKeyType="done"
onSubmitEditing={handlePasswordSubmitEditing}
/>
<View style={styles.switchContainer}>
<AppSwitch label="Сохранить пароль" value={savePassword} onValueChange={handleToggleSavePassword} disabled={isLoading} />
</View>
<AppButton
title={isLoading ? 'Вход...' : 'Войти'}
onPress={handleLogin}
disabled={isLoading || !isSettingsLoaded}
style={styles.loginButton}
textStyle={styles.loginButtonText}
/>
</View>
</ScrollView>
</KeyboardAvoidingView>
<SideMenu
visible={menuVisible}
onClose={handleMenuClose}
items={menuItems}
title="Меню"
mode={mode}
username={session?.userName}
organization={session?.companyName}
/>
<OrganizationSelectDialog
visible={showOrgDialog}
organizations={organizations}
onSelect={handleSelectOrganization}
onCancel={handleCancelOrganization}
title="Выберите организацию"
/>
<LoadingOverlay visible={isLoading} message="Выполняется вход..." />
</AdaptiveView>
);
}
//----------------
//Интерфейс модуля
//----------------
module.exports = AuthScreen;

View File

@ -9,16 +9,22 @@
const React = require('react'); //React и хуки const React = require('react'); //React и хуки
const { View } = require('react-native'); //Базовые компоненты 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 { useAppMessagingContext } = require('../components/layout/AppMessagingProvider'); //Контекст сообщений
const { useAppModeContext } = require('../components/layout/AppModeProvider'); //Контекст режима работы const { useAppModeContext } = require('../components/layout/AppModeProvider'); //Контекст режима работы
const { useAppNavigationContext } = require('../components/layout/AppNavigationProvider'); //Контекст навигации const { useAppNavigationContext } = require('../components/layout/AppNavigationProvider'); //Контекст навигации
const { useAppLocalDbContext } = require('../components/layout/AppLocalDbProvider'); //Контекст локальной БД const { useAppLocalDbContext } = require('../components/layout/AppLocalDbProvider'); //Контекст локальной БД
const { useAppPreTripInspectionsContext } = require('../components/layout/AppPreTripInspectionsProvider'); //Контекст осмотров const { useAppPreTripInspectionsContext } = require('../components/layout/AppPreTripInspectionsProvider'); //Контекст осмотров
const { useAppAuthContext } = require('../components/layout/AppAuthProvider'); //Контекст авторизации
const AppHeader = require('../components/layout/AppHeader'); //Заголовок с меню const AppHeader = require('../components/layout/AppHeader'); //Заголовок с меню
const SideMenu = require('../components/menu/SideMenu'); //Боковое меню const SideMenu = require('../components/menu/SideMenu'); //Боковое меню
const InspectionList = require('../components/inspections/InspectionList'); //Список осмотров const InspectionList = require('../components/inspections/InspectionList'); //Список осмотров
const LoadingOverlay = require('../components/common/LoadingOverlay'); //Оверлей загрузки
const OrganizationSelectDialog = require('../components/auth/OrganizationSelectDialog'); //Диалог выбора организации
const { getAppInfo } = require('../utils/appInfo'); //Информация о приложении 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'); //Стили экрана const styles = require('../styles/screens/MainScreen.styles'); //Стили экрана
//----------- //-----------
@ -28,14 +34,31 @@ const styles = require('../styles/screens/MainScreen.styles'); //Стили эк
//Главный экран приложения //Главный экран приложения
function MainScreen() { function MainScreen() {
const { inspections, loadStatus, error, isDbReady, refreshInspections } = useAppPreTripInspectionsContext(); const { inspections, loadStatus, error, isDbReady, refreshInspections } = useAppPreTripInspectionsContext();
const { showInfo } = useAppMessagingContext(); const { showInfo, showError, showSuccess } = useAppMessagingContext();
const { mode } = useAppModeContext(); const { mode, setOnline, setOffline, setNotConnected } = useAppModeContext();
const { navigate, SCREENS } = useAppNavigationContext(); const { navigate, SCREENS, setInitialScreen } = useAppNavigationContext();
const { getSetting, isDbReady: isLocalDbReady } = useAppLocalDbContext(); 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 [menuVisible, setMenuVisible] = React.useState(false);
const [serverUrl, setServerUrl] = React.useState(''); 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); const initialLoadRef = React.useRef(false);
@ -59,6 +82,65 @@ function MainScreen() {
loadServerUrl(); loadServerUrl();
}, [isLocalDbReady, getSetting]); }, [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(() => { React.useEffect(() => {
//Выходим, если БД не готова или уже загружали //Выходим, если БД не готова или уже загружали
@ -69,6 +151,44 @@ function MainScreen() {
refreshInspections(); refreshInspections();
}, [isDbReady, 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(() => { const handleMenuOpen = React.useCallback(() => {
setMenuVisible(true); setMenuVisible(true);
@ -92,24 +212,81 @@ function MainScreen() {
}); });
}, [showInfo, mode, serverUrl, isLocalDbReady]); }, [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', id: 'settings',
title: 'Настройки', title: 'Настройки',
onPress: () => { onPress: handleOpenSettings
navigate(SCREENS.SETTINGS);
}
}, },
{ {
id: 'about', id: 'about',
title: 'О приложении', title: 'О приложении',
onPress: handleShowAbout 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 ( return (
<View style={styles.container}> <View style={styles.container}>
@ -124,7 +301,25 @@ function MainScreen() {
/> />
</View> </View>
<SideMenu visible={menuVisible} onClose={handleMenuClose} items={menuItems} title="Меню" /> <SideMenu
visible={menuVisible}
onClose={handleMenuClose}
items={menuItems}
title="Меню"
mode={mode}
username={session?.userName}
organization={session?.companyName}
/>
<OrganizationSelectDialog
visible={showOrgDialog}
organizations={organizations}
onSelect={handleSelectOrganization}
onCancel={handleCancelOrganization}
title="Выберите организацию"
/>
<LoadingOverlay visible={isAuthLoading} message="Выполняется операция..." />
</View> </View>
); );
} }

View File

@ -12,6 +12,7 @@ const { ScrollView, View, Pressable } = require('react-native');
const AdaptiveView = require('../components/common/AdaptiveView'); const AdaptiveView = require('../components/common/AdaptiveView');
const AppText = require('../components/common/AppText'); const AppText = require('../components/common/AppText');
const AppButton = require('../components/common/AppButton'); const AppButton = require('../components/common/AppButton');
const AppSwitch = require('../components/common/AppSwitch');
const CopyButton = require('../components/common/CopyButton'); const CopyButton = require('../components/common/CopyButton');
const InputDialog = require('../components/common/InputDialog'); const InputDialog = require('../components/common/InputDialog');
const AppHeader = require('../components/layout/AppHeader'); const AppHeader = require('../components/layout/AppHeader');
@ -19,8 +20,11 @@ const { useAppMessagingContext } = require('../components/layout/AppMessagingPro
const { useAppModeContext } = require('../components/layout/AppModeProvider'); const { useAppModeContext } = require('../components/layout/AppModeProvider');
const { useAppNavigationContext } = require('../components/layout/AppNavigationProvider'); const { useAppNavigationContext } = require('../components/layout/AppNavigationProvider');
const { useAppLocalDbContext } = require('../components/layout/AppLocalDbProvider'); 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 { getAppInfo, getModeLabel } = require('../utils/appInfo');
const { validateServerUrlAllowEmpty, validateIdleTimeout } = require('../utils/validation');
const styles = require('../styles/screens/SettingsScreen.styles'); const styles = require('../styles/screens/SettingsScreen.styles');
//----------- //-----------
@ -29,18 +33,23 @@ const styles = require('../styles/screens/SettingsScreen.styles');
function SettingsScreen() { function SettingsScreen() {
const { showInfo, showError, showSuccess } = useAppMessagingContext(); const { showInfo, showError, showSuccess } = useAppMessagingContext();
const { mode, setOnline, setNotConnected } = useAppModeContext(); const { APP_MODE, mode, setNotConnected } = useAppModeContext();
const { goBack, canGoBack } = useAppNavigationContext(); const { goBack, canGoBack } = useAppNavigationContext();
const { getSetting, setSetting, clearSettings, clearInspections, vacuum, isDbReady } = useAppLocalDbContext(); const { getSetting, setSetting, clearSettings, clearInspections, vacuum, isDbReady } = useAppLocalDbContext();
const { session, isAuthenticated, getDeviceId } = useAppAuthContext();
const [serverUrl, setServerUrl] = React.useState(''); 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 [isLoading, setIsLoading] = React.useState(false);
const [isServerUrlDialogVisible, setIsServerUrlDialogVisible] = React.useState(false); const [isServerUrlDialogVisible, setIsServerUrlDialogVisible] = React.useState(false);
const [isIdleTimeoutDialogVisible, setIsIdleTimeoutDialogVisible] = React.useState(false);
//Предотвращение повторной загрузки настроек //Предотвращение повторной загрузки настроек
const settingsLoadedRef = React.useRef(false); const settingsLoadedRef = React.useRef(false);
//Загрузка сохраненного URL сервера при готовности БД //Загрузка сохраненных настроек при готовности БД
React.useEffect(() => { React.useEffect(() => {
//Выходим, если БД не готова или уже загрузили настройки //Выходим, если БД не готова или уже загрузили настройки
if (!isDbReady || settingsLoadedRef.current) { if (!isDbReady || settingsLoadedRef.current) {
@ -52,10 +61,27 @@ function SettingsScreen() {
const loadSettings = async () => { const loadSettings = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
const savedUrl = await getSetting('app_server_url'); const savedUrl = await getSetting(AUTH_SETTINGS_KEYS.SERVER_URL);
if (savedUrl) { if (savedUrl) {
setServerUrl(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) { } catch (error) {
console.error('Ошибка загрузки настроек:', error); console.error('Ошибка загрузки настроек:', error);
showError('Не удалось загрузить настройки'); showError('Не удалось загрузить настройки');
@ -65,52 +91,56 @@ function SettingsScreen() {
}; };
loadSettings(); 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(() => { const handleOpenServerUrlDialog = React.useCallback(() => {
if (!isServerUrlEditable) {
return;
}
setIsServerUrlDialogVisible(true); setIsServerUrlDialogVisible(true);
}, []); }, [isServerUrlEditable]);
//Закрытие диалога ввода URL сервера //Закрытие диалога ввода URL сервера
const handleCloseServerUrlDialog = React.useCallback(() => { const handleCloseServerUrlDialog = React.useCallback(() => {
setIsServerUrlDialogVisible(false); 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( const handleSaveServerUrl = React.useCallback(
async url => { async url => {
setIsServerUrlDialogVisible(false); setIsServerUrlDialogVisible(false);
setIsLoading(true); setIsLoading(true);
const valueToSave = url != null ? String(url).trim() : '';
try { try {
const success = await setSetting('app_server_url', url); const success = await setSetting(AUTH_SETTINGS_KEYS.SERVER_URL, valueToSave);
if (success) { if (success) {
setServerUrl(url); setServerUrl(valueToSave);
showSuccess('Настройки сервера сохранены'); showSuccess('Настройки сервера сохранены');
if (mode === 'NOT_CONNECTED') {
setOnline();
}
} else { } else {
showError('Не удалось сохранить настройки'); showError('Не удалось сохранить настройки');
} }
@ -121,23 +151,67 @@ function SettingsScreen() {
setIsLoading(false); setIsLoading(false);
}, },
[mode, setOnline, setSetting, showError, showSuccess] [setSetting, showError, showSuccess]
); );
//Очистка кэша (осмотров) //Переключатель скрытия URL сервера в окне логина
const handleClearCache = React.useCallback(async () => { const handleToggleHideServerUrl = React.useCallback(
showInfo('Очистить кэш приложения?', { async value => {
title: 'Подтверждение', try {
buttons: [ const success = await setSetting(AUTH_SETTINGS_KEYS.HIDE_SERVER_URL, value ? 'true' : 'false');
{
id: 'cancel', if (success) {
title: 'Отмена', setHideServerUrl(value);
onPress: () => { } showSuccess('Настройка сохранена');
} else {
showError('Не удалось сохранить настройку');
}
} catch (error) {
console.error('Ошибка сохранения настройки:', error);
showError('Не удалось сохранить настройку');
}
}, },
{ [setSetting, showSuccess, showError]
id: 'confirm', );
title: 'Очистить',
onPress: async () => { //Открытие диалога ввода времени простоя
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 { try {
const success = await clearInspections(); const success = await clearInspections();
if (success) { if (success) {
@ -149,48 +223,68 @@ function SettingsScreen() {
console.error('Ошибка очистки кэша:', error); console.error('Ошибка очистки кэша:', error);
showError('Не удалось очистить кэш'); showError('Не удалось очистить кэш');
} }
}, }, [showSuccess, showError, clearInspections]);
buttonStyle: { backgroundColor: APP_COLORS.error },
textStyle: { color: APP_COLORS.white }
}
]
});
}, [showInfo, showSuccess, showError, clearInspections]);
//Сброс всех настроек //Очистка кэша (осмотров)
const handleResetSettings = React.useCallback(async () => { const handleClearCache = React.useCallback(() => {
showInfo('Сбросить все настройки к значениям по умолчанию?', { const confirmButton = getConfirmButtonOptions(DIALOG_BUTTON_TYPE.ERROR, 'Очистить', performClearCache);
title: 'Подтверждение сброса',
buttons: [ showInfo('Очистить кэш приложения?', {
{ title: 'Подтверждение',
id: 'cancel', buttons: [DIALOG_CANCEL_BUTTON, confirmButton]
title: 'Отмена', });
onPress: () => { } }, [showInfo, performClearCache]);
},
{ //Выполнение сброса настроек (для диалога подтверждения)
id: 'confirm', //Подключён (онлайн/офлайн): сбрасываем только непричастные к подключению настройки; не подключён: полный сброс
title: 'Сбросить', const performResetSettings = React.useCallback(async () => {
onPress: async () => {
try { try {
const defaultValue = String(DEFAULT_IDLE_TIMEOUT);
if (mode === APP_MODE.NOT_CONNECTED) {
const success = await clearSettings(); const success = await clearSettings();
if (success) { if (success) {
setServerUrl(''); setServerUrl('');
setHideServerUrl(false);
setIdleTimeout(defaultValue);
await setSetting(AUTH_SETTINGS_KEYS.IDLE_TIMEOUT, defaultValue);
setNotConnected(); setNotConnected();
showSuccess('Настройки сброшены'); showSuccess('Настройки сброшены');
} else { } else {
showError('Не удалось сбросить настройки'); showError('Не удалось сбросить настройки');
} }
} else {
//Подключён (онлайн или офлайн): сбрасываем только время простоя
await setSetting(AUTH_SETTINGS_KEYS.IDLE_TIMEOUT, defaultValue);
setIdleTimeout(defaultValue);
showSuccess('Настройки сброшены');
}
} catch (error) { } catch (error) {
console.error('Ошибка сброса настроек:', error); console.error('Ошибка сброса настроек:', error);
showError('Не удалось сбросить настройки'); showError('Не удалось сбросить настройки');
} }
}, }, [
buttonStyle: { backgroundColor: APP_COLORS.warning }, mode,
textStyle: { color: APP_COLORS.white } APP_MODE.NOT_CONNECTED,
} setServerUrl,
] setHideServerUrl,
setIdleTimeout,
setNotConnected,
clearSettings,
setSetting,
showSuccess,
showError
]);
//Сброс настроек: при подключении (онлайн/офлайн) — без настроек подключения; при отсутствии подключения — все настройки
const handleResetSettings = React.useCallback(() => {
const confirmButton = getConfirmButtonOptions(DIALOG_BUTTON_TYPE.WARNING, 'Сбросить', performResetSettings);
showInfo('Сбросить все настройки к значениям по умолчанию?', {
title: 'Подтверждение сброса',
buttons: [DIALOG_CANCEL_BUTTON, confirmButton]
}); });
}, [showInfo, showSuccess, showError, setNotConnected, clearSettings]); }, [showInfo, performResetSettings]);
//Оптимизация базы данных //Оптимизация базы данных
const handleOptimizeDb = React.useCallback(async () => { const handleOptimizeDb = React.useCallback(async () => {
@ -240,11 +334,42 @@ function SettingsScreen() {
showError('Не удалось скопировать адрес'); showError('Не удалось скопировать адрес');
}, [showError]); }, [showError]);
//Обработчик копирования идентификатора устройства
const handleCopyDeviceId = React.useCallback(() => {
showSuccess('Идентификатор устройства скопирован');
}, [showSuccess]);
return ( return (
<AdaptiveView padding={false}> <AdaptiveView padding={false}>
<AppHeader showBackButton={true} onBackPress={handleBackPress} showMenuButton={false} /> <AppHeader showBackButton={true} onBackPress={handleBackPress} showMenuButton={false} />
<ScrollView style={styles.scrollView} contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}> <ScrollView style={styles.scrollView} contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
{isAuthenticated && session ? (
<View style={styles.section}>
<AppText style={styles.sectionTitle} variant="h3" weight="semibold">
Информация о подключении
</AppText>
<View style={styles.infoRow}>
<AppText style={styles.infoLabel} variant="body">
Имя пользователя:
</AppText>
<AppText style={styles.infoValue} variant="body" weight="medium">
{session.userName || session.userCode || 'Не указано'}
</AppText>
</View>
<View style={styles.infoRow}>
<AppText style={styles.infoLabel} variant="body">
Организация:
</AppText>
<AppText style={styles.infoValue} variant="body" weight="medium">
{session.companyName || 'Не указана'}
</AppText>
</View>
</View>
) : null}
<View style={styles.section}> <View style={styles.section}>
<AppText style={styles.sectionTitle} variant="h3" weight="semibold"> <AppText style={styles.sectionTitle} variant="h3" weight="semibold">
Сервер приложений Сервер приложений
@ -255,13 +380,13 @@ function SettingsScreen() {
</AppText> </AppText>
<View style={styles.serverUrlRow}> <View style={styles.serverUrlRow}>
<Pressable <Pressable style={getServerUrlFieldPressableStyle} onPress={handleOpenServerUrlDialog} disabled={!isServerUrlEditable}>
style={({ pressed }) => [styles.serverUrlField, pressed && styles.serverUrlFieldPressed]}
onPress={handleOpenServerUrlDialog}
disabled={isLoading || !isDbReady}
>
<AppText <AppText
style={[styles.serverUrlText, !serverUrl && styles.serverUrlPlaceholder]} style={[
styles.serverUrlText,
!serverUrl && styles.serverUrlPlaceholder,
!isServerUrlEditable && styles.serverUrlTextDisabled
]}
numberOfLines={1} numberOfLines={1}
> >
{serverUrl || 'Нажмите для ввода адреса'} {serverUrl || 'Нажмите для ввода адреса'}
@ -269,12 +394,48 @@ function SettingsScreen() {
</Pressable> </Pressable>
{serverUrl ? ( {serverUrl ? (
<CopyButton <CopyButton value={serverUrl} onCopy={handleCopyServerUrl} onError={handleCopyError} style={styles.serverUrlCopyButton} />
value={serverUrl} ) : null}
onCopy={handleCopyServerUrl} </View>
onError={handleCopyError}
style={styles.serverUrlCopyButton} <View style={styles.switchRow}>
<AppSwitch
label="Не отображать адрес сервера в окне логина"
value={hideServerUrl}
onValueChange={handleToggleHideServerUrl}
disabled={isLoading || !isDbReady}
/> />
</View>
</View>
<View style={styles.section}>
<AppText style={styles.sectionTitle} variant="h3" weight="semibold">
Системные настройки
</AppText>
<AppText style={styles.fieldLabel} variant="caption" weight="medium">
Максимальное время простоя (минут)
</AppText>
<Pressable style={getIdleTimeoutFieldPressableStyle} onPress={handleOpenIdleTimeoutDialog} disabled={isLoading || !isDbReady}>
<AppText style={styles.serverUrlText} numberOfLines={1}>
{idleTimeout || String(DEFAULT_IDLE_TIMEOUT)}
</AppText>
</Pressable>
<AppText style={[styles.fieldLabel, styles.fieldLabelMarginTop]} variant="caption" weight="medium">
Идентификатор устройства
</AppText>
<View style={styles.serverUrlRow}>
<View style={[styles.serverUrlField, styles.deviceIdField]}>
<AppText style={styles.serverUrlText} numberOfLines={1}>
{deviceId || 'Загрузка...'}
</AppText>
</View>
{deviceId ? (
<CopyButton value={deviceId} onCopy={handleCopyDeviceId} onError={handleCopyError} style={styles.serverUrlCopyButton} />
) : null} ) : null}
</View> </View>
</View> </View>
@ -334,12 +495,7 @@ function SettingsScreen() {
{serverUrl || 'Не настроен'} {serverUrl || 'Не настроен'}
</AppText> </AppText>
{serverUrl ? ( {serverUrl ? (
<CopyButton <CopyButton value={serverUrl} onCopy={handleCopyServerUrl} onError={handleCopyError} style={styles.copyButton} />
value={serverUrl}
onCopy={handleCopyServerUrl}
onError={handleCopyError}
style={styles.copyButton}
/>
) : null} ) : null}
</View> </View>
</View> </View>
@ -366,7 +522,21 @@ function SettingsScreen() {
cancelText="Отмена" cancelText="Отмена"
onConfirm={handleSaveServerUrl} onConfirm={handleSaveServerUrl}
onCancel={handleCloseServerUrlDialog} onCancel={handleCloseServerUrlDialog}
validator={validateServerUrl} validator={validateServerUrlAllowEmpty}
/>
<InputDialog
visible={isIdleTimeoutDialogVisible}
title="Время простоя"
label="Максимальное время простоя (минут)"
value={idleTimeout}
placeholder="Например: 30"
keyboardType="numeric"
confirmText="Сохранить"
cancelText="Отмена"
onConfirm={handleSaveIdleTimeout}
onCancel={handleCloseIdleTimeoutDialog}
validator={validateIdleTimeout}
/> />
</AdaptiveView> </AdaptiveView>
); );

View File

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

View File

@ -9,122 +9,51 @@
const { StyleSheet } = require('react-native'); //StyleSheet React Native const { StyleSheet } = require('react-native'); //StyleSheet React Native
const { responsiveSize } = require('../../utils/responsive'); //Адаптивные утилиты const { responsiveSize } = require('../../utils/responsive'); //Адаптивные утилиты
const { LOGO_SIZE_KEYS } = require('../../config/appAssets'); //Ключи размеров логотипа
//----------- //-----------
//Тело модуля //Тело модуля
//----------- //-----------
//Цвета иконки //Размеры контейнера для вариантов логотипа
const TEAL_BACKGROUND = '#50AF95'; const SIZES = Object.freeze({
const ROBOT_WHITE = '#FFFFFF'; [LOGO_SIZE_KEYS.SMALL]: responsiveSize(32),
[LOGO_SIZE_KEYS.MEDIUM]: responsiveSize(40),
[LOGO_SIZE_KEYS.LARGE]: responsiveSize(56)
});
//Размеры для разных вариантов //Радиус скругления контейнера по размерам
const SIZES = { const BORDER_RADIUS = Object.freeze({
small: responsiveSize(32), [LOGO_SIZE_KEYS.SMALL]: responsiveSize(6),
medium: responsiveSize(40), [LOGO_SIZE_KEYS.MEDIUM]: responsiveSize(8),
large: responsiveSize(56) [LOGO_SIZE_KEYS.LARGE]: responsiveSize(10)
}; });
//Стили логотипа //Стили логотипа
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
backgroundColor: TEAL_BACKGROUND,
overflow: 'hidden' overflow: 'hidden'
}, },
containerSmall: { containerSmall: {
width: SIZES.small, width: SIZES[LOGO_SIZE_KEYS.SMALL],
height: SIZES.small, height: SIZES[LOGO_SIZE_KEYS.SMALL],
borderRadius: responsiveSize(6) borderRadius: BORDER_RADIUS[LOGO_SIZE_KEYS.SMALL]
}, },
containerMedium: { containerMedium: {
width: SIZES.medium, width: SIZES[LOGO_SIZE_KEYS.MEDIUM],
height: SIZES.medium, height: SIZES[LOGO_SIZE_KEYS.MEDIUM],
borderRadius: responsiveSize(8) borderRadius: BORDER_RADIUS[LOGO_SIZE_KEYS.MEDIUM]
}, },
containerLarge: { containerLarge: {
width: SIZES.large, width: SIZES[LOGO_SIZE_KEYS.LARGE],
height: SIZES.large, height: SIZES[LOGO_SIZE_KEYS.LARGE],
borderRadius: responsiveSize(10) borderRadius: BORDER_RADIUS[LOGO_SIZE_KEYS.LARGE]
}, },
robotContainer: { image: {
alignItems: 'center', width: '100%',
justifyContent: 'center' height: '100%'
},
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)
} }
}); });

View File

@ -7,8 +7,8 @@
//Подключение библиотек //Подключение библиотек
//--------------------- //---------------------
const { StyleSheet } = require("react-native"); //StyleSheet React Native const { StyleSheet } = require('react-native'); //StyleSheet React Native
const { APP_COLORS } = require("../../config/theme"); //Цветовая схема приложения const { APP_COLORS } = require('../../config/theme'); //Цветовая схема приложения
//----------- //-----------
//Тело модуля //Тело модуля
@ -19,12 +19,12 @@ const styles = StyleSheet.create({
backdrop: { backdrop: {
flex: 1, flex: 1,
backgroundColor: APP_COLORS.overlay, backgroundColor: APP_COLORS.overlay,
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
padding: 24 padding: 24
}, },
container: { container: {
width: "100%", width: '100%',
borderRadius: 12, borderRadius: 12,
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 12, paddingVertical: 12,
@ -55,15 +55,15 @@ const styles = StyleSheet.create({
borderLeftColor: APP_COLORS.success borderLeftColor: APP_COLORS.success
}, },
header: { header: {
flexDirection: "row", flexDirection: 'row',
alignItems: "center", alignItems: 'center',
justifyContent: "space-between", justifyContent: 'space-between',
marginBottom: 8 marginBottom: 8
}, },
title: { title: {
flex: 1, flex: 1,
fontSize: 16, fontSize: 16,
fontWeight: "600", fontWeight: '600',
color: APP_COLORS.textPrimary, color: APP_COLORS.textPrimary,
marginRight: 8 marginRight: 8
}, },
@ -71,8 +71,8 @@ const styles = StyleSheet.create({
width: 28, width: 28,
height: 28, height: 28,
borderRadius: 14, borderRadius: 14,
alignItems: "center", alignItems: 'center',
justifyContent: "center" justifyContent: 'center'
}, },
closeButtonText: { closeButtonText: {
fontSize: 20, fontSize: 20,
@ -87,8 +87,8 @@ const styles = StyleSheet.create({
color: APP_COLORS.textSecondary color: APP_COLORS.textSecondary
}, },
buttonsRow: { buttonsRow: {
flexDirection: "row", flexDirection: 'row',
justifyContent: "flex-end", justifyContent: 'flex-end',
gap: 8 gap: 8
}, },
buttonBase: { buttonBase: {
@ -102,7 +102,7 @@ const styles = StyleSheet.create({
}, },
buttonText: { buttonText: {
fontSize: 14, fontSize: 14,
fontWeight: "500", fontWeight: '500',
color: APP_COLORS.white color: APP_COLORS.white
} }
}); });
@ -112,4 +112,3 @@ const styles = StyleSheet.create({
//---------------- //----------------
module.exports = styles; module.exports = styles;

View File

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

View File

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

View File

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

View File

@ -7,8 +7,8 @@
//Подключение библиотек //Подключение библиотек
//--------------------- //---------------------
const { StyleSheet } = require("react-native"); //StyleSheet React Native const { StyleSheet } = require('react-native'); //StyleSheet React Native
const { APP_COLORS } = require("../../config/theme"); //Цветовая схема приложения const { APP_COLORS } = require('../../config/theme'); //Цветовая схема приложения
//----------- //-----------
//Тело модуля //Тело модуля
@ -25,12 +25,12 @@ const styles = StyleSheet.create({
}, },
title: { title: {
fontSize: 16, fontSize: 16,
fontWeight: "500", fontWeight: '500',
marginBottom: 4 marginBottom: 4
}, },
metaRow: { metaRow: {
flexDirection: "row", flexDirection: 'row',
justifyContent: "space-between" justifyContent: 'space-between'
}, },
meta: { meta: {
fontSize: 12, fontSize: 12,
@ -43,4 +43,3 @@ const styles = StyleSheet.create({
//---------------- //----------------
module.exports = styles; module.exports = styles;

View File

@ -7,8 +7,8 @@
//Подключение библиотек //Подключение библиотек
//--------------------- //---------------------
const { StyleSheet } = require("react-native"); //StyleSheet React Native const { StyleSheet } = require('react-native'); //StyleSheet React Native
const { APP_COLORS } = require("../../config/theme"); //Цветовая схема приложения const { APP_COLORS } = require('../../config/theme'); //Цветовая схема приложения
//----------- //-----------
//Тело модуля //Тело модуля
@ -18,23 +18,26 @@ const { APP_COLORS } = require("../../config/theme"); //Цветовая схе
const styles = StyleSheet.create({ const styles = StyleSheet.create({
centerContainer: { centerContainer: {
flex: 1, flex: 1,
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
paddingHorizontal: 32 paddingHorizontal: 32
}, },
centerText: { centerText: {
marginTop: 12, marginTop: 12,
textAlign: "center", textAlign: 'center',
color: APP_COLORS.textSecondary color: APP_COLORS.textSecondary
}, },
errorText: { errorText: {
marginTop: 8, marginTop: 8,
textAlign: "center", textAlign: 'center',
color: APP_COLORS.error, color: APP_COLORS.error,
fontSize: 12 fontSize: 12
}, },
centerButton: { centerButton: {
marginTop: 16 marginTop: 16
},
indicator: {
color: APP_COLORS.primary
} }
}); });
@ -43,4 +46,3 @@ const styles = StyleSheet.create({
//---------------- //----------------
module.exports = styles; module.exports = styles;

View File

@ -0,0 +1,17 @@
/*
Предрейсовые осмотры - мобильное приложение
Стили провайдера авторизации (провайдер без визуальной разметки)
*/
//---------
//Константы
//---------
//Провайдер не рендерит собственных View — стили не требуются
const styles = {};
//----------------
//Интерфейс модуля
//----------------
module.exports = styles;

View File

@ -7,8 +7,8 @@
//Подключение библиотек //Подключение библиотек
//--------------------- //---------------------
const { StyleSheet } = require("react-native"); //StyleSheet React Native const { StyleSheet } = require('react-native'); //StyleSheet React Native
const { APP_COLORS } = require("../../config/theme"); //Цветовая схема приложения const { APP_COLORS } = require('../../config/theme'); //Цветовая схема приложения
//----------- //-----------
//Тело модуля //Тело модуля
@ -18,13 +18,13 @@ const { APP_COLORS } = require("../../config/theme"); //Цветовая схе
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
backgroundColor: APP_COLORS.background, backgroundColor: APP_COLORS.background,
padding: 24 padding: 24
}, },
card: { card: {
width: "100%", width: '100%',
borderRadius: 12, borderRadius: 12,
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 12, paddingVertical: 12,
@ -40,7 +40,7 @@ const styles = StyleSheet.create({
}, },
title: { title: {
fontSize: 18, fontSize: 18,
fontWeight: "600", fontWeight: '600',
color: APP_COLORS.error, color: APP_COLORS.error,
marginBottom: 8 marginBottom: 8
}, },
@ -50,8 +50,8 @@ const styles = StyleSheet.create({
marginBottom: 16 marginBottom: 16
}, },
buttonRow: { buttonRow: {
flexDirection: "row", flexDirection: 'row',
justifyContent: "flex-end" justifyContent: 'flex-end'
} }
}); });
@ -60,4 +60,3 @@ const styles = StyleSheet.create({
//---------------- //----------------
module.exports = styles; module.exports = styles;

View File

@ -10,7 +10,7 @@
const { StyleSheet } = require('react-native'); //StyleSheet React Native const { StyleSheet } = require('react-native'); //StyleSheet React Native
const { APP_COLORS } = require('../../config/theme'); //Цветовая схема приложения const { APP_COLORS } = require('../../config/theme'); //Цветовая схема приложения
const { UI } = require('../../config/appConfig'); //Конфигурация UI const { UI } = require('../../config/appConfig'); //Конфигурация UI
const { responsiveSize, responsiveWidth, responsiveSpacing } = require('../../utils/responsive'); //Адаптивные утилиты const { responsiveSize, responsiveSpacing } = require('../../utils/responsive'); //Адаптивные утилиты
//----------- //-----------
//Тело модуля //Тело модуля
@ -91,59 +91,6 @@ const styles = StyleSheet.create({
borderRadius: responsiveSize(1), borderRadius: responsiveSize(1),
transform: [{ rotate: '45deg' }, { translateY: responsiveSize(2.5) }] 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: { menuButton: {
width: responsiveSize(40), width: responsiveSize(40),
height: responsiveSize(40), height: responsiveSize(40),

View File

@ -0,0 +1,17 @@
/*
Предрейсовые осмотры - мобильное приложение
Стили провайдера локальной БД (провайдер без визуальной разметки)
*/
//---------
//Константы
//---------
//Провайдер не рендерит собственных View — стили не требуются
const styles = {};
//----------------
//Интерфейс модуля
//----------------
module.exports = styles;

View File

@ -0,0 +1,17 @@
/*
Предрейсовые осмотры - мобильное приложение
Стили провайдера сообщений (провайдер без визуальной разметки)
*/
//---------
//Константы
//---------
//Провайдер не рендерит собственных View — стили не требуются
const styles = {};
//----------------
//Интерфейс модуля
//----------------
module.exports = styles;

View File

@ -0,0 +1,17 @@
/*
Предрейсовые осмотры - мобильное приложение
Стили провайдера режима работы (провайдер без визуальной разметки)
*/
//---------
//Константы
//---------
//Провайдер не рендерит собственных View — стили не требуются
const styles = {};
//----------------
//Интерфейс модуля
//----------------
module.exports = styles;

View File

@ -0,0 +1,17 @@
/*
Предрейсовые осмотры - мобильное приложение
Стили провайдера навигации (провайдер без визуальной разметки)
*/
//---------
//Константы
//---------
//Провайдер не рендерит собственных View — стили не требуются
const styles = {};
//----------------
//Интерфейс модуля
//----------------
module.exports = styles;

View File

@ -0,0 +1,17 @@
/*
Предрейсовые осмотры - мобильное приложение
Стили провайдера предрейсовых осмотров (провайдер без визуальной разметки)
*/
//---------
//Константы
//---------
//Провайдер не рендерит собственных View — стили не требуются
const styles = {};
//----------------
//Интерфейс модуля
//----------------
module.exports = styles;

View File

@ -0,0 +1,17 @@
/*
Предрейсовые осмотры - мобильное приложение
Стили провайдера сервера приложений (провайдер без визуальной разметки)
*/
//---------
//Константы
//---------
//Провайдер не рендерит собственных View — стили не требуются
const styles = {};
//----------------
//Интерфейс модуля
//----------------
module.exports = styles;

View File

@ -7,8 +7,8 @@
//Подключение библиотек //Подключение библиотек
//--------------------- //---------------------
const { StyleSheet } = require("react-native"); //StyleSheet React Native const { StyleSheet } = require('react-native'); //StyleSheet React Native
const { APP_COLORS } = require("../../config/theme"); //Цветовая схема приложения const { APP_COLORS } = require('../../config/theme'); //Цветовая схема приложения
//----------- //-----------
//Тело модуля //Тело модуля
@ -27,4 +27,3 @@ const styles = StyleSheet.create({
//---------------- //----------------
module.exports = styles; module.exports = styles;

View File

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

View File

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

View File

@ -6,6 +6,7 @@
//--------------------- //---------------------
//Подключение библиотек //Подключение библиотек
//--------------------- //---------------------
const { StyleSheet } = require('react-native'); const { StyleSheet } = require('react-native');
const { APP_COLORS } = require('../../config/theme'); const { APP_COLORS } = require('../../config/theme');
const { UI } = require('../../config/appConfig'); const { UI } = require('../../config/appConfig');
@ -39,6 +40,9 @@ const styles = StyleSheet.create({
marginBottom: responsiveSpacing(2), marginBottom: responsiveSpacing(2),
color: APP_COLORS.textSecondary color: APP_COLORS.textSecondary
}, },
fieldLabelMarginTop: {
marginTop: responsiveSpacing(4)
},
serverUrlRow: { serverUrlRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center' alignItems: 'center'
@ -57,16 +61,33 @@ const styles = StyleSheet.create({
borderColor: APP_COLORS.primary, borderColor: APP_COLORS.primary,
backgroundColor: APP_COLORS.primaryExtraLight backgroundColor: APP_COLORS.primaryExtraLight
}, },
serverUrlFieldDisabled: {
backgroundColor: APP_COLORS.surfaceAlt,
borderColor: APP_COLORS.borderSubtle
},
serverUrlText: { serverUrlText: {
fontSize: UI.FONT_SIZE_MD, fontSize: UI.FONT_SIZE_MD,
color: APP_COLORS.textPrimary color: APP_COLORS.textPrimary
}, },
serverUrlTextDisabled: {
color: APP_COLORS.textTertiary
},
serverUrlPlaceholder: { serverUrlPlaceholder: {
color: APP_COLORS.textTertiary color: APP_COLORS.textTertiary
}, },
serverUrlCopyButton: { serverUrlCopyButton: {
marginLeft: responsiveSpacing(2) marginLeft: responsiveSpacing(2)
}, },
deviceIdField: {
backgroundColor: APP_COLORS.surfaceAlt
},
helperText: {
marginTop: responsiveSpacing(2),
color: APP_COLORS.textTertiary
},
switchRow: {
marginTop: responsiveSpacing(3)
},
actionButton: { actionButton: {
marginTop: responsiveSpacing(3) marginTop: responsiveSpacing(3)
}, },
@ -124,4 +145,5 @@ const styles = StyleSheet.create({
//---------------- //----------------
//Интерфейс модуля //Интерфейс модуля
//---------------- //----------------
module.exports = styles; module.exports = styles;

View File

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

View File

@ -0,0 +1,22 @@
/*
Предрейсовые осмотры - мобильное приложение
Утилиты формы входа (логин): видимость полей, правила отображения
*/
//-----------
//Тело модуля
//-----------
//Определяет, нужно ли показывать поле ввода адреса сервера
function isServerUrlFieldVisible(hideServerUrl, serverUrl) {
const hasServerUrl = Boolean(serverUrl && String(serverUrl).trim());
return !hideServerUrl || !hasServerUrl;
}
//----------------
//Интерфейс модуля
//----------------
module.exports = {
isServerUrlFieldVisible
};

View File

@ -8,15 +8,12 @@
//--------------------- //---------------------
const { Dimensions, Platform, PixelRatio } = require('react-native'); //Размеры экрана и платформа 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'); const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');

View File

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

View File

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