diff --git a/AGENTS.md b/AGENTS.md index 0d229b2..6531c79 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,32 +1,48 @@ # AGENTS.md ## Цель + Единые подробные инструкции для любых ИИ-агентов, которые вносят изменения в репозиторий MyKHSU. ## Область применения -- Любые правки в components/, hooks/, utils/, assets/. + +- Любые правки в `components/`, `hooks/`, `utils/`, `assets/`. - Любые изменения, которые затрагивают расписание, новости, карту, настройки, уведомления. - Любые исправления багов, рефакторинг и задачи по улучшению UX в рамках текущей архитектуры. +- Любые изменения кода, требующие синхронизации документации в `docs/`. ## Роли + - Исполнитель: делает изменения в коде. - Ревьюер: проверяет риски, регрессии, edge-cases. - Верификатор: проверяет запуск и базовую работоспособность. ## Принципы принятия решений + 1. Сначала безопасность и корректность. 2. Затем обратная совместимость и стабильность пользовательских сценариев. 3. Затем читаемость и поддерживаемость. 4. Затем минимальность диффа и скорость. ## Протокол работы агента -1. Прочитать запрос и определить затрагиваемые зоны (components, hooks, utils, assets). + +1. Прочитать запрос и определить затрагиваемые зоны (`components`, `hooks`, `utils`, `assets`). 2. Проверить связанные файлы перед правкой, чтобы не сломать зависимости. 3. Внести минимальные атомарные изменения. -4. Выполнить базовую локальную проверку. -5. Описать результат, риски и шаги ручной проверки. +4. Обновить релевантные файлы в `docs/` в рамках той же задачи. +5. Выполнить базовую локальную проверку. +6. Описать результат, риски и шаги ручной проверки. + +## Обязательная синхронизация docs/ + +- При любом изменении кода агент обязан обновить документацию в `docs/`. +- Обновляется минимум один релевантный файл в `docs/` по затронутому поведению. +- Обновляются только затронутые разделы, без лишнего переписывания. +- Если изменение не влияет на поведение, архитектуру или данные, это явно фиксируется в отчете. +- Нельзя завершать задачу с код-правкой без проверки актуальности `docs/`. ## Детализированный workflow + 1. Уточнить пользовательский эффект: что изменится на экране и в поведении. 2. Найти точку входа изменения и соседние зависимости. 3. Проверить, нет ли уже реализованной похожей логики. @@ -37,60 +53,70 @@ 8. Подготовить отчет: что сделано, почему, риски, ручные шаги. ## Инварианты проекта + - Экран расписания должен оставаться рабочим и отзывчивым. - Навигация между ключевыми экранами не должна ломаться. - Настройки пользователя и локальное хранилище должны сохраняться корректно. - Ошибки сети должны обрабатываться с понятным fallback-состоянием. ## Дополнительные инварианты -- Приложение должно запускаться без явных ошибок импорта/синтаксиса. + +- Приложение должно запускаться без явных ошибок импорта и синтаксиса. - Пустые, частичные и отсутствующие данные не должны приводить к падению UI. - Основные пользовательские действия должны быть воспроизводимы после правки. ## Ограничения на изменения -- Не переименовывай файлы и экспортируемые сущности без необходимости. -- Не добавляй тяжелые зависимости без явного запроса. -- Не изменяй формат существующих данных в storage без миграционного плана. -- Не удаляй legacy-ветки логики, пока не проверено, что они не используются. + +- Не переименовывать файлы и экспортируемые сущности без необходимости. +- Не добавлять тяжелые зависимости без явного запроса. +- Не изменять формат существующих данных в storage без миграционного плана. +- Не удалять legacy-ветки логики, пока не проверено, что они не используются. ## Обязательные требования к качеству + - Любая новая асинхронная логика должна иметь блок обработки ошибок. -- Любая ветка с внешними данными должна обрабатывать null/undefined/пустые коллекции. +- Любая ветка с внешними данными должна обрабатывать `null`, `undefined` и пустые коллекции. - Любая правка UI должна учитывать корректное состояние загрузки и ошибки. - Любая вынесенная утилита должна иметь понятный интерфейс и предсказуемый возврат. ## Стандарты качества + - Код должен быть читаемым и предсказуемым. - Любая новая ветка логики должна иметь обработку ошибок. -- Избегай глубокой вложенности условий, упрощай через ранние возвраты. -- Для повторяющейся логики используй утилиты и переиспользуемые функции. +- Избегать глубокой вложенности условий, упрощать через ранние возвраты. +- Для повторяющейся логики использовать утилиты и переиспользуемые функции. ## Примеры безопасной стратегии изменений + - Если меняется формат данных для экрана, сначала добавить адаптер совместимости, затем использовать его в одном месте. - Если изменяется сетевой ответ, обеспечить fallback на кеш или информативное состояние ошибки. - Если правится логика фильтра, проверять поведение при пустом списке и при единственном элементе. ## Минимальная валидация -- Запуск приложения: npm run start. + +- Запуск приложения: `npm run start`. - Проверка ключевых сценариев вручную: - открытие расписания; - - переключение форматов/фильтров (если затронуто); + - переключение форматов и фильтров, если затронуто; - открытие настроек; - - загрузка новостей/данных при сети и без сети. + - загрузка новостей и данных при сети и без сети. ## Рекомендованная расширенная валидация + - Если затронуты уведомления: проверить отсутствие дублирования и корректный тайминг. - Если затронуты фоновые задачи: убедиться, что нет частых лишних запусков. - Если затронуты настройки: проверить чтение ранее сохраненных значений. - Если затронуты ассеты: убедиться, что не нарушены пути и отображение на основных экранах. ## Красные флаги + - Изменение прошло, но причины потенциальной регрессии не проанализированы. - Логика изменилась сразу в нескольких файлах без четкой необходимости. - Появились неиспользуемые импорты, временные костыли и отладочные вставки. - Нет явного описания, как вручную проверить изменение. ## Шаблон отчета агента + - Что сделано. - Какие файлы изменены. - Почему решение выбрано. diff --git a/App.js b/App.js index c270947..8eb7629 100644 --- a/App.js +++ b/App.js @@ -24,6 +24,7 @@ import { getBlurConfig } from './utils/liquidGlass'; import * as Sentry from '@sentry/react-native'; import notificationService from './utils/notificationService'; import backgroundService from './utils/backgroundService'; +import { hasUnlockedAllAchievements } from './utils/achievements'; Sentry.init({ dsn: 'https://9954c52fe80999a51a6905e3ee180d11@sentry.sculkmetrics.com/5', @@ -146,6 +147,7 @@ export default Sentry.wrap(function App() { const [systemTheme, setSystemTheme] = useState(Appearance.getColorScheme() || 'light'); const [refresh, setRefresh] = useState(0); const [refreshKey, setRefreshKey] = useState(0); + const [legendUnlocked, setLegendUnlocked] = useState(false); const insets = useSafeAreaInsets(); // Refs для дочерних экранов (для управления из хедера) @@ -158,6 +160,8 @@ export default Sentry.wrap(function App() { const scheduleExportHandler = useRef(null); const [scheduleExportAvailable, setScheduleExportAvailable] = useState(false); const [scheduleExporting, setScheduleExporting] = useState(false); + const scheduleFavoritesHandler = useRef(null); + const [scheduleFavoritesAvailable, setScheduleFavoritesAvailable] = useState(false); const [mapSubScreen, setMapSubScreen] = useState(null); const [freshmanSubScreen, setFreshmanSubScreen] = useState(null); const [settingsSubScreen, setSettingsSubScreen] = useState(null); @@ -460,9 +464,22 @@ const handleNewYearModeChange = async (enabled) => { try { const savedTheme = await SecureStore.getItemAsync('theme'); const savedAccentColor = await SecureStore.getItemAsync('accentColor'); + const allUnlocked = await hasUnlockedAllAchievements().catch(() => false); + + setLegendUnlocked(allUnlocked); - if (savedTheme) setTheme(savedTheme); - if (savedAccentColor) setAccentColor(savedAccentColor); + if (savedTheme === 'legend' && !allUnlocked) { + setTheme('dark'); + await SecureStore.setItemAsync('theme', 'dark'); + } else if (savedTheme) { + setTheme(savedTheme); + } + if (savedAccentColor === 'legend' && !allUnlocked) { + setAccentColor('green'); + await SecureStore.setItemAsync('accentColor', 'green'); + } else if (savedAccentColor) { + setAccentColor(savedAccentColor); + } } catch (error) { console.error('Error loading settings:', error); } @@ -504,6 +521,7 @@ const handleNewYearModeChange = async (enabled) => { accentColor={accentColor} theme={getEffectiveTheme()} holidayInfo={holidayInfo} + legendUnlocked={legendUnlocked} />; } @@ -687,6 +705,20 @@ const handleNewYearModeChange = async (enabled) => { )} + {activeScreen === SCREENS.SCHEDULE && scheduleFavoritesAvailable && ( + scheduleFavoritesHandler.current?.()} + activeOpacity={0.7} + style={{ + marginLeft: 8, + padding: 6, + borderRadius: 10, + backgroundColor: colors.glass, + }} + > + + + )} {activeScreen === SCREENS.SCHEDULE && scheduleExportAvailable && ( scheduleExportHandler.current?.()} @@ -783,6 +815,20 @@ const handleNewYearModeChange = async (enabled) => { )} + {activeScreen === SCREENS.SCHEDULE && scheduleFavoritesAvailable && ( + scheduleFavoritesHandler.current?.()} + activeOpacity={0.7} + style={{ + marginLeft: 8, + padding: 6, + borderRadius: 10, + backgroundColor: colors.glass, + }} + > + + + )} {activeScreen === SCREENS.SCHEDULE && scheduleExportAvailable && ( scheduleExportHandler.current?.()} @@ -919,6 +965,10 @@ const handleNewYearModeChange = async (enabled) => { setScheduleExportAvailable(!!handler); setScheduleExporting(isExporting); }} + onFavoritesReady={(handler) => { + scheduleFavoritesHandler.current = handler; + setScheduleFavoritesAvailable(!!handler); + }} /> )} {activeScreen === SCREENS.MAP && ( @@ -959,6 +1009,7 @@ const handleNewYearModeChange = async (enabled) => { ref={settingsScreenRef} theme={effectiveTheme} accentColor={accentColor} + legendUnlocked={legendUnlocked} setTheme={setTheme} setAccentColor={setAccentColor} key={`settings-${refreshKey}`} diff --git a/CLAUDE.md b/CLAUDE.md index 384a5b8..2e906ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,38 +1,51 @@ # CLAUDE.md ## Operating Guidelines for Claude-like Agents + This repository contains an Expo-based React Native app. Keep all edits focused, safe, and easy to review. Primary objective: deliver user-visible improvements with minimal risk, preserve existing behavior unless explicitly requested, and keep diffs small and maintainable. ## Project Snapshot + - Platform: React Native 0.81 + Expo 54. -- Entry files: App.js, index.js. -- UI components: components/. -- Domain/business helpers: utils/. -- Reusable logic: hooks/. +- Entry files: `App.js`, `index.js`. +- UI components: `components/`. +- Domain and business helpers: `utils/`. +- Reusable logic: `hooks/`. Critical user-facing flows: + - schedule browsing and filters, - navigation between key screens, - settings persistence, - network-backed content loading with graceful fallback. ## How to Approach Tasks -- Start from user impact: what screen/flow changes for a real user. + +- Start from user impact: what screen or flow changes for a real user. - Read related files before editing to avoid hidden coupling. - Prefer small, incremental diffs over broad rewrites. - Preserve naming, style, and existing structure unless requested otherwise. Execution sequence: + 1. Identify the exact user-visible behavior to change. 2. Locate the narrowest safe edit point. -3. Confirm dependencies and affected imports/exports. +3. Confirm dependencies and affected imports and exports. 4. Implement atomically with explicit error handling. -5. Perform minimal validation and manual sanity checks. -6. Report what changed, why, and what to verify. +5. Update relevant documentation in `docs/` for every code change. +6. Perform minimal validation and manual sanity checks. +7. Report what changed, why, and what to verify. + +## Documentation Sync Is Mandatory + +- Any code change must include corresponding documentation updates in `docs/`. +- Update only impacted sections, but do not skip docs refresh. +- If no docs update is needed, explicitly justify why in the final report. ## Coding Rules + - Keep components focused on rendering and interaction. - Keep side effects and asynchronous logic controlled and error-safe. - Reuse existing utility helpers when possible. @@ -40,56 +53,66 @@ Execution sequence: - Avoid introducing global mutable state unless already established. Additional coding constraints: + - Avoid deep conditional nesting; prefer early returns. - Avoid magic literals repeated across files. - Keep helper functions pure when possible. - Do not move large blocks of code across files unless the task explicitly requires refactoring. ## Reliability and Error Handling + - Network code should fail gracefully with clear fallback behavior. - Storage-related changes must remain backward-compatible. - Notification and background-task changes should be conservative and platform-aware. -- Never assume optional data exists; guard against null/undefined values. +- Never assume optional data exists; guard against `null` and `undefined` values. Required safeguards: + - Every new async branch must include a predictable error path. - Loading, empty, and error UI states must stay coherent. - Any behavior that depends on remote data must remain stable in offline conditions. ## Asset and UI Safety + - Do not overwrite branding assets unless explicitly asked. -- Respect existing icon/theme variants and folder conventions. +- Respect existing icon and theme variants with folder conventions. - Keep visual changes consistent with current app style. When touching assets: + - Keep file naming clear and deterministic. - Keep path conventions unchanged. - Ensure existing references are not broken. ## Verification Checklist -- App can start with npm run start. -- No obvious syntax/import errors in touched files. + +- App can start with `npm run start`. +- No obvious syntax or import errors in touched files. - Changed flow is manually sanity-checked. -- Response includes: changed files, behavior impact, residual risks. +- Response includes changed files, behavior impact, and residual risks. Extended verification guidance: -- If schedule logic changed: verify day/week mode switches and selected group/course behavior. + +- If schedule logic changed: verify day and week mode switches with selected group or course behavior. - If settings changed: verify values persist after app restart. -- If network/cache changed: verify behavior with network on/off. +- If network or cache changed: verify behavior with network on and off. - If notifications changed: verify no duplicate scheduling and safe fallback on permission issues. ## Out-of-Scope Unless Requested + - Renaming files or public exports. - Introducing new heavy dependencies. - Reformatting unrelated files. - Broad architecture rewrites. ## Preferred Final Response Style + - Brief summary first. - Then explicit file list. - Then manual test recommendations. Recommended completion note should also include: + - why the selected approach is safest for current architecture, - what residual risks remain, - what exact manual steps should be run by a reviewer. diff --git a/COPILOT.md b/COPILOT.md index ed19942..c25db49 100644 --- a/COPILOT.md +++ b/COPILOT.md @@ -1,25 +1,31 @@ # COPILOT.md ## Назначение + Этот документ задает подробные рабочие правила для GitHub Copilot и любых ИИ-ассистентов, которые вносят изменения в проект MyKHSU. + Цель: безопасные, минимальные и предсказуемые правки без регрессий в ключевых пользовательских сценариях. ## Краткий профиль проекта + - Платформа: React Native 0.81 + Expo 54. -- Вход: App.js, index.js. +- Вход: `App.js`, `index.js`. - Ключевые зоны: - - экраны и UI: components/ - - бизнес-логика и сервисы: utils/ - - переиспользуемая логика: hooks/ -- Критичный сценарий: экран расписания и связанные фильтры/форматы. + - экраны и UI: `components/` + - бизнес-логика и сервисы: `utils/` + - переиспользуемая логика: `hooks/` +- Критичный сценарий: экран расписания и связанные фильтры и форматы. ## Что считается успешной задачей + - Изменение решает конкретный запрос пользователя. - Не ломаются базовые экраны: расписание, новости, карта, настройки. - Не появляются новые ошибки импорта, рантайма и очевидные визуальные артефакты. - Изменение ограничено затронутой областью, без широкого рефакторинга вне запроса. +- Для любой кодовой правки обновлены релевантные документы в `docs/`. ## Приоритеты при принятии решений + 1. Корректность и отсутствие регрессий. 2. Сохранение текущего поведения и совместимости данных. 3. Простота поддержки и читаемость. @@ -27,86 +33,107 @@ 5. Скорость выполнения. ## Пошаговый рабочий процесс + 1. Прочитать запрос и сформулировать ожидаемый пользовательский результат. 2. Определить затронутые файлы и зависимости. 3. Проверить соседние модули, которые могут зависеть от изменяемого API. 4. Подготовить минимальный план правки. 5. Внести изменения маленькими атомарными шагами. 6. Добавить обработку ошибок в новые ветки асинхронной логики. -7. Выполнить базовую проверку запуска и ключевого сценария. -8. Кратко зафиксировать: что изменено, риски, ручная проверка. +7. Синхронизировать `docs/` по затронутому функционалу. +8. Выполнить базовую проверку запуска и ключевого сценария. +9. Кратко зафиксировать: что изменено, риски, ручная проверка. + +## Обязательное правило по документации + +- Любое изменение кода в `components/`, `hooks/`, `utils/` или конфигурации требует обновления `docs/`. +- Обновлять нужно только релевантные разделы, без лишнего переписывания. +- Если `docs/` не были изменены, в итоге обязательно указать причину, почему это допустимо. ## Правила изменения кода ### Общие + - Не менять названия файлов, экспортов и публичных функций без явного запроса. - Не добавлять зависимости, если задачу можно решить существующим стеком. - Не удалять legacy-логику, пока не подтверждено, что она не используется. - Не менять формат данных в storage без миграции и проверки обратной совместимости. ### React Native и компоненты + - Предпочитать функциональные компоненты и хуки. -- Не переносить тяжелую бизнес-логику в JSX; выносить в hooks/utils. +- Не переносить тяжелую бизнес-логику в JSX; выносить в hooks и utils. - Избегать глубокой вложенности условий; использовать ранние возвраты. - Все новые условные ветки интерфейса должны иметь fallback-состояние. ### Асинхронная логика + - Каждый асинхронный вызов оборачивать в корректную обработку ошибок. -- Учитывать отмену/размонтирование компонента, чтобы избегать гонок состояния. +- Учитывать отмену и размонтирование компонента, чтобы избегать гонок состояния. - Не вызывать сеть в рендере; использовать эффекты и существующий сервисный слой. - Для сети и кэша сохранять предсказуемое поведение в оффлайн-режиме. ### Константы и повторное использование -- Общие константы размещать в utils/constants.js. -- Повторяющиеся фрагменты логики выносить в utils или hooks. + +- Общие константы размещать в `utils/constants.js`. +- Повторяющиеся фрагменты логики выносить в `utils` или `hooks`. - Избегать магических строк и чисел, если они используются более одного раза. ## Предметные ограничения проекта ### Расписание + - Экран расписания должен оставаться отзывчивым и функциональным. -- Переключение дня/недели/фильтров не должно ломаться. +- Переключение дня, недели и фильтров не должно ломаться. - Любые изменения форматов отображения проверять на пустых и частично заполненных данных. ### Новости и сеть + - Ошибки сети должны показывать понятный fallback, а не пустой или сломанный экран. - Кэш не должен приводить к неявно устаревшему или неконсистентному состоянию без признаков для пользователя. ### Настройки и хранилище + - Настройки пользователя обязаны сохраняться и корректно восстанавливаться. - Изменения ключей хранения делать осторожно, с учетом уже установленных версий приложения. ### Уведомления и фоновые задачи + - Изменять логику уведомлений только в профильных сервисах. - Учитывать платформенные ограничения Expo Task Manager и энергопотребление. - Любая новая фоновая задача должна иметь безопасный сценарий ошибки. ## Работа с ассетами -- Не перезаписывать брендовые иконки/изображения без прямого запроса. + +- Не перезаписывать брендовые иконки и изображения без прямого запроса. - Новые ассеты именовать однозначно и размещать в существующей структуре каталогов. - Для сезонных вариантов соблюдать текущую схему папок и имен. ## Антипаттерны, которых нужно избегать + - Широкий рефакторинг под видом локальной правки. - Несогласованные изменения сразу в нескольких слоях без необходимости. - Подмена логики временными хардкодами в production-потоке. - Тихое подавление ошибок без fallback и без возможности диагностики. ## Минимальная локальная валидация -- Запуск: npm run start. + +- Запуск: `npm run start`. - Проверка измененных импортов и отсутствия синтаксических ошибок. - Ручной smoke-test сценария, который затронут правкой. -- Если затронуто расписание: открыть экран, переключить формат/день/фильтры. +- Если затронуто расписание: открыть экран, переключить формат, день и фильтры. - Если затронуты новости или сеть: проверить поведение при сети и без сети. ## Чеклист перед сдачей + - Изменения соответствуют запросу пользователя и не выходят за рамки задачи. - Нет лишних файлов, случайных правок и отладочных следов. -- Обработаны null/undefined и пустые данные в новых ветках. +- Обработаны `null`, `undefined` и пустые данные в новых ветках. - Описаны риски и шаги ручной проверки. ## Формат итогового ответа пользователю -- Что сделано (кратко и по делу). + +- Что сделано. - Какие файлы затронуты. - Почему решение выбрано. - Какие риски остаются. diff --git a/README.md b/README.md index 533f398..cfc4d37 100644 --- a/README.md +++ b/README.md @@ -266,7 +266,11 @@ npx eas build --platform ios --profile development --local ``` > [!WARNING] -> Для локальной сборки под iOS требуется macOS с установленным Xcode. +> Для локальной сборки под iOS требуется macOS с установленным Xcode. Для development профиля требуется еще подключенное к Mac устройство iOS с активированным режимом разработчика (т. к. приложение будет устанавливаться непосредственно на него) + +> [!NOTE] +> **Комментарий TheDayG0ne:** +> *Лично я для сборки приложения под iOS использую MacBook Pro 16 2019 года (macOS Tahoe), либо - iMac 21,5 2012 года (macOS Sonoma), а для тестирования - iPhone 14 Pro Max (iOS 26), iPhone 6s (iOS 15). Иногда для тестирования используется iPad 10 поколения (iPadOS 26). На всех устройствах выполнен вход в мою учетную запись Apple Account (ранее - Apple ID), а также - активирован Режим разработчика.* ## 📚 Полезные ресурсы diff --git a/app.json b/app.json index 54d3198..f21287d 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Мой ИТИ ХГУ", "slug": "mykhsu", - "version": "2.3.1", + "version": "2.3.2", "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", @@ -74,9 +74,9 @@ [ "@sentry/react-native/expo", { - "url": "https://sentry.io/", // temporary migrate from sentry.sculkmetrics.com to sentry.io, because of tech works on our server, after works we will migrate back + "url": "https://sentry.io/", "project": "mykhsu", - "organization": "pro100byte-team" // temporary migrate from sculk to pro100byte-team, because of tech works on our server, after works we will migrate back + "organization": "pro100byte-team" } ], "expo-font", @@ -97,7 +97,8 @@ } ], "expo-splash-screen", - "expo-calendar" + "expo-calendar", + "@react-native-community/datetimepicker" ], "extra": { "eas": { diff --git a/components/AcademicCalendarScreen.js b/components/AcademicCalendarScreen.js new file mode 100644 index 0000000..9a65aae --- /dev/null +++ b/components/AcademicCalendarScreen.js @@ -0,0 +1,456 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import * as Notifications from 'expo-notifications'; +import { Ionicons as Icon } from '@expo/vector-icons'; +import { LIQUID_GLASS, ACCENT_COLORS } from '../utils/constants'; +import { exportAcademicEventsToCalendar } from '../utils/calendarExport'; +import notificationService from '../utils/notificationService'; +import AcademicEventModal from './AcademicEventModal'; +import { + ACADEMIC_EVENT_TYPES, + addAcademicEvent, + deleteAcademicEvent, + getAcademicEvents, + updateAcademicEvent, +} from '../utils/academicEventsStorage'; + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +const formatDisplayDate = (value) => { + if (!DATE_RE.test(String(value || ''))) return value || 'Дата не указана'; + const [year, month, day] = value.split('-'); + return `${day}.${month}.${year}`; +}; + +const AcademicCalendarScreen = ({ theme, accentColor }) => { + const [events, setEvents] = useState([]); + const [filter, setFilter] = useState('all'); + const [isModalVisible, setIsModalVisible] = useState(false); + const [editingEvent, setEditingEvent] = useState(null); + + const glass = LIQUID_GLASS[theme] || LIQUID_GLASS.light; + const colors = ACCENT_COLORS[accentColor] || ACCENT_COLORS.green; + + const loadEvents = async () => { + const list = await getAcademicEvents(); + setEvents(list); + }; + + useEffect(() => { + loadEvents(); + }, []); + + const visibleEvents = useMemo(() => { + if (filter === 'all') return events; + return events.filter((event) => event.type === filter); + }, [events, filter]); + + const upcomingCount = useMemo(() => { + const today = new Date().toISOString().slice(0, 10); + return events.filter((event) => event.date >= today).length; + }, [events]); + + const scheduleReminderIfNeeded = async (event) => { + if (!event.reminderEnabled || !DATE_RE.test(event.date)) return null; + const granted = await notificationService.requestPermissions(); + if (!granted) { + Alert.alert('Нет доступа', 'Разрешите уведомления, чтобы получать напоминания о событиях.'); + return null; + } + const [year, month, day] = event.date.split('-').map(Number); + const triggerDate = new Date(year, month - 1, day, 9, 0, 0, 0); + if (Number.isNaN(triggerDate.getTime()) || triggerDate <= new Date()) return null; + + return Notifications.scheduleNotificationAsync({ + content: { + title: 'Учебное событие', + body: `${event.title} сегодня`, + data: { type: 'academic_event', eventId: event.id }, + }, + trigger: { type: 'date', date: triggerDate }, + }); + }; + + const openCreateModal = () => { + setEditingEvent(null); + setIsModalVisible(true); + }; + + const openEditModal = (event) => { + setEditingEvent(event); + setIsModalVisible(true); + }; + + const closeModal = () => { + setIsModalVisible(false); + setEditingEvent(null); + }; + + const handleSaveEvent = async (draft) => { + const trimmedTitle = draft.title.trim(); + const trimmedDate = draft.date.trim(); + + if (!trimmedTitle) { + throw new Error('Введите название учебного события.'); + } + + if (!DATE_RE.test(trimmedDate)) { + throw new Error('Выберите дату учебного события через календарь.'); + } + + const eventId = draft.id || `academic_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`; + const previousEvent = draft.id ? events.find((item) => item.id === draft.id) : null; + const normalizedEvent = { + id: eventId, + title: trimmedTitle, + date: trimmedDate, + type: draft.type, + description: draft.description.trim(), + reminderEnabled: !!draft.reminderEnabled, + notificationId: null, + }; + + try { + if (previousEvent?.notificationId) { + try { + await Notifications.cancelScheduledNotificationAsync(previousEvent.notificationId); + } catch {} + } + + if (normalizedEvent.reminderEnabled) { + normalizedEvent.notificationId = await scheduleReminderIfNeeded(normalizedEvent); + } + + if (draft.id) { + await updateAcademicEvent(draft.id, normalizedEvent); + } else { + await addAcademicEvent(normalizedEvent); + } + + await loadEvents(); + } catch (error) { + throw new Error(error?.message || 'Не удалось сохранить учебное событие.'); + } + }; + + const handleExport = async () => { + try { + await exportAcademicEventsToCalendar(visibleEvents.map((event) => ({ + ...event, + typeLabel: (ACADEMIC_EVENT_TYPES.find((item) => item.key === event.type) || { label: 'Другое' }).label, + })), { + title: 'Учебные события MyKHSU', + fileName: 'academic_events', + }); + } catch (error) { + if (error?.message !== 'NO_ACADEMIC_EVENTS') { + Alert.alert('Ошибка', 'Не удалось экспортировать учебные события.'); + } else { + Alert.alert('Нет событий', 'Для экспорта пока нет учебных событий.'); + } + } + }; + + const handleDelete = async (event) => { + Alert.alert('Удалить событие', `Удалить "${event.title}"?`, [ + { text: 'Отмена', style: 'cancel' }, + { + text: 'Удалить', + style: 'destructive', + onPress: async () => { + try { + if (event.notificationId) { + await Notifications.cancelScheduledNotificationAsync(event.notificationId); + } + await deleteAcademicEvent(event.id); + await loadEvents(); + } catch { + Alert.alert('Ошибка', 'Не удалось удалить событие.'); + } + }, + }, + ]); + }; + + return ( + + + Календарь учебных событий + + + + + + + + Учебный планер + + + Всего событий: {events.length}. Ближайших и актуальных: {upcomingCount}. Добавляйте экзамены, зачеты и практику в одном месте. + + + + + + + Фильтры + + + + + + + Экспорт + + + + + {[{ key: 'all', label: 'Все' }, ...ACADEMIC_EVENT_TYPES].map((item) => { + const active = filter === item.key; + return ( + setFilter(item.key)} + style={[ + styles.chip, + { + borderColor: active ? colors.primary : glass.border, + backgroundColor: active ? colors.glass : glass.surfaceTertiary, + }, + ]} + > + + {item.label} + + + ); + })} + + + + + {visibleEvents.length === 0 && ( + + + + + + События не найдены + + + Попробуйте другой фильтр или нажмите на кнопку + в блоке фильтров. + + + )} + + {visibleEvents.map((event) => ( + + + + {formatDisplayDate(event.date).slice(0, 5)} + + + {formatDisplayDate(event.date).slice(6)} + + + + + + + {event.title} + + + + + {(ACADEMIC_EVENT_TYPES.find((item) => item.key === event.type) || { label: 'Другое' }).label} + + + {event.reminderEnabled ? ( + + + + Напоминание + + + ) : null} + + + + openEditModal(event)} style={styles.iconActionBtn}> + + + handleDelete(event)} style={styles.iconActionBtn}> + + + + + + + {formatDisplayDate(event.date)} + + + {!!event.description && ( + + {event.description} + + )} + + + ))} + + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + title: { + fontSize: 20, + fontFamily: 'Montserrat_700Bold', + marginBottom: 12, + }, + card: { + borderRadius: 14, + borderWidth: StyleSheet.hairlineWidth, + padding: 12, + }, + heroCard: { + borderRadius: 18, + borderWidth: StyleSheet.hairlineWidth, + padding: 14, + flexDirection: 'row', + gap: 12, + marginBottom: 12, + }, + heroBadge: { + width: 42, + height: 42, + borderRadius: 14, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(255,255,255,0.22)', + }, + label: { + fontSize: 12, + fontFamily: 'Montserrat_500Medium', + marginBottom: 6, + marginTop: 8, + }, + chipsRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 6, + marginTop: 6, + marginBottom: 12, + }, + filtersHeaderRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + gap: 8, + marginBottom: 6, + }, + eventsSection: { + marginTop: 6, + }, + chip: { + borderWidth: StyleSheet.hairlineWidth, + borderRadius: 999, + paddingHorizontal: 10, + paddingVertical: 6, + }, + eventRow: { + marginTop: 8, + borderRadius: 14, + borderWidth: StyleSheet.hairlineWidth, + padding: 12, + flexDirection: 'row', + alignItems: 'flex-start', + gap: 12, + }, + exportBtn: { + borderWidth: StyleSheet.hairlineWidth, + borderRadius: 999, + paddingHorizontal: 10, + paddingVertical: 7, + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + topActions: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + iconHeaderBtn: { + width: 33, + height: 33, + borderRadius: 16, + borderWidth: StyleSheet.hairlineWidth, + alignItems: 'center', + justifyContent: 'center', + }, + dateBadge: { + width: 62, + borderRadius: 14, + borderWidth: StyleSheet.hairlineWidth, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 10, + paddingHorizontal: 6, + }, + metaRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 6, + marginTop: 8, + }, + metaChip: { + flexDirection: 'row', + alignItems: 'center', + gap: 5, + borderWidth: StyleSheet.hairlineWidth, + borderRadius: 999, + paddingHorizontal: 8, + paddingVertical: 5, + }, + actionButtons: { + flexDirection: 'row', + gap: 4, + }, + iconActionBtn: { + width: 30, + height: 30, + borderRadius: 15, + alignItems: 'center', + justifyContent: 'center', + }, + emptyState: { + marginTop: 8, + borderRadius: 14, + borderWidth: StyleSheet.hairlineWidth, + padding: 20, + alignItems: 'center', + }, + emptyIconWrap: { + width: 48, + height: 48, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + }, +}); + +export default AcademicCalendarScreen; diff --git a/components/AcademicEventModal.js b/components/AcademicEventModal.js new file mode 100644 index 0000000..9d80619 --- /dev/null +++ b/components/AcademicEventModal.js @@ -0,0 +1,302 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + Alert, + Modal, + ScrollView, + StatusBar, + StyleSheet, + Switch, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; +import { Ionicons as Icon } from '@expo/vector-icons'; +import { ACCENT_COLORS, LIQUID_GLASS } from '../utils/constants'; +import { ACADEMIC_EVENT_TYPES } from '../utils/academicEventsStorage'; +import NativeDateField from './NativeDateField'; + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +const buildInitialState = (event) => ({ + id: event?.id || null, + title: event?.title || '', + date: event?.date || '', + type: event?.type || 'exam', + description: event?.description || '', + reminderEnabled: !!event?.reminderEnabled, +}); + +const AcademicEventModal = ({ visible, onClose, onSubmit, event, theme, accentColor }) => { + const [title, setTitle] = useState(''); + const [date, setDate] = useState(''); + const [type, setType] = useState('exam'); + const [description, setDescription] = useState(''); + const [reminderEnabled, setReminderEnabled] = useState(false); + const [hasChanges, setHasChanges] = useState(false); + const [saving, setSaving] = useState(false); + + const glass = LIQUID_GLASS[theme] || LIQUID_GLASS.light; + const colors = ACCENT_COLORS[accentColor] || ACCENT_COLORS.green; + const bgColor = glass.backgroundElevated || glass.background; + const textColor = glass.text; + const placeholderColor = glass.textSecondary; + const borderColor = glass.border; + const initialState = useMemo(() => buildInitialState(event), [event]); + const isEditing = !!event?.id; + + useEffect(() => { + if (visible) { + setTitle(initialState.title); + setDate(initialState.date); + setType(initialState.type); + setDescription(initialState.description); + setReminderEnabled(initialState.reminderEnabled); + setHasChanges(false); + setSaving(false); + } + }, [visible, initialState]); + + const markChanged = (setter) => (value) => { + setter(value); + setHasChanges(true); + }; + + const handleSubmit = async () => { + const trimmedTitle = title.trim(); + const trimmedDate = date.trim(); + + if (!trimmedTitle) { + Alert.alert('Пустой заголовок', 'Введите название учебного события.'); + return; + } + + if (!DATE_RE.test(trimmedDate)) { + Alert.alert('Некорректная дата', 'Выберите дату учебного события через календарь.'); + return; + } + + setSaving(true); + try { + await onSubmit({ + id: initialState.id, + title: trimmedTitle, + date: trimmedDate, + type, + description: description.trim(), + reminderEnabled, + }); + setHasChanges(false); + onClose(); + } catch (error) { + Alert.alert('Ошибка', error?.message || 'Не удалось сохранить учебное событие.'); + } finally { + setSaving(false); + } + }; + + const handleClose = () => { + if (!hasChanges || saving) { + if (!saving) onClose(); + return; + } + + Alert.alert( + 'Несохранённые изменения', + 'Сохранить изменения перед закрытием?', + [ + { text: 'Не сохранять', style: 'destructive', onPress: onClose }, + { text: 'Отмена', style: 'cancel' }, + { text: 'Сохранить', onPress: handleSubmit }, + ], + ); + }; + + return ( + + + + + + + + + + + + {isEditing ? 'Редактирование события' : 'Новое событие'} + + + Учебный планер + + + + + + + + + + + + + + + + + НАЗВАНИЕ + + + + + ТИП СОБЫТИЯ + + {ACADEMIC_EVENT_TYPES.map((eventType) => { + const active = type === eventType.key; + return ( + { + setType(eventType.key); + setHasChanges(true); + }} + style={[ + styles.chip, + { + borderColor: active ? colors.primary : borderColor, + backgroundColor: active ? colors.glass : glass.surfaceSecondary, + }, + ]} + > + + {eventType.label} + + + ); + })} + + + ОПИСАНИЕ + + + + + Локальное напоминание в 09:00 + + { + setReminderEnabled(value); + setHasChanges(true); + }} + trackColor={{ true: colors.primary, false: '#64748b' }} + /> + + + + + ); +}; + +const styles = StyleSheet.create({ + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingTop: 20, + paddingBottom: 16, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + saveBtn: { + width: 36, + height: 36, + borderRadius: 18, + justifyContent: 'center', + alignItems: 'center', + }, + label: { + fontSize: 12, + fontFamily: 'Montserrat_500Medium', + marginBottom: 6, + marginTop: 8, + }, + input: { + borderWidth: StyleSheet.hairlineWidth, + borderRadius: 12, + paddingHorizontal: 12, + paddingVertical: 10, + fontFamily: 'Montserrat_400Regular', + }, + multilineInput: { + minHeight: 96, + }, + chipsRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 6, + marginBottom: 8, + }, + chip: { + borderWidth: StyleSheet.hairlineWidth, + borderRadius: 999, + paddingHorizontal: 10, + paddingVertical: 6, + }, + switchRow: { + marginTop: 12, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderWidth: StyleSheet.hairlineWidth, + borderRadius: 14, + paddingHorizontal: 12, + paddingVertical: 12, + }, +}); + +export default AcademicEventModal; \ No newline at end of file diff --git a/components/AppearanceSettingsSheet.js b/components/AppearanceSettingsSheet.js index cbc82bb..d23f5be 100644 --- a/components/AppearanceSettingsSheet.js +++ b/components/AppearanceSettingsSheet.js @@ -4,12 +4,13 @@ import { Ionicons as Icon } from '@expo/vector-icons'; import { useColorScheme } from 'react-native'; import * as SecureStore from 'expo-secure-store'; import { ACCENT_COLORS, isNewYearPeriod, LIQUID_GLASS } from '../utils/constants'; -import { unlockAchievement } from '../utils/achievements'; +import { getAchievementsCount, unlockAchievement } from '../utils/achievements'; import { showAchievementToast } from './AchievementToast'; const AppearanceSettingsSheet = ({ theme, accentColor, + legendUnlocked, setTheme, setAccentColor, onTabbarSettingsChange, @@ -31,11 +32,25 @@ const AppearanceSettingsSheet = ({ const [showTabbarLabels, setShowTabbarLabels] = useState(true); const [tabbarFontSize, setTabbarFontSize] = useState('medium'); const [newYearSetting, setNewYearSetting] = useState(false); + const [localLegendUnlocked, setLocalLegendUnlocked] = useState(false); useEffect(() => { loadSettings(); }, []); + useEffect(() => { + checkLegendUnlock(); + }, []); + + const checkLegendUnlock = async () => { + try { + const stats = await getAchievementsCount(); + setLocalLegendUnlocked(stats.total > 0 && stats.unlocked >= stats.total); + } catch { + setLocalLegendUnlocked(false); + } + }; + const loadSettings = async () => { try { setSelectedTheme(theme); @@ -64,6 +79,11 @@ const AppearanceSettingsSheet = ({ }; const handleThemeChange = async (newTheme) => { + if (newTheme === 'legend' && !(legendUnlocked || localLegendUnlocked)) { + Alert.alert('Тема недоступна', 'Легендарная тема откроется после получения всех достижений.'); + return; + } + setSelectedTheme(newTheme); setTheme(newTheme); await SecureStore.setItemAsync('theme', newTheme); @@ -84,6 +104,11 @@ const AppearanceSettingsSheet = ({ }; const handleAccentColorChange = async (newColor) => { + if (newColor === 'legend' && !(legendUnlocked || localLegendUnlocked)) { + Alert.alert('Цвет недоступен', 'Легендарный акцент откроется после получения всех достижений.'); + return; + } + setAccentColor(newColor); await SecureStore.setItemAsync('accentColor', newColor); }; @@ -135,6 +160,7 @@ const AppearanceSettingsSheet = ({ { key: 'purple', label: 'Фиолетовый' }, ...(developerMode ? [{ key: 'orange', label: 'Оранжевый' }] : []), ...(developerMode ? [{ key: 'matrix', label: 'Матрица' }] : []), + ...((legendUnlocked || localLegendUnlocked) ? [{ key: 'legend', label: 'Легендарный' }] : []), ]; const themeOptions = [ @@ -142,6 +168,7 @@ const AppearanceSettingsSheet = ({ { key: 'dark', icon: 'moon-outline', label: 'Тёмная' }, { key: 'auto', icon: 'phone-portrait-outline', label: 'Системная', desc: systemColorScheme === 'dark' ? 'Тёмная' : 'Светлая' }, ...(developerMode ? [{ key: 'matrix', icon: 'code-slash-outline', label: 'Матрица', desc: 'Цифровой дождь' }] : []), + ...((legendUnlocked || localLegendUnlocked) ? [{ key: 'legend', icon: 'trophy-outline', label: 'Легендарная', desc: 'Награда за все достижения' }] : []), ]; return ( @@ -214,6 +241,22 @@ const AppearanceSettingsSheet = ({ )} + {(legendUnlocked || localLegendUnlocked) && selectedTheme === 'legend' && ( + + + + Эксклюзивная тема открыта за полный набор достижений. + + + )} + {(legendUnlocked || localLegendUnlocked) && accentColor === 'legend' && ( + + + + Легендарный акцент активен. + + + )} {/* Секция новогоднего настроения */} diff --git a/components/AttendanceStatsModal.js b/components/AttendanceStatsModal.js new file mode 100644 index 0000000..4aeb58e --- /dev/null +++ b/components/AttendanceStatsModal.js @@ -0,0 +1,201 @@ +import React from 'react'; +import { + View, + Text, + Modal, + ScrollView, + TouchableOpacity, + StyleSheet, + StatusBar, +} from 'react-native'; +import { Ionicons as Icon } from '@expo/vector-icons'; +import { ACCENT_COLORS, LIQUID_GLASS } from '../utils/constants'; +import { getAttendanceStats } from '../utils/attendanceStorage'; + +const AttendanceStatsModal = ({ visible, onClose, attendanceMap, theme, accentColor }) => { + const glass = LIQUID_GLASS[theme] || LIQUID_GLASS.light; + const colors = ACCENT_COLORS[accentColor] || ACCENT_COLORS.green; + const textColor = glass.text; + const placeholderColor = glass.textSecondary; + const borderColor = glass.border; + const bgColor = glass.backgroundElevated || glass.background; + + const stats = getAttendanceStats(attendanceMap || {}); + + const totalAttended = stats.reduce((s, r) => s + r.attended, 0); + const totalMissed = stats.reduce((s, r) => s + r.missed, 0); + const totalExcused = stats.reduce((s, r) => s + r.excused, 0); + const grandTotal = totalAttended + totalMissed + totalExcused; + + const ProgressBar = ({ value, total, color }) => { + const pct = total > 0 ? value / total : 0; + return ( + + + + ); + }; + + return ( + + + + + {/* Шапка */} + + + + + + + Посещаемость + + + + + + + + + {/* Общая статистика */} + {grandTotal > 0 && ( + + + ИТОГО + + + {[ + { label: 'Посещено', value: totalAttended, color: '#10b981', icon: 'checkmark-circle' }, + { label: 'Пропущено', value: totalMissed, color: '#ef4444', icon: 'close-circle' }, + { label: 'Уваж.', value: totalExcused, color: '#f59e0b', icon: 'remove-circle' }, + ].map(({ label, value, color, icon }) => ( + + + {value} + {label} + + ))} + + + )} + + {/* По предметам */} + {stats.length === 0 ? ( + + + + Нет данных о посещаемости + + + Отмечайте посещение в карточках занятий + + + ) : ( + <> + + ПО ПРЕДМЕТАМ + + {stats.map((item) => ( + + {/* Заголовок */} + + + {item.subject} + + {item.isWarning && ( + + + + {Math.round(item.absentRatio * 100)}% пропусков + + + )} + + + {/* Счётчики */} + + {[ + { label: 'Посещ.', value: item.attended, color: '#10b981' }, + { label: 'Пропуск', value: item.missed, color: '#ef4444' }, + { label: 'Уваж.', value: item.excused, color: '#f59e0b' }, + ].map(({ label, value, color }) => ( + + {value} + {label} + + ))} + + + {/* Прогресс-бар */} + + + + + + {item.total} + + + + ))} + + )} + + + + ); +}; + +export default AttendanceStatsModal; diff --git a/components/DeveloperMenuScreen.js b/components/DeveloperMenuScreen.js index 6f0682e..5d3dbd2 100644 --- a/components/DeveloperMenuScreen.js +++ b/components/DeveloperMenuScreen.js @@ -6,6 +6,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { ACCENT_COLORS, API_BASE_URL, APP_VERSION, BUILD_VER, BUILD_DATE, LIQUID_GLASS } from '../utils/constants'; import { loadAllNotes, clearAllNotes, getNotesCount, saveNote } from '../utils/notesStorage'; import { unlockAchievement, clearAchievements, getAchievementsCount, ACHIEVEMENT_DEFINITIONS } from '../utils/achievements'; +import { addAcademicEvent, getAcademicEvents, saveAcademicEvents } from '../utils/academicEventsStorage'; import * as Updates from 'expo-updates'; import * as Notifications from 'expo-notifications'; import * as TaskManager from 'expo-task-manager'; @@ -15,6 +16,7 @@ import ApiService from '../utils/api'; import notificationService from '../utils/notificationService'; import backgroundService from '../utils/backgroundService'; import { exportScheduleToCalendar } from '../utils/calendarExport'; +import SnakeGame from './SnakeGame'; const DeveloperMenuScreen = ({ theme, accentColor, onResetDeveloperMode }) => { const [customApiUrl, setCustomApiUrl] = useState(''); @@ -22,6 +24,10 @@ const DeveloperMenuScreen = ({ theme, accentColor, onResetDeveloperMode }) => { const [cacheKeys, setCacheKeys] = useState([]); const [secureKeys, setSecureKeys] = useState([]); const [showDebugInfo, setShowDebugInfo] = useState(false); + const [snakeVisible, setSnakeVisible] = useState(false); + const [storageKeyInput, setStorageKeyInput] = useState(''); + const [storageKeyValue, setStorageKeyValue] = useState(null); + const [apiPingResults, setApiPingResults] = useState(null); const colors = ACCENT_COLORS[accentColor]; const glass = LIQUID_GLASS[theme] || LIQUID_GLASS.light; @@ -144,6 +150,61 @@ const DeveloperMenuScreen = ({ theme, accentColor, onResetDeveloperMode }) => { ); }; + const createPlannerFixtures = async () => { + const today = new Date(); + const inTwoDays = new Date(today); + inTwoDays.setDate(today.getDate() + 2); + const inFiveDays = new Date(today); + inFiveDays.setDate(today.getDate() + 5); + const toISO = (date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + }; + + await addAcademicEvent({ + title: 'Тестовый экзамен по аналитике', + date: toISO(inFiveDays), + type: 'exam', + description: 'Аудитория 305, взять зачетку и ручку.', + reminderEnabled: false, + }); + + await addAcademicEvent({ + title: 'Тестовая практика по проекту', + date: toISO(inTwoDays), + type: 'practice', + description: 'Подготовить прототип и презентацию.', + reminderEnabled: false, + }); + + await saveNote({ + subject: 'Тестовый предмет', + weekday: 1, + timeSlot: 1, + group: 'ТСТ-01', + noteText: 'Тестовая заметка из учебного планера', + homework: 'Подготовить отчет по лабораторной работе', + homeworkStatus: 'in_progress', + homeworkDueDate: toISO(inTwoDays), + }); + + await notificationService.appendScheduleChangeHistory([ + { + type: 'changed', + weekday: 2, + prev: { subject: 'Алгоритмы', teacher: 'Иванов И.И.', auditory: '201', time: '2' }, + lesson: { subject: 'Алгоритмы', teacher: 'Петров П.П.', auditory: '410', time: '2' }, + }, + ], 'ТСТ-01'); + }; + + const clearPlannerFixtures = async () => { + await saveAcademicEvents([]); + await AsyncStorage.removeItem('schedule_changes_history_v1'); + }; + return ( { + {/* Учебный планер */} + + Учебный планер + + { + try { + await createPlannerFixtures(); + Alert.alert('Готово', 'Добавлены тестовые учебные события, дедлайн и запись в историю изменений.'); + } catch (e) { + Alert.alert('Ошибка', e.message); + } + }} + > + + Создать тестовые данные планера + + + { + try { + const events = await getAcademicEvents(); + const history = await notificationService.getScheduleChangesHistory(30); + Alert.alert( + 'Статистика планера', + `Учебных событий: ${events.length}\nИстория изменений: ${history.length}` + ); + } catch (e) { + Alert.alert('Ошибка', e.message); + } + }} + > + + Статистика учебного планера + + + { + Alert.alert( + 'Очистить данные планера?', + 'Будут удалены учебные события и история изменений расписания.', + [ + { text: 'Отмена', style: 'cancel' }, + { + text: 'Очистить', + style: 'destructive', + onPress: async () => { + try { + await clearPlannerFixtures(); + Alert.alert('Готово', 'Данные учебного планера очищены'); + } catch (e) { + Alert.alert('Ошибка', e.message); + } + }, + }, + ] + ); + }} + > + + Очистить данные планера + + + {/* Достижения */} Достижения @@ -973,6 +1101,44 @@ const DeveloperMenuScreen = ({ theme, accentColor, onResetDeveloperMode }) => { + { + const result = await unlockAchievement('offline_hero'); + if (result) { + Alert.alert('Успех', 'Ачивка "Партизан" разблокирована'); + } else { + Alert.alert('Инфо', 'Ачивка "Партизан" уже получена'); + } + }} + > + + Тест: offline_hero ачивка + + + { + let count = 0; + for (const id of Object.keys(ACHIEVEMENT_DEFINITIONS)) { + const result = await unlockAchievement(id); + if (result) count += 1; + } + Alert.alert('Готово', `Разблокировано новых ачивок: ${count}`); + }} + > + + Разблокировать все ачивки + + + setSnakeVisible(true)} + > + + Мини-игра + + { @@ -1032,12 +1198,94 @@ const DeveloperMenuScreen = ({ theme, accentColor, onResetDeveloperMode }) => { {/* Информация */} - + Некоторые изменения требуют перезапуска приложения + + {/* Пинг всех API */} + + Пинг всех API + { + setApiPingResults(null); + const endpoints = [ + { label: 'Новости', fn: () => ApiService.getNews(0, 1) }, + { label: 'Группы', fn: () => ApiService.getGroups(1) }, + { label: 'Расписание', fn: () => ApiService.getSchedule('125-1', new Date()) }, + ]; + const results = await Promise.allSettled( + endpoints.map(async e => { + const start = Date.now(); + const r = await e.fn(); + return { label: e.label, ms: Date.now() - start, source: r?.source || '?' }; + }) + ); + const lines = results.map((r, i) => + r.status === 'fulfilled' + ? `${endpoints[i].label}: ${r.value.ms} мс [${r.value.source}]` + : `${endpoints[i].label}: ошибка` + ); + setApiPingResults(lines.join('\n')); + Alert.alert('Пинг API', lines.join('\n')); + }} + > + + Запустить пинг всех эндпоинтов + + {apiPingResults ? ( + + {apiPingResults} + + ) : null} + + + {/* Инспектор AsyncStorage */} + + Инспектор AsyncStorage + + { + const key = storageKeyInput.trim(); + if (!key) { Alert.alert('Ошибка', 'Введите ключ'); return; } + try { + const value = await AsyncStorage.getItem(key); + setStorageKeyValue(value !== null ? value : '(null)'); + } catch (e) { + setStorageKeyValue(`Ошибка: ${e.message}`); + } + }} + > + Прочитать значение + + {storageKeyValue !== null ? ( + + Значение: + + {storageKeyValue} + + + ) : null} + + + setSnakeVisible(false)} + theme={theme} + accentColor={accentColor} + /> ); diff --git a/components/FreeAuditoriesScreen.js b/components/FreeAuditoriesScreen.js new file mode 100644 index 0000000..cbf787f --- /dev/null +++ b/components/FreeAuditoriesScreen.js @@ -0,0 +1,598 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { + View, + Text, + Modal, + ScrollView, + TouchableOpacity, + TextInput, + ActivityIndicator, + StyleSheet, + StatusBar, + Alert, +} from 'react-native'; +import { Ionicons as Icon } from '@expo/vector-icons'; +import { ACCENT_COLORS, LIQUID_GLASS } from '../utils/constants'; +import ApiService from '../utils/api'; +import { unlockAchievement } from '../utils/achievements'; +import { showAchievementToast } from './AchievementToast'; + +const WEEKDAY_SHORT = ['', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']; + +const getMondayForCurrentWeek = () => { + const today = new Date(); + const day = today.getDay() || 7; + const monday = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + monday.setDate(monday.getDate() - (day - 1)); + return monday; +}; + +const getDateByRelativeWeek = (targetWeek, baseWeek, dayOfWeek) => { + const safeBaseWeek = Number(baseWeek) || Number(targetWeek) || 1; + const safeTargetWeek = Number(targetWeek) || safeBaseWeek; + const weekDiff = safeTargetWeek - safeBaseWeek; + const baseMonday = getMondayForCurrentWeek(); + const targetDate = new Date(baseMonday); + targetDate.setDate(baseMonday.getDate() + weekDiff * 7 + (dayOfWeek - 1)); + return targetDate; +}; + +const formatHintDate = (date) => { + const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + return `${day}.${month}`; +}; + +const buildWeekOptions = (current, fromApi) => { + if (Array.isArray(fromApi) && fromApi.length > 0) { + return [...new Set(fromApi.map((w) => Number(w)).filter((w) => Number.isFinite(w) && w > 0))].sort((a, b) => a - b); + } + + const base = Number(current) || 1; + const left = Math.max(1, base - 2); + return [left, left + 1, left + 2, left + 3, left + 4]; +}; + +const FreeAuditoriesScreen = ({ visible, onClose, theme, accentColor, currentWeek, pairsTime }) => { + const glass = LIQUID_GLASS[theme] || LIQUID_GLASS.light; + const colors = ACCENT_COLORS[accentColor] || ACCENT_COLORS.green; + const textColor = glass.text; + const placeholderColor = glass.textSecondary; + const borderColor = glass.border; + const bgColor = glass.backgroundElevated || glass.background; + + const [searchQuery, setSearchQuery] = useState(''); + const [selectedDay, setSelectedDay] = useState(() => { + const d = new Date().getDay(); + return d === 0 ? 7 : d; // 1–7 + }); + const [selectedSlot, setSelectedSlot] = useState(null); // номер пары + const [loading, setLoading] = useState(false); + const [results, setResults] = useState(null); // null = не запускали + const [localPairsTime, setLocalPairsTime] = useState([]); + const [weekNum, setWeekNum] = useState(currentWeek || 1); + const [weekOptions, setWeekOptions] = useState(buildWeekOptions(currentWeek || 1)); + const searchRef = useRef(null); + + const effectivePairsTime = pairsTime && pairsTime.length > 0 ? pairsTime : localPairsTime; + + // Определяем текущую пару по времени + const getCurrentSlot = useCallback(() => { + if (!effectivePairsTime || effectivePairsTime.length === 0) return null; + const now = new Date(); + const hhmm = (h, m) => h * 60 + m; + const nowMins = hhmm(now.getHours(), now.getMinutes()); + for (const pt of effectivePairsTime) { + const [sh, sm] = (pt.time_start || pt.start || '00:00').split(':').map(Number); + const [eh, em] = (pt.time_end || pt.end || '00:00').split(':').map(Number); + if (nowMins >= hhmm(sh, sm) - 5 && nowMins <= hhmm(eh, em)) { + return pt.time; + } + } + // Ближайшая следующая + for (const pt of effectivePairsTime) { + const [sh, sm] = (pt.time_start || pt.start || '00:00').split(':').map(Number); + if (hhmm(sh, sm) > nowMins) return pt.time; + } + return null; + }, [effectivePairsTime]); + + useEffect(() => { + if (!visible) return; + // Загружаем пары и неделю, если не переданы + unlockAchievement('free_room_hunter').then(result => { + if (result) showAchievementToast(result); + }).catch(() => {}); + + if (!pairsTime || pairsTime.length === 0) { + ApiService.getPairsTime().then(r => { + if (r?.data?.pairs_time) setLocalPairsTime(r.data.pairs_time); + }).catch(() => {}); + } + if (currentWeek) { + setWeekNum(currentWeek); + setWeekOptions(buildWeekOptions(currentWeek)); + } + + ApiService.getWeekNumbers().then(r => { + const fromApiCurrent = r?.data?.current_week_number; + if (!currentWeek && fromApiCurrent) { + setWeekNum(fromApiCurrent); + } + setWeekOptions(buildWeekOptions(currentWeek || fromApiCurrent || weekNum, r?.data?.week_numbers)); + }).catch(() => { + setWeekOptions(buildWeekOptions(currentWeek || weekNum)); + }); + }, [visible]); + + // Устанавливаем текущую пару при загрузке времён + useEffect(() => { + if (selectedSlot == null && effectivePairsTime.length > 0) { + const cur = getCurrentSlot(); + if (cur != null) setSelectedSlot(cur); + else if (effectivePairsTime.length > 0) setSelectedSlot(effectivePairsTime[0].time); + } + }, [effectivePairsTime]); + + const handleSearch = useCallback(async () => { + const q = searchQuery.trim(); + if (!q) { + Alert.alert('Введите запрос', 'Укажите номер или префикс аудитории (например: 2-, 1-2)'); + return; + } + if (selectedSlot == null) { + Alert.alert('Выберите пару', 'Укажите номер пары для проверки'); + return; + } + + setLoading(true); + setResults(null); + try { + // Поиск аудиторий + const searchRes = await ApiService.search(q); + const auditories = (searchRes?.data?.auditories || []).slice(0, 20); + + if (auditories.length === 0) { + setResults({ error: 'Аудитории не найдены. Попробуйте другой запрос.' }); + return; + } + + // Параллельно загружаем расписание для каждой аудитории + const week = weekNum || 1; + const schedules = await Promise.allSettled( + auditories.map(aud => ApiService.getAuditorySchedule(aud, week)) + ); + + const resultList = []; + for (let i = 0; i < auditories.length; i++) { + const aud = auditories[i]; + const settled = schedules[i]; + if (settled.status !== 'fulfilled' || !settled.value?.data) { + resultList.push({ auditory: aud, isFree: null }); // неизвестно + continue; + } + const data = settled.value.data; + const days = data.days || []; + // Ищем день с нужным weekday + const dayData = days.find(d => d && d.weekday === selectedDay); + if (!dayData || !dayData.lessons || dayData.lessons.length === 0) { + resultList.push({ auditory: aud, isFree: true }); + continue; + } + const lessonAtSlot = dayData.lessons.find(l => l && l.time === selectedSlot); + if (lessonAtSlot) { + resultList.push({ auditory: aud, isFree: false, lesson: lessonAtSlot }); + } else { + resultList.push({ auditory: aud, isFree: true }); + } + } + + setResults({ list: resultList }); + } catch (e) { + console.error('FreeAuditoriesScreen error:', e); + setResults({ error: 'Ошибка загрузки. Проверьте подключение к сети.' }); + } finally { + setLoading(false); + } + }, [searchQuery, selectedSlot, selectedDay, weekNum]); + + const selectedPairInfo = effectivePairsTime.find(p => p.time === selectedSlot); + const minWeek = weekOptions.length > 0 ? Math.min(...weekOptions) : 1; + const maxWeek = weekOptions.length > 0 ? Math.max(...weekOptions) : Number.MAX_SAFE_INTEGER; + const canGoPrevWeek = weekNum > minWeek; + const canGoNextWeek = weekNum < maxWeek; + + const changeWeek = (delta) => { + setWeekNum((prev) => { + const next = Number(prev || 1) + delta; + if (next < minWeek || next > maxWeek) return prev; + return next; + }); + }; + + return ( + + + + + {/* Шапка */} + + + + + + + + Свободные аудитории + + + Неделя {weekNum} + + + + + + + + + + {/* Выбор недели */} + + НЕДЕЛЯ + + + changeWeek(-1)} + style={{ padding: 6, borderRadius: 10, backgroundColor: canGoPrevWeek ? colors.glass : 'transparent' }} + disabled={!canGoPrevWeek} + > + + + + + Неделя {weekNum} + + + changeWeek(1)} + style={{ padding: 6, borderRadius: 10, backgroundColor: canGoNextWeek ? colors.glass : 'transparent' }} + disabled={!canGoNextWeek} + > + + + + + {/* Выбор дня */} + + ДЕНЬ НЕДЕЛИ + + + {[1, 2, 3, 4, 5, 6].map(d => ( + (() => { + const dayDate = getDateByRelativeWeek(weekNum, currentWeek || weekNum, d); + return ( + setSelectedDay(d)} + style={{ + paddingVertical: 8, + paddingHorizontal: 14, + borderRadius: 14, + backgroundColor: selectedDay === d ? colors.primary : glass.surfaceSecondary, + borderWidth: selectedDay === d ? 1.5 : StyleSheet.hairlineWidth, + borderColor: selectedDay === d ? colors.primary : glass.border, + }} + > + + {WEEKDAY_SHORT[d]} + + + {formatHintDate(dayDate)} + + + ); + })() + ))} + + + {/* Выбор пары */} + + ПАРА + + {effectivePairsTime.length === 0 ? ( + + ) : ( + + {effectivePairsTime.map(pt => ( + setSelectedSlot(pt.time)} + style={{ + paddingVertical: 8, + paddingHorizontal: 12, + borderRadius: 14, + backgroundColor: selectedSlot === pt.time ? colors.primary : glass.surfaceSecondary, + borderWidth: selectedSlot === pt.time ? 1.5 : StyleSheet.hairlineWidth, + borderColor: selectedSlot === pt.time ? colors.primary : glass.border, + alignItems: 'center', + }} + > + + №{pt.time} + + + {pt.time_start || pt.start} + + + ))} + + )} + + {/* Поиск аудитории */} + + ПОИСК АУДИТОРИИ + + + + + {searchQuery.length > 0 && ( + setSearchQuery('')} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}> + + + )} + + + {/* Кнопка поиска */} + + {loading ? ( + + ) : ( + + )} + + {loading ? 'Проверяем...' : 'Найти свободные'} + + + + {/* Результаты */} + {results?.error && ( + + + + {results.error} + + + )} + + {results?.list && ( + <> + + РЕЗУЛЬТАТЫ — {WEEKDAY_SHORT[selectedDay]}, пара №{selectedSlot} + {selectedPairInfo ? ` (${selectedPairInfo.time_start || selectedPairInfo.start}–${selectedPairInfo.time_end || selectedPairInfo.end})` : ''} + + + {/* Свободные */} + {results.list.filter(r => r.isFree === true).length > 0 && ( + + + + + Свободны ({results.list.filter(r => r.isFree === true).length}) + + + + {results.list.filter(r => r.isFree === true).map(r => ( + + + {r.auditory} + + + ))} + + + )} + + {/* Занятые */} + {results.list.filter(r => r.isFree === false).length > 0 && ( + + + + + Заняты ({results.list.filter(r => r.isFree === false).length}) + + + {results.list.filter(r => r.isFree === false).map(r => ( + + + + + + + {r.auditory} + + {r.lesson && ( + + {r.lesson.subject} + {r.lesson.group && Array.isArray(r.lesson.group) ? ` · ${r.lesson.group.join(', ')}` : ''} + + )} + + + ))} + + )} + + {/* Неизвестно */} + {results.list.filter(r => r.isFree === null).length > 0 && ( + + + + + Данные недоступны ({results.list.filter(r => r.isFree === null).length}) + + + + {results.list.filter(r => r.isFree === null).map(r => ( + + + {r.auditory} + + + ))} + + + )} + + )} + + + + ); +}; + +export default FreeAuditoriesScreen; diff --git a/components/FreshmanScreen.js b/components/FreshmanScreen.js index b380f28..6fd41b7 100644 --- a/components/FreshmanScreen.js +++ b/components/FreshmanScreen.js @@ -4,6 +4,7 @@ import { Ionicons as Icon } from '@expo/vector-icons'; import { ACCENT_COLORS, LIQUID_GLASS } from '../utils/constants'; import UnderDevelopmentModal from './UnderDevelopmentModal'; import BuildingsListScreen from './BuildingsListScreen'; +import AcademicCalendarScreen from './AcademicCalendarScreen'; import Snowfall from './Snowfall'; import { unlockAchievement } from '../utils/achievements'; import { showAchievementToast } from './AchievementToast'; @@ -46,6 +47,8 @@ const FreshmanScreen = forwardRef(({ theme, accentColor, isNewYearMode, onNaviga title = 'Полезные группы'; } else if (currentGroupType === 'bells') { title = 'Расписание звонков'; + } else if (currentGroupType === 'academicCalendar') { + title = 'Календарь учебных событий'; } else if (currentGroupType === 'glossary') { title = 'Словарь аббревиатур'; } @@ -347,6 +350,13 @@ const FreshmanScreen = forwardRef(({ theme, accentColor, isNewYearMode, onNaviga () => setCurrentGroupType('bells'), )} + {renderSectionCard( + 'today-outline', + 'Календарь учебных событий', + 'Экзамены, зачеты, практика и экспорт в календарь', + () => setCurrentGroupType('academicCalendar'), + )} + {renderSectionCard( 'book-outline', 'Словарь аббревиатур', @@ -587,6 +597,10 @@ const FreshmanScreen = forwardRef(({ theme, accentColor, isNewYearMode, onNaviga ); + const renderAcademicCalendar = () => ( + + ); + // Расписание звонков (загружается с сервера) const bellSchedule = bellScheduleData || []; const renderBellSchedule = () => { @@ -827,6 +841,8 @@ const FreshmanScreen = forwardRef(({ theme, accentColor, isNewYearMode, onNaviga return renderGroupTypeSelection(); } else if (currentGroupType === 'bells') { return renderBellSchedule(); + } else if (currentGroupType === 'academicCalendar') { + return renderAcademicCalendar(); } else if (currentGroupType === 'glossary') { return renderGlossary(); } diff --git a/components/LessonNoteModal.js b/components/LessonNoteModal.js index 8642a0d..2fce8ed 100644 --- a/components/LessonNoteModal.js +++ b/components/LessonNoteModal.js @@ -13,11 +13,14 @@ import { Animated, Dimensions, } from 'react-native'; +import * as Notifications from 'expo-notifications'; import { Ionicons as Icon } from '@expo/vector-icons'; import { LIQUID_GLASS, ACCENT_COLORS } from '../utils/constants'; -import { saveNote, loadNote, deleteNote } from '../utils/notesStorage'; +import { saveNote, loadNote, deleteNote, HOMEWORK_STATUSES } from '../utils/notesStorage'; import { unlockAchievement, incrementCounter } from '../utils/achievements'; import { showAchievementToast } from './AchievementToast'; +import NativeDateField from './NativeDateField'; +import notificationService from '../utils/notificationService'; const TABBAR_HEIGHT = 90; const SCREEN_HEIGHT = Dimensions.get('window').height; @@ -25,6 +28,9 @@ const SCREEN_HEIGHT = Dimensions.get('window').height; const LessonNoteModal = ({ visible, onClose, lesson, weekday, theme, accentColor }) => { const [noteText, setNoteText] = useState(''); const [homework, setHomework] = useState(''); + const [homeworkStatus, setHomeworkStatus] = useState(HOMEWORK_STATUSES.TODO); + const [homeworkDueDate, setHomeworkDueDate] = useState(''); + const [homeworkReminderId, setHomeworkReminderId] = useState(null); const [loading, setLoading] = useState(false); const [hasChanges, setHasChanges] = useState(false); const [rendered, setRendered] = useState(false); @@ -88,9 +94,15 @@ const LessonNoteModal = ({ visible, onClose, lesson, weekday, theme, accentColor if (existing) { setNoteText(existing.noteText || ''); setHomework(existing.homework || ''); + setHomeworkStatus(existing.homeworkStatus || HOMEWORK_STATUSES.TODO); + setHomeworkDueDate(existing.homeworkDueDate || ''); + setHomeworkReminderId(existing.homeworkReminderNotificationId || null); } else { setNoteText(''); setHomework(''); + setHomeworkStatus(HOMEWORK_STATUSES.TODO); + setHomeworkDueDate(''); + setHomeworkReminderId(null); } setHasChanges(false); } catch (e) { @@ -102,11 +114,55 @@ const LessonNoteModal = ({ visible, onClose, lesson, weekday, theme, accentColor const handleSave = async () => { try { + const trimmedDueDate = homeworkDueDate.trim(); + if (trimmedDueDate && !/^\d{4}-\d{2}-\d{2}$/.test(trimmedDueDate)) { + Alert.alert('Некорректная дата', 'Используйте формат ГГГГ-ММ-ДД для дедлайна.'); + return; + } + + let nextReminderId = homeworkReminderId || null; + const shouldCancelReminder = !homework.trim() || !trimmedDueDate || homeworkStatus === HOMEWORK_STATUSES.DONE; + + if (shouldCancelReminder && homeworkReminderId) { + try { + await Notifications.cancelScheduledNotificationAsync(homeworkReminderId); + } catch {} + nextReminderId = null; + } + + if (!shouldCancelReminder && trimmedDueDate) { + const granted = await notificationService.requestPermissions(); + if (!granted) { + Alert.alert('Нет доступа', 'Разрешите уведомления, чтобы получать напоминания о дедлайнах.'); + } + const [year, month, day] = trimmedDueDate.split('-').map(Number); + const triggerDate = new Date(year, month - 1, day, 9, 0, 0, 0); + if (granted && triggerDate > new Date()) { + if (homeworkReminderId) { + try { + await Notifications.cancelScheduledNotificationAsync(homeworkReminderId); + } catch {} + } + nextReminderId = await Notifications.scheduleNotificationAsync({ + content: { + title: 'Дедлайн по паре', + body: `${lesson?.subject || 'Задание'}: ${homework.trim().slice(0, 80)}`, + data: { type: 'homework_deadline', subject: lesson?.subject || '' }, + }, + trigger: { type: 'date', date: triggerDate }, + }); + } + } + await saveNote({ ...lessonParams, noteText: noteText.trim(), homework: homework.trim(), + homeworkStatus: homework.trim() ? homeworkStatus : null, + homeworkDueDate: homework.trim() ? (trimmedDueDate || null) : null, + homeworkReminderNotificationId: nextReminderId, }); + setHomeworkReminderId(nextReminderId); setHasChanges(false); // Ачивки @@ -119,6 +175,11 @@ const LessonNoteModal = ({ visible, onClose, lesson, weekday, theme, accentColor const hwMaster = await unlockAchievement('homework_master'); if (hwMaster) showAchievementToast(hwMaster); } + + if (homeworkStatus === HOMEWORK_STATUSES.DONE) { + const hwDone = await unlockAchievement('homework_done'); + if (hwDone) showAchievementToast(hwDone); + } } onClose(true); // true = saved @@ -139,9 +200,15 @@ const LessonNoteModal = ({ visible, onClose, lesson, weekday, theme, accentColor style: 'destructive', onPress: async () => { try { + if (homeworkReminderId) { + try { + await Notifications.cancelScheduledNotificationAsync(homeworkReminderId); + } catch {} + } await deleteNote(lessonParams); setNoteText(''); setHomework(''); + setHomeworkReminderId(null); setHasChanges(false); onClose(true); } catch (e) { @@ -171,6 +238,14 @@ const LessonNoteModal = ({ visible, onClose, lesson, weekday, theme, accentColor const hasContent = noteText.trim().length > 0 || homework.trim().length > 0; + const setQuickDueDate = (daysFromNow) => { + const date = new Date(); + date.setDate(date.getDate() + daysFromNow); + const iso = date.toISOString().slice(0, 10); + setHomeworkDueDate(iso); + setHasChanges(true); + }; + if (!visible && !rendered) return null; return ( @@ -254,6 +329,102 @@ const LessonNoteModal = ({ visible, onClose, lesson, weekday, theme, accentColor multiline textAlignVertical="top" /> + + {homework.trim().length > 0 && ( + <> + + Статус + + + {[ + { key: HOMEWORK_STATUSES.TODO, label: 'К выполнению', color: '#64748b' }, + { key: HOMEWORK_STATUSES.IN_PROGRESS, label: 'В работе', color: '#f59e0b' }, + { key: HOMEWORK_STATUSES.DONE, label: 'Сделано', color: '#10b981' }, + ].map(item => { + const active = homeworkStatus === item.key; + return ( + { + setHomeworkStatus(item.key); + setHasChanges(true); + }} + style={{ + flex: 1, + borderRadius: 10, + paddingVertical: 7, + alignItems: 'center', + backgroundColor: active ? `${item.color}22` : glass.surfaceTertiary, + borderWidth: StyleSheet.hairlineWidth, + borderColor: active ? item.color : glass.border, + }} + > + + {item.label} + + + ); + })} + + + { + setHomeworkDueDate(nextValue); + setHasChanges(true); + }} + theme={theme} + accentColor={accentColor} + placeholder="Выбрать дедлайн" + /> + + + setQuickDueDate(1)} + style={{ + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 10, + backgroundColor: glass.surfaceTertiary, + borderWidth: StyleSheet.hairlineWidth, + borderColor: glass.border, + }} + > + +1 день + + setQuickDueDate(7)} + style={{ + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 10, + backgroundColor: glass.surfaceTertiary, + borderWidth: StyleSheet.hairlineWidth, + borderColor: glass.border, + }} + > + +7 дней + + { + setHomeworkDueDate(''); + setHasChanges(true); + }} + style={{ + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 10, + backgroundColor: glass.surfaceTertiary, + borderWidth: StyleSheet.hairlineWidth, + borderColor: glass.border, + }} + > + Сбросить + + + + )} {/* Домашнее задание */} diff --git a/components/NativeDateField.js b/components/NativeDateField.js new file mode 100644 index 0000000..5ad620c --- /dev/null +++ b/components/NativeDateField.js @@ -0,0 +1,234 @@ +import React, { useMemo, useState } from 'react'; +import { Keyboard, Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import DateTimePicker, { DateTimePickerAndroid } from '@react-native-community/datetimepicker'; +import { Ionicons as Icon } from '@expo/vector-icons'; +import { LIQUID_GLASS, ACCENT_COLORS } from '../utils/constants'; + +const PICKER_LOCALE = 'ru-RU'; + +const formatISODate = (date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +}; + +const formatDisplayDate = (date) => { + const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const year = date.getFullYear(); + return `${day}.${month}.${year}`; +}; + +const parseISODate = (value) => { + const normalized = String(value || '').trim(); + if (!normalized) return null; + + let year; + let month; + let day; + + if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) { + [year, month, day] = normalized.split('-').map(Number); + } else if (/^\d{2}\.\d{2}\.\d{4}$/.test(normalized)) { + [day, month, year] = normalized.split('.').map(Number); + } else { + return null; + } + + const result = new Date(year, month - 1, day, 12, 0, 0, 0); + return Number.isNaN(result.getTime()) ? null : result; +}; + +const NativeDateField = ({ label, value, onChange, theme, accentColor, placeholder = 'Выбрать дату', clearable = true }) => { + const [showIOSPicker, setShowIOSPicker] = useState(false); + const glass = LIQUID_GLASS[theme] || LIQUID_GLASS.light; + const colors = ACCENT_COLORS[accentColor] || ACCENT_COLORS.green; + const selectedDate = useMemo(() => parseISODate(value) || new Date(), [value]); + const displayValue = useMemo(() => { + const parsed = parseISODate(value); + return parsed ? formatDisplayDate(parsed) : ''; + }, [value]); + + const openPicker = () => { + Keyboard.dismiss(); + + if (!value) { + onChange(formatISODate(new Date())); + } + + if (Platform.OS === 'android') { + DateTimePickerAndroid.open({ + value: selectedDate, + mode: 'date', + display: 'calendar', + is24Hour: true, + onChange: (event, pickedDate) => { + if (event.type === 'set' && pickedDate) { + onChange(formatISODate(pickedDate)); + } + }, + }); + return; + } + + setTimeout(() => { + setShowIOSPicker((prev) => !prev); + }, 80); + }; + + return ( + + {label ? ( + + {label} + + ) : null} + + + + + + {displayValue || placeholder} + + + + {clearable && value ? ( + onChange('')} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}> + + + ) : null} + + + {Platform.OS === 'ios' && showIOSPicker ? ( + + + + + + Выбор даты + + + + {formatDisplayDate(selectedDate)} + + + + { + if (pickedDate) { + onChange(formatISODate(pickedDate)); + } + }} + accentColor={colors.primary} + /> + + + onChange(formatISODate(new Date()))} + style={styles.actionBtn} + > + + Сегодня + + + + {clearable && value ? ( + onChange('')} + style={styles.actionBtn} + > + + Очистить + + + ) : } + + { + Keyboard.dismiss(); + setShowIOSPicker(false); + }} + style={styles.actionBtn} + > + + Готово + + + + + ) : null} + + ); +}; + +const styles = StyleSheet.create({ + field: { + minHeight: 46, + borderRadius: 12, + borderWidth: StyleSheet.hairlineWidth, + paddingHorizontal: 12, + paddingVertical: 11, + flexDirection: 'row', + alignItems: 'center', + }, + actionBtn: { + minHeight: 34, + paddingHorizontal: 10, + justifyContent: 'center', + alignItems: 'center', + borderRadius: 10, + }, +}); + +export default NativeDateField; \ No newline at end of file diff --git a/components/NewsScreen.js b/components/NewsScreen.js index 473dc12..c4e7300 100644 --- a/components/NewsScreen.js +++ b/components/NewsScreen.js @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef } from 'react'; -import { View, Text, ScrollView, TouchableOpacity, ActivityIndicator, StyleSheet, RefreshControl, Animated, StatusBar } from 'react-native'; +import { View, Text, ScrollView, TouchableOpacity, ActivityIndicator, StyleSheet, RefreshControl, Animated, StatusBar, TextInput } from 'react-native'; import { Ionicons as Icon } from '@expo/vector-icons'; import { ACCENT_COLORS, LIQUID_GLASS } from '../utils/constants'; import ConnectionError from './ConnectionError'; @@ -22,6 +22,7 @@ const NewsScreen = ({ theme, accentColor, isNewYearMode, onCacheStatusChange }) const [cacheInfo, setCacheInfo] = useState(null); const [hasMoreNews, setHasMoreNews] = useState(true); const [lastNewsCheck, setLastNewsCheck] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); // Анимация появления const fadeAnim = useRef(new Animated.Value(0)).current; @@ -96,6 +97,15 @@ const NewsScreen = ({ theme, accentColor, isNewYearMode, onCacheStatusChange }) .sort((a, b) => new Date(b.normalizedDate) - new Date(a.normalizedDate)); }; + const extractNewsList = (payload) => { + if (payload == null) return []; + if (Array.isArray(payload)) return payload; + if (Array.isArray(payload?.news)) return payload.news; + if (Array.isArray(payload?.items)) return payload.items; + if (Array.isArray(payload?.data)) return payload.data; + return []; + }; + // Создание уникального ID для новости const createNewsId = (newsItem) => { const contentHash = newsItem.content.substring(0, 100).replace(/\s+/g, '_'); @@ -162,13 +172,10 @@ const NewsScreen = ({ theme, accentColor, isNewYearMode, onCacheStatusChange }) try { const result = await ApiService.getNews(currentFrom, 10); - - if (!result.data || !Array.isArray(result.data)) { - throw new Error('INVALID_RESPONSE'); - } + const newsList = extractNewsList(result.data); // Фильтрация пустого контента и дубликатов - const filteredData = filterAndProcessNews(result.data); + const filteredData = filterAndProcessNews(newsList); if (filteredData.length === 0 && currentFrom === 0) { setHasMoreNews(false); @@ -297,16 +304,90 @@ return ( contentContainerStyle={{ paddingTop: 16, paddingHorizontal: 16, paddingBottom: 100 }} refreshControl={ } > {/* ВСЕ содержимое новостей */} - - {news.map((item) => ( + + {/* Поиск по новостям */} + + + + {searchQuery.length > 0 && ( + setSearchQuery('')} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}> + + + )} + + + {(() => { + const filteredNews = searchQuery.trim() + ? news.filter(item => + (item.content || '').toLowerCase().includes(searchQuery.toLowerCase()) + ) + : news; + + if (searchQuery.trim() && filteredNews.length === 0) { + return ( + + + + Ничего не найдено + + + ); + } + + if (!loading && filteredNews.length === 0 && !searchQuery.trim()) { + return ( + + + + Пока что новостей нет + + + Попробуйте заглянуть позже :) + + + ); + } + + return filteredNews.map((item) => ( - ))} + )); + })()} {loadingMore && ( diff --git a/components/NotificationSettingsModal.js b/components/NotificationSettingsModal.js index 4f1b8ba..875545d 100644 --- a/components/NotificationSettingsModal.js +++ b/components/NotificationSettingsModal.js @@ -9,6 +9,7 @@ const NotificationSettingsModal = ({ theme, accentColor }) => { enabled: false, news: false, schedule: false, + scheduleChanges: true, beforeLesson: true, lessonStart: true, beforeLessonEnd: true, @@ -31,7 +32,8 @@ const NotificationSettingsModal = ({ theme, accentColor }) => { try { const saved = await SecureStore.getItemAsync('notification_settings'); if (saved) { - setSettings(JSON.parse(saved)); + const parsed = JSON.parse(saved); + setSettings(prev => ({ ...prev, ...parsed })); } } catch (error) { console.error('Error loading settings:', error); @@ -54,8 +56,14 @@ const NotificationSettingsModal = ({ theme, accentColor }) => { if (key === 'enabled' && !newSettings.enabled) { newSettings.news = false; newSettings.schedule = false; + newSettings.scheduleChanges = false; } else if ((key === 'news' || key === 'schedule') && newSettings[key] && !newSettings.enabled) { newSettings.enabled = true; + } else if (key === 'schedule' && !newSettings.schedule) { + newSettings.scheduleChanges = false; + } else if (key === 'scheduleChanges' && newSettings.scheduleChanges) { + newSettings.enabled = true; + newSettings.schedule = true; } saveSettings(newSettings); @@ -127,6 +135,25 @@ const NotificationSettingsModal = ({ theme, accentColor }) => { disabled={!settings.enabled} /> + + + + + + Изменения расписания + + Уведомления при изменении пар + + + + toggleSetting('scheduleChanges')} + trackColor={{ false: borderColor, true: colors.light }} + thumbColor={settings.scheduleChanges && settings.schedule && settings.enabled ? colors.primary : placeholderColor} + disabled={!settings.enabled || !settings.schedule} + /> + {settings.schedule && settings.enabled && ( diff --git a/components/ScheduleChangesHistoryScreen.js b/components/ScheduleChangesHistoryScreen.js new file mode 100644 index 0000000..f458d37 --- /dev/null +++ b/components/ScheduleChangesHistoryScreen.js @@ -0,0 +1,149 @@ +import React, { useEffect, useState } from 'react'; +import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { Ionicons as Icon } from '@expo/vector-icons'; +import { ACCENT_COLORS, LIQUID_GLASS } from '../utils/constants'; +import notificationService from '../utils/notificationService'; + +const WEEKDAY_SHORT = ['', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']; + +const formatStamp = (timestamp) => { + try { + return new Date(timestamp).toLocaleString('ru-RU', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + } catch { + return ''; + } +}; + +const buildChangeRows = (entry) => { + if (!entry) return []; + + if (entry.type === 'changed') { + const pairs = [ + ['Предмет', entry.prev?.subject, entry.lesson?.subject], + ['Преподаватель', entry.prev?.teacher, entry.lesson?.teacher], + ['Аудитория', entry.prev?.auditory, entry.lesson?.auditory], + ['Время пары', entry.prev?.time, entry.lesson?.time], + ]; + return pairs.filter(([, prevValue, nextValue]) => (prevValue || '') !== (nextValue || '')); + } + + return [ + ['Предмет', null, entry.lesson?.subject || 'Не указано'], + ['Преподаватель', null, entry.lesson?.teacher || 'Не указан'], + ['Аудитория', null, entry.lesson?.auditory || 'Не указана'], + ['Время пары', null, entry.lesson?.time || 'Не указано'], + ]; +}; + +const ScheduleChangesHistoryScreen = ({ theme, accentColor }) => { + const [history, setHistory] = useState([]); + const [refreshing, setRefreshing] = useState(false); + + const glass = LIQUID_GLASS[theme] || LIQUID_GLASS.light; + const colors = ACCENT_COLORS[accentColor] || ACCENT_COLORS.green; + + const loadHistory = async () => { + const items = await notificationService.getScheduleChangesHistory(7); + setHistory(items); + }; + + useEffect(() => { + loadHistory(); + }, []); + + const handleRefresh = async () => { + setRefreshing(true); + await loadHistory(); + setRefreshing(false); + }; + + return ( + } + > + + Лента изменений расписания + + Изменения за 7 дней с детализацией по полям пары. + + + + {history.length === 0 ? ( + + Изменений пока нет. + + ) : null} + + {history.map((entry) => { + const rows = buildChangeRows(entry); + const changeTitle = entry.type === 'changed' + ? 'Изменена пара' + : entry.type === 'added' + ? 'Добавлена пара' + : 'Удалена пара'; + + return ( + + + + {changeTitle} + + {entry.scheduleKey} · {WEEKDAY_SHORT[entry.weekday] || 'День не указан'} + + + + {formatStamp(entry.timestamp)} + + + + {rows.map(([label, prevValue, nextValue]) => ( + + + {label} + {prevValue != null ? ( + + Было: {prevValue || 'Не указано'} + + ) : null} + + Стало: {nextValue || 'Не указано'} + + + + + ))} + + ); + })} + + ); +}; + +const styles = StyleSheet.create({ + card: { + borderRadius: 14, + borderWidth: StyleSheet.hairlineWidth, + padding: 12, + }, + title: { + fontSize: 20, + fontFamily: 'Montserrat_700Bold', + }, + diffRow: { + marginTop: 10, + borderRadius: 10, + borderWidth: StyleSheet.hairlineWidth, + padding: 10, + flexDirection: 'row', + gap: 8, + }, +}); + +export default ScheduleChangesHistoryScreen; \ No newline at end of file diff --git a/components/ScheduleFormatModal.js b/components/ScheduleFormatModal.js index 13bc787..a602fbb 100644 --- a/components/ScheduleFormatModal.js +++ b/components/ScheduleFormatModal.js @@ -15,6 +15,8 @@ const ScheduleFormatModal = ({ theme, accentColor, onSettingsChange, onSave }) = const [selectedCourse, setSelectedCourse] = useState(1); const [loadingGroups, setLoadingGroups] = useState(false); const [showCourseSelector, setShowCourseSelector] = useState(true); + const [attendanceTrackingEnabled, setAttendanceTrackingEnabled] = useState(true); + const [freeAuditoriesEnabled, setFreeAuditoriesEnabled] = useState(true); const [availableCourses, setAvailableCourses] = useState([]); const [loadingCourses, setLoadingCourses] = useState(false); @@ -76,6 +78,8 @@ const ScheduleFormatModal = ({ theme, accentColor, onSettingsChange, onSave }) = const savedTeacher = await SecureStore.getItemAsync('teacher_name'); const savedAuditory = await SecureStore.getItemAsync('auditory_name'); const savedShowSelector = await SecureStore.getItemAsync('show_course_selector'); + const savedAttendanceTracking = await SecureStore.getItemAsync('attendance_tracking_enabled'); + const savedFreeAuditories = await SecureStore.getItemAsync('free_auditories_enabled'); if (savedFormat) setScheduleFormat(savedFormat); if (savedGroup) setDefaultGroup(savedGroup); @@ -83,6 +87,8 @@ const ScheduleFormatModal = ({ theme, accentColor, onSettingsChange, onSave }) = if (savedTeacher) setTeacherName(savedTeacher); if (savedAuditory) setAuditoryName(savedAuditory); if (savedShowSelector !== null) setShowCourseSelector(savedShowSelector === 'true'); + if (savedAttendanceTracking !== null) setAttendanceTrackingEnabled(savedAttendanceTracking === 'true'); + if (savedFreeAuditories !== null) setFreeAuditoriesEnabled(savedFreeAuditories === 'true'); if (savedCourse) setSelectedCourse(parseInt(savedCourse)); } catch (error) { console.error('Error loading schedule format settings:', error); @@ -134,6 +140,8 @@ const ScheduleFormatModal = ({ theme, accentColor, onSettingsChange, onSave }) = await SecureStore.setItemAsync('teacher_name', teacherName.trim()); await SecureStore.setItemAsync('auditory_name', auditoryName.trim()); await SecureStore.setItemAsync('show_course_selector', showCourseSelector.toString()); + await SecureStore.setItemAsync('attendance_tracking_enabled', attendanceTrackingEnabled.toString()); + await SecureStore.setItemAsync('free_auditories_enabled', freeAuditoriesEnabled.toString()); if (onSettingsChange) { onSettingsChange({ @@ -142,7 +150,9 @@ const ScheduleFormatModal = ({ theme, accentColor, onSettingsChange, onSave }) = course: selectedCourse, teacher: teacherName.trim(), auditory: auditoryName.trim(), - showSelector: showCourseSelector + showSelector: showCourseSelector, + attendanceTrackingEnabled, + freeAuditoriesEnabled, }); } @@ -459,6 +469,70 @@ const ScheduleFormatModal = ({ theme, accentColor, onSettingsChange, onSave }) = )} + + + Посещаемость + setAttendanceTrackingEnabled(!attendanceTrackingEnabled)} + > + + + + + Отмечать свою посещаемость + + + {attendanceTrackingEnabled + ? 'В карточках пар и в панели будет доступна отметка посещаемости' + : 'Функции отметки посещаемости и статистики будут скрыты'} + + + + + + + + + Свободные аудитории + setFreeAuditoriesEnabled(!freeAuditoriesEnabled)} + > + + + + + Показывать поиск свободных аудиторий + + + {freeAuditoriesEnabled + ? 'Кнопка поиска свободных аудиторий отображается в расписании' + : 'Кнопка поиска свободных аудиторий скрыта'} + + + + + + )} diff --git a/components/ScheduleScreen.js b/components/ScheduleScreen.js index e2e1591..7912489 100644 --- a/components/ScheduleScreen.js +++ b/components/ScheduleScreen.js @@ -11,6 +11,7 @@ import { Animated, StatusBar, Alert, + Modal, LayoutAnimation, Platform, UIManager, @@ -30,11 +31,28 @@ import * as SecureStore from 'expo-secure-store'; import AsyncStorage from '@react-native-async-storage/async-storage'; import Snowfall from './Snowfall'; import { exportScheduleToCalendar } from '../utils/calendarExport'; +import { unlockAchievement } from '../utils/achievements'; +import { showAchievementToast } from './AchievementToast'; import LessonNoteModal from './LessonNoteModal'; import { loadAllNotes, getLessonNoteKey, findHomeworkBySubject, markHomeworkDelivered } from '../utils/notesStorage'; import ViewShot from 'react-native-view-shot'; import * as Sharing from 'expo-sharing'; import ScheduleShareImage from './ScheduleShareImage'; +import notificationService from '../utils/notificationService'; +import AttendanceStatsModal from './AttendanceStatsModal'; +import FreeAuditoriesScreen from './FreeAuditoriesScreen'; +import { + loadAllAttendance, + saveAttendance, + removeAttendance, + buildAttendanceKeyV2, +} from '../utils/attendanceStorage'; +import { + getFavorites, + addFavorite, + removeFavorite, + buildFavoriteId, +} from '../utils/favoritesStorage'; const { width, height } = Dimensions.get('window'); @@ -100,7 +118,7 @@ const LessonCountdown = ({ timeEnd, accentColor }) => { ); }; -const ScheduleScreen = ({ theme, accentColor, scheduleSettings: externalSettings, onSettingsUpdate, isNewYearMode, onCacheStatusChange, onExportReady }) => { +const ScheduleScreen = ({ theme, accentColor, scheduleSettings: externalSettings, onSettingsUpdate, isNewYearMode, onCacheStatusChange, onExportReady, onFavoritesReady }) => { const [isTeacherMode, setIsTeacherMode] = useState(false); const [isAuditoryMode, setIsAuditoryMode] = useState(false); const [teacherSchedule, setTeacherSchedule] = useState(null); @@ -116,6 +134,20 @@ const ScheduleScreen = ({ theme, accentColor, scheduleSettings: externalSettings const [settingsLoaded, setSettingsLoaded] = useState(false); const [exporting, setExporting] = useState(false); + // Посещаемость + const [attendanceMap, setAttendanceMap] = useState({}); + const [attendanceStatsVisible, setAttendanceStatsVisible] = useState(false); + const [attendanceTrackingEnabled, setAttendanceTrackingEnabled] = useState(true); + + // Избранные расписания + const [favorites, setFavorites] = useState([]); + const [isFav, setIsFav] = useState(false); + const [favoritesModalVisible, setFavoritesModalVisible] = useState(false); + + // Поиск свободных аудиторий + const [freeAuditoriesVisible, setFreeAuditoriesVisible] = useState(false); + const [freeAuditoriesEnabled, setFreeAuditoriesEnabled] = useState(true); + // Заметки к парам const [lessonNotes, setLessonNotes] = useState({}); const [noteModalVisible, setNoteModalVisible] = useState(false); @@ -197,6 +229,13 @@ const ScheduleScreen = ({ theme, accentColor, scheduleSettings: externalSettings } }, [handleExportAction, isTeacherMode, isAuditoryMode, teacherSchedule, auditorySchedule, scheduleData, exporting]); + // Передаём обработчик открытия модалки избранного в App.js + useEffect(() => { + if (onFavoritesReady) { + onFavoritesReady(() => setFavoritesModalVisible(true)); + } + }, [onFavoritesReady]); + // Синхронизация статуса кэша с хедером приложения useEffect(() => { if (onCacheStatusChange) { @@ -445,12 +484,59 @@ const ScheduleScreen = ({ theme, accentColor, scheduleSettings: externalSettings refreshNotes(); }, [scheduleData, teacherSchedule, auditorySchedule]); + // Загрузка отметок посещаемости + useEffect(() => { + loadAllAttendance().then(setAttendanceMap).catch(() => {}); + }, []); + + // Загрузка избранных расписаний + useEffect(() => { + getFavorites().then(setFavorites).catch(() => {}); + }, []); + + // Обновление isFav при смене активного расписания + useEffect(() => { + let type, data; + if (isTeacherMode && teacherName) { + type = 'teacher'; data = { teacherName }; + } else if (isAuditoryMode && auditoryName) { + type = 'auditory'; data = { auditoryName }; + } else if (selectedGroup) { + type = 'student'; data = { group: selectedGroup }; + } else { + setIsFav(false); + return; + } + const id = buildFavoriteId(type, data); + getFavorites().then(list => setIsFav(list.some(f => f.id === id))).catch(() => {}); + }, [isTeacherMode, isAuditoryMode, teacherName, auditoryName, selectedGroup]); + const openNoteModal = (lesson, weekday) => { setNoteModalLesson(lesson); setNoteModalWeekday(weekday); setNoteModalVisible(true); }; + const formatLocalDateISO = useCallback((date) => { + if (!date) return ''; + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + }, []); + + const getAttendanceMeta = useCallback((lesson, weekday, lessonDate) => { + const resolvedDate = lessonDate || (viewMode === 'day' + ? currentDate + : getLessonDateForWeek(currentWeek, weekday, currentTime)); + + return { + group: selectedGroup || '', + dateISO: formatLocalDateISO(resolvedDate), + lessonType: lesson?.type_lesson || '', + }; + }, [selectedGroup, viewMode, currentDate, currentWeek, currentTime, formatLocalDateISO]); + const handleNoteModalClose = (saved) => { setNoteModalVisible(false); setNoteModalLesson(null); @@ -458,6 +544,110 @@ const ScheduleScreen = ({ theme, accentColor, scheduleSettings: externalSettings if (saved) refreshNotes(); }; + // Отметить посещаемость пары (toggle: повторный тап снимает отметку) + const handleAttendanceMark = useCallback(async (lesson, weekday, status, lessonDate) => { + const { group, dateISO, lessonType } = getAttendanceMeta(lesson, weekday, lessonDate); + const key = buildAttendanceKeyV2(lesson.subject, weekday, lesson.time, group, dateISO, lessonType); + const current = attendanceMap[key]; + try { + if (current?.status === status) { + await removeAttendance({ subject: lesson.subject, weekday, timeSlot: lesson.time, group, dateISO, lessonType }); + setAttendanceMap(prev => { + const next = { ...prev }; + delete next[key]; + return next; + }); + } else { + const entry = await saveAttendance({ subject: lesson.subject, weekday, timeSlot: lesson.time, group, status, dateISO, lessonType }); + setAttendanceMap(prev => ({ ...prev, [key]: entry })); + } + } catch (e) { + console.error('Error saving attendance:', e); + } + }, [attendanceMap, getAttendanceMeta]); + + const checkIsActiveFavorite = useCallback((fav) => { + if (fav.type === 'teacher') return isTeacherMode && teacherName === fav.data.teacherName; + if (fav.type === 'auditory') return isAuditoryMode && auditoryName === fav.data.auditoryName; + if (fav.type === 'student') return !isTeacherMode && !isAuditoryMode && selectedGroup === fav.data.group; + return false; + }, [isTeacherMode, isAuditoryMode, teacherName, auditoryName, selectedGroup]); + + const confirmRemoveFavorite = useCallback((id, label = 'элемент') => { + Alert.alert( + 'Удалить из избранного?', + `"${label}" будет удален из избранного.`, + [ + { text: 'Отмена', style: 'cancel' }, + { + text: 'Удалить', + style: 'destructive', + onPress: async () => { + await removeFavorite(id); + setFavorites(prev => prev.filter(f => f.id !== id)); + const cur = favorites.find(f => f.id === id); + if (cur && checkIsActiveFavorite(cur)) setIsFav(false); + }, + }, + ] + ); + }, [favorites, checkIsActiveFavorite]); + + // Добавить / удалить текущее расписание из избранного + const handleToggleFavorite = useCallback(async () => { + let type, label, data; + if (isTeacherMode && teacherName) { + type = 'teacher'; label = teacherName; data = { teacherName }; + } else if (isAuditoryMode && auditoryName) { + type = 'auditory'; label = auditoryName; data = { auditoryName }; + } else if (selectedGroup) { + type = 'student'; label = selectedGroup; data = { group: selectedGroup, course }; + } else { + return; + } + const id = buildFavoriteId(type, data); + try { + if (isFav) { + confirmRemoveFavorite(id, label); + } else { + const favorite = { id, type, label, data }; + await addFavorite(favorite); + setFavorites(prev => [...prev, favorite]); + setIsFav(true); + } + } catch (e) { + console.error('Error toggling favorite:', e); + } + }, [isFav, isTeacherMode, isAuditoryMode, teacherName, auditoryName, selectedGroup, course, confirmRemoveFavorite]); + + // Применить избранное + const applyFavorite = useCallback((fav) => { + if (fav.type === 'teacher') { + setIsTeacherMode(true); + setIsAuditoryMode(false); + setTeacherName(fav.data.teacherName); + fetchTeacherSchedule(fav.data.teacherName); + if (onSettingsUpdate) onSettingsUpdate({ format: 'teacher', teacher: fav.data.teacherName }); + } else if (fav.type === 'auditory') { + setIsTeacherMode(false); + setIsAuditoryMode(true); + setAuditoryName(fav.data.auditoryName); + fetchAuditorySchedule(fav.data.auditoryName); + if (onSettingsUpdate) onSettingsUpdate({ format: 'auditory', auditory: fav.data.auditoryName }); + } else if (fav.type === 'student') { + setIsTeacherMode(false); + setIsAuditoryMode(false); + if (fav.data.course) setCourse(fav.data.course); + if (fav.data.group) setSelectedGroup(fav.data.group); + if (onSettingsUpdate) onSettingsUpdate({ format: 'student', group: fav.data.group, course: fav.data.course }); + } + }, [fetchTeacherSchedule, fetchAuditorySchedule, setCourse, setSelectedGroup, onSettingsUpdate]); + + // Удалить из избранного (с подтверждением) + const handleRemoveFavoriteFromBar = useCallback((id, label) => { + confirmRemoveFavorite(id, label); + }, [confirmRemoveFavorite]); + // Проверяет, есть ли заметка к занятию const hasNoteForLesson = (lesson, weekday) => { const group = lesson.group ? (Array.isArray(lesson.group) ? lesson.group.join(',') : lesson.group) : ''; @@ -776,26 +966,79 @@ const ScheduleScreen = ({ theme, accentColor, scheduleSettings: externalSettings accentColor={accentColor} /> )} - openNoteModal(lesson, weekday)} - hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} - style={{ - width: 30, - height: 30, - borderRadius: 15, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: hasNoteForLesson(lesson, weekday) - ? (colors.glass || colors.primary + '10') - : glass.surfaceTertiary, - }} - > - - + + {attendanceTrackingEnabled && !isTeacher && !isAuditory && ( + { + const { group, dateISO, lessonType } = getAttendanceMeta(lesson, weekday, lessonDate); + const attKey = buildAttendanceKeyV2(lesson.subject, weekday, lesson.time, group, dateISO, lessonType); + const currentStatus = attendanceMap[attKey]?.status; + if (currentStatus === 'attended') { + handleAttendanceMark(lesson, weekday, 'missed', lessonDate); + } else if (currentStatus === 'missed') { + handleAttendanceMark(lesson, weekday, 'excused', lessonDate); + } else if (currentStatus === 'excused') { + handleAttendanceMark(lesson, weekday, 'excused', lessonDate); + } else { + handleAttendanceMark(lesson, weekday, 'attended', lessonDate); + } + }} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + style={{ + width: 30, + height: 30, + borderRadius: 15, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: (() => { + const { group, dateISO, lessonType } = getAttendanceMeta(lesson, weekday, lessonDate); + const attKey = buildAttendanceKeyV2(lesson.subject, weekday, lesson.time, group, dateISO, lessonType); + const status = attendanceMap[attKey]?.status; + if (status === 'attended') return 'rgba(16, 185, 129, 0.14)'; + if (status === 'missed') return 'rgba(239, 68, 68, 0.14)'; + if (status === 'excused') return 'rgba(245, 158, 11, 0.14)'; + return glass.surfaceTertiary; + })(), + }} + > + {(() => { + const { group, dateISO, lessonType } = getAttendanceMeta(lesson, weekday, lessonDate); + const attKey = buildAttendanceKeyV2(lesson.subject, weekday, lesson.time, group, dateISO, lessonType); + const status = attendanceMap[attKey]?.status; + if (status === 'attended') { + return ; + } + if (status === 'missed') { + return ; + } + if (status === 'excused') { + return ; + } + return ; + })()} + + )} + openNoteModal(lesson, weekday)} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + style={{ + width: 30, + height: 30, + borderRadius: 15, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: hasNoteForLesson(lesson, weekday) + ? (colors.glass || colors.primary + '10') + : glass.surfaceTertiary, + }} + > + + + )} @@ -845,21 +1088,29 @@ const ScheduleScreen = ({ theme, accentColor, scheduleSettings: externalSettings const showSelector = await SecureStore.getItemAsync('show_course_selector'); const teacher = await SecureStore.getItemAsync('teacher_name') || ''; const auditory = await SecureStore.getItemAsync('auditory_name') || ''; + const attendanceTracking = await SecureStore.getItemAsync('attendance_tracking_enabled'); + const freeAuditories = await SecureStore.getItemAsync('free_auditories_enabled'); const shouldShowSelector = showSelector !== 'false'; + const shouldTrackAttendance = attendanceTracking !== 'false'; + const shouldShowFreeAuditories = freeAuditories !== 'false'; setIsTeacherMode(format === 'teacher'); setIsAuditoryMode(format === 'auditory'); setShowCourseSelector(shouldShowSelector); setTeacherName(teacher); setAuditoryName(auditory); + setAttendanceTrackingEnabled(shouldTrackAttendance); + setFreeAuditoriesEnabled(shouldShowFreeAuditories); console.log('Настройки расписания загружены:', { format, showSelector, shouldShowSelector, teacher, - auditory + auditory, + shouldTrackAttendance, + shouldShowFreeAuditories, }); if (format === 'teacher' && teacher) { @@ -905,6 +1156,12 @@ const ScheduleScreen = ({ theme, accentColor, scheduleSettings: externalSettings setShowCourseSelector(settings.showSelector !== false); setTeacherName(settings.teacher || ''); setAuditoryName(settings.auditory || ''); + if (typeof settings.attendanceTrackingEnabled === 'boolean') { + setAttendanceTrackingEnabled(settings.attendanceTrackingEnabled); + } + if (typeof settings.freeAuditoriesEnabled === 'boolean') { + setFreeAuditoriesEnabled(settings.freeAuditoriesEnabled); + } if (settings.format === 'teacher' && settings.teacher) { fetchTeacherSchedule(settings.teacher); @@ -982,6 +1239,16 @@ const ScheduleScreen = ({ theme, accentColor, scheduleSettings: externalSettings if (result.data.week_number && !week) { setCurrentWeek(result.data.week_number); } + try { + const weekScope = result.data.week_number || week || currentWeek || 'unknown'; + await notificationService.checkScheduleChanges( + result.data, + `teacher_${teacher}__week_${weekScope}`, + { source: result.source } + ); + } catch (notifyError) { + console.error('Error checking teacher schedule changes:', notifyError); + } console.log('Расписание преподавателя загружено:', result.data); } else { throw new Error('INVALID_RESPONSE'); @@ -1010,6 +1277,16 @@ const ScheduleScreen = ({ theme, accentColor, scheduleSettings: externalSettings if (result.data.week_number && !week) { setCurrentWeek(result.data.week_number); } + try { + const weekScope = result.data.week_number || week || currentWeek || 'unknown'; + await notificationService.checkScheduleChanges( + result.data, + `auditory_${auditory}__week_${weekScope}`, + { source: result.source } + ); + } catch (notifyError) { + console.error('Error checking auditory schedule changes:', notifyError); + } console.log('Расписание аудитории загружено:', result.data); } else { throw new Error('INVALID_RESPONSE'); @@ -1126,6 +1403,9 @@ const ScheduleScreen = ({ theme, accentColor, scheduleSettings: externalSettings currentDate, title, }); + unlockAchievement('export_master').then(result => { + if (result) showAchievementToast(result); + }).catch(() => {}); } catch (err) { if (err.message === 'NO_EVENTS') { Alert.alert('Экспорт', 'Нет занятий для экспорта'); @@ -1874,6 +2154,85 @@ const ScheduleScreen = ({ theme, accentColor, scheduleSettings: externalSettings return null; }; + const hasSelection = (isTeacherMode && teacherName) || (isAuditoryMode && auditoryName) || (!isTeacherMode && !isAuditoryMode && selectedGroup); + + const renderFavoriteToggleButton = ({ compact = false } = {}) => { + if (!hasSelection) return null; + return ( + + + + {compact ? 'Избр.' : (isFav ? 'В избранном' : 'В избранное')} + + + ); + }; + + // Кнопки инструментов: посещаемость + свободные аудитории + const renderToolbar = () => { + const showAttendanceBtn = attendanceTrackingEnabled && !isTeacherMode && !isAuditoryMode && selectedGroup; + const showFreeAuditoriesBtn = freeAuditoriesEnabled; + const showFavoriteInToolbar = hasSelection && (isTeacherMode || isAuditoryMode); + if (!showFavoriteInToolbar && !showAttendanceBtn && !showFreeAuditoriesBtn) return null; + + const compactActionStyle = { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 6, + paddingHorizontal: 10, + borderRadius: 12, + backgroundColor: glass.surfaceSecondary, + borderWidth: StyleSheet.hairlineWidth, + borderColor: glass.border, + gap: 5, + }; + + return ( + + {showFavoriteInToolbar && renderFavoriteToggleButton({ compact: true })} + {showAttendanceBtn && ( + setAttendanceStatsVisible(true)} + style={compactActionStyle} + > + + + Посещаемость + + + )} + {showFreeAuditoriesBtn && ( + setFreeAuditoriesVisible(true)} + style={compactActionStyle} + > + + + Своб. аудитории + + + )} + + ); + }; + const renderCurrentScreen = () => { // Обработка ошибок @@ -1943,7 +2302,10 @@ return ( {/* Управление */} {renderControls()} - + + {/* Инструменты: посещаемость, свободные аудитории */} + {renderToolbar()} + {/* Студенческий режим: кнопки курса и групп */} {settingsLoaded && !isTeacherMode && !isAuditoryMode && (showCourseSelector || selectorExpandedTemp) && ( @@ -2039,23 +2401,26 @@ return ( {/* Список групп */} - - - + + + + + + Группа - Группа + {renderFavoriteToggleButton({ compact: true })} {loadingGroups ? ( @@ -2130,6 +2495,11 @@ return ( }}> Группа {selectedGroup} + {hasSelection && ( + + {renderFavoriteToggleButton({ compact: true })} + + )} { LayoutAnimation.configureNext(LayoutAnimation.create( @@ -2175,6 +2545,149 @@ return ( return ( {renderCurrentScreen()} + setFavoritesModalVisible(false)} + > + + + + + + + + + + Избранные расписания + + + setFavoritesModalVisible(false)}> + + + + + + {favorites.length === 0 ? ( + + + + Избранных расписаний пока нет + + + Добавьте группы, преподавателей или аудитории в избранное + + + ) : ( + (() => { + const sections = [ + { + key: 'student', + title: 'Студенческие группы', + icon: 'people-outline', + data: favorites.filter(f => f.type === 'student'), + }, + { + key: 'teacher', + title: 'Преподаватели', + icon: 'person-outline', + data: favorites.filter(f => f.type === 'teacher'), + }, + { + key: 'auditory', + title: 'Аудитории', + icon: 'business-outline', + data: favorites.filter(f => f.type === 'auditory'), + }, + ].filter(section => section.data.length > 0); + + return sections.map(section => ( + + + + + {section.title.toUpperCase()} + + + + + {section.data.map(fav => { + const active = checkIsActiveFavorite(fav); + return ( + + { + applyFavorite(fav); + setFavoritesModalVisible(false); + }} + style={{ flexDirection: 'row', alignItems: 'center', flex: 1, marginRight: 8 }} + > + + + {fav.label} + + {active && ( + + Активно + + )} + + + + handleRemoveFavoriteFromBar(fav.id, fav.label)} + hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} + style={{ + width: 28, + height: 28, + borderRadius: 8, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(239, 68, 68, 0.12)', + }} + > + + + + ); + })} + + + )); + })() + )} + + + + setAttendanceStatsVisible(false)} + attendanceMap={attendanceMap} + theme={theme} + accentColor={accentColor} + /> + setFreeAuditoriesVisible(false)} + theme={theme} + accentColor={accentColor} + currentWeek={currentWeek} + pairsTime={pairsTime} + /> { }; const SettingsScreen = forwardRef(({ - theme, accentColor, setTheme, setAccentColor, + theme, accentColor, legendUnlocked, setTheme, setAccentColor, onScheduleSettingsChange, onTabbarSettingsChange, isNewYearMode, onNewYearModeChange, onNavigationChange }, ref) => { @@ -251,6 +254,9 @@ const SettingsScreen = forwardRef(({ useEffect(() => { const titles = { schedule: 'Формат расписания', + academicCalendar: 'Календарь событий', + studyProfile: 'Учебный профиль', + scheduleHistory: 'Лента изменений', appearance: 'Внешний вид', notifications: 'Уведомления', achievements: 'Достижения', @@ -524,7 +530,32 @@ const SettingsScreen = forwardRef(({ title="Формат расписания" subtitle={getScheduleLabel()} onPress={() => navigateTo('schedule')} - isFirst isLast + isFirst + /> + navigateTo('academicCalendar')} + isLast + /> + + + + + navigateTo('studyProfile')} + isFirst + /> + navigateTo('scheduleHistory')} + isLast /> @@ -722,6 +753,7 @@ const SettingsScreen = forwardRef(({ )} + {currentScreen === 'academicCalendar' && ( + + )} + + {currentScreen === 'studyProfile' && ( + + )} + + {currentScreen === 'scheduleHistory' && ( + + )} + {currentScreen === 'notifications' && ( { + let pos; + do { + pos = { + x: Math.floor(Math.random() * GRID_SIZE), + y: Math.floor(Math.random() * GRID_SIZE), + }; + } while (snake.some(s => s.x === pos.x && s.y === pos.y)); + return pos; +}; + +const INIT_SNAKE = [{ x: 8, y: 8 }, { x: 7, y: 8 }, { x: 6, y: 8 }]; +const INIT_DIR = { x: 1, y: 0 }; + +const SnakeGame = ({ visible, onClose, theme, accentColor }) => { + const glass = LIQUID_GLASS[theme] || LIQUID_GLASS.light; + const colors = ACCENT_COLORS[accentColor] || ACCENT_COLORS.green; + + // All game state in refs to avoid stale closures in the interval callback + const snakeRef = useRef([...INIT_SNAKE]); + const dirRef = useRef({ ...INIT_DIR }); + const foodRef = useRef(randomPos(INIT_SNAKE)); + const scoreRef = useRef(0); + const gameOverRef = useRef(false); + const startedRef = useRef(false); + const championRef = useRef(false); + const tickRef = useRef(null); + + // Minimal state only for triggering re-renders + const [tick, setTick] = useState(0); + const rerender = useCallback(() => setTick(t => t + 1), []); + + const stopTick = useCallback(() => { + if (tickRef.current) { + clearInterval(tickRef.current); + tickRef.current = null; + } + }, []); + + const resetGame = useCallback(() => { + stopTick(); + snakeRef.current = [{ x: 8, y: 8 }, { x: 7, y: 8 }, { x: 6, y: 8 }]; + dirRef.current = { x: 1, y: 0 }; + foodRef.current = randomPos(snakeRef.current); + scoreRef.current = 0; + gameOverRef.current = false; + startedRef.current = false; + championRef.current = false; + rerender(); + }, [stopTick, rerender]); + + const startGame = useCallback(() => { + stopTick(); + startedRef.current = true; + rerender(); + + tickRef.current = setInterval(() => { + if (gameOverRef.current) { + stopTick(); + return; + } + + const snake = snakeRef.current; + const dir = dirRef.current; + const food = foodRef.current; + const head = snake[0]; + const newHead = { x: head.x + dir.x, y: head.y + dir.y }; + + // Wall collision + if ( + newHead.x < 0 || + newHead.x >= GRID_SIZE || + newHead.y < 0 || + newHead.y >= GRID_SIZE + ) { + gameOverRef.current = true; + stopTick(); + rerender(); + return; + } + + // Self collision + if (snake.some(s => s.x === newHead.x && s.y === newHead.y)) { + gameOverRef.current = true; + stopTick(); + rerender(); + return; + } + + const ate = newHead.x === food.x && newHead.y === food.y; + snakeRef.current = ate + ? [newHead, ...snake] + : [newHead, ...snake.slice(0, -1)]; + + if (ate) { + foodRef.current = randomPos(snakeRef.current); + scoreRef.current += 10; + if (scoreRef.current >= CHAMPION_SCORE && !championRef.current) { + championRef.current = true; + unlockAchievement('snake_champion').then(result => { + if (result) showAchievementToast(result); + }).catch(() => {}); + } + } + + rerender(); + }, TICK_MS); + }, [stopTick, rerender]); + + // Clean up on close / unmount + useEffect(() => { + if (!visible) { + resetGame(); + } + return stopTick; + }, [visible, resetGame, stopTick]); + + const handleDir = (dx, dy) => { + const cur = dirRef.current; + // Prevent reversing direction + if (cur.x === -dx && cur.y === -dy) return; + dirRef.current = { x: dx, y: dy }; + }; + + const handleAction = () => { + if (gameOverRef.current) { + resetGame(); + setTimeout(startGame, 30); + } else { + startGame(); + } + }; + + const snake = snakeRef.current; + const food = foodRef.current; + const score = scoreRef.current; + const gameOver = gameOverRef.current; + const started = startedRef.current; + const gridPx = GRID_SIZE * CELL_SIZE; + + const bgColor = glass.backgroundElevated || (theme === 'dark' ? '#1c1c1e' : '#ffffff'); + const textColor = glass.text; + const textSecondary = glass.textSecondary; + const borderColor = glass.border; + + return ( + + + + {/* Header */} + + 🐍 Snake + + {score} + + + + + + + {/* Grid */} + + {Array.from({ length: GRID_SIZE }).map((_, y) => ( + + {Array.from({ length: GRID_SIZE }).map((_, x) => { + const isHead = snake[0]?.x === x && snake[0]?.y === y; + const isBody = !isHead && snake.slice(1).some(s => s.x === x && s.y === y); + const isFood = food.x === x && food.y === y; + return ( + + ); + })} + + ))} + + {/* Start / Game over overlay */} + {(!started || gameOver) && ( + + {gameOver && ( + <> + Игра окончена + Счёт: {score} + + )} + + + {gameOver ? 'Ещё раз' : 'Начать'} + + + + )} + + + {/* D-pad */} + + handleDir(0, -1)}> + + + + handleDir(-1, 0)}> + + + + handleDir(1, 0)}> + + + + handleDir(0, 1)}> + + + + + + Набери {CHAMPION_SCORE}+ очков для особой награды + + + + + ); +}; + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0,0,0,0.65)', + }, + card: { + borderRadius: 24, + borderWidth: StyleSheet.hairlineWidth, + padding: 20, + alignItems: 'center', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + width: '100%', + justifyContent: 'space-between', + marginBottom: 14, + }, + title: { + fontFamily: 'Montserrat_700Bold', + fontSize: 18, + }, + scoreBadge: { + paddingHorizontal: 14, + paddingVertical: 4, + borderRadius: 10, + }, + scoreText: { + fontFamily: 'Montserrat_700Bold', + fontSize: 16, + }, + grid: { + borderWidth: StyleSheet.hairlineWidth, + borderRadius: 10, + overflow: 'hidden', + }, + row: { + flexDirection: 'row', + }, + cell: { + width: CELL_SIZE, + height: CELL_SIZE, + }, + overlayPanel: { + ...StyleSheet.absoluteFillObject, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0,0,0,0.62)', + borderRadius: 10, + }, + gameOverText: { + color: '#EF4444', + fontFamily: 'Montserrat_700Bold', + fontSize: 20, + marginBottom: 4, + }, + finalScore: { + color: '#ffffff', + fontFamily: 'Montserrat_400Regular', + fontSize: 15, + marginBottom: 20, + }, + startButton: { + paddingHorizontal: 28, + paddingVertical: 12, + borderRadius: 14, + }, + startButtonText: { + color: '#ffffff', + fontFamily: 'Montserrat_700Bold', + fontSize: 16, + }, + dpadContainer: { + alignItems: 'center', + marginTop: 16, + gap: 4, + }, + dpadRow: { + flexDirection: 'row', + gap: 4, + }, + dpadBtn: { + width: 46, + height: 46, + borderRadius: 12, + borderWidth: StyleSheet.hairlineWidth, + backgroundColor: 'rgba(128,128,128,0.1)', + justifyContent: 'center', + alignItems: 'center', + }, + dpadCenter: { + width: 46, + height: 46, + }, + hint: { + marginTop: 12, + fontFamily: 'Montserrat_400Regular', + fontSize: 11, + }, +}); + +export default SnakeGame; diff --git a/components/SplashScreen.js b/components/SplashScreen.js index ff59cc9..8e1f5cc 100644 --- a/components/SplashScreen.js +++ b/components/SplashScreen.js @@ -6,17 +6,27 @@ import MatrixRain from './MatrixRain'; const { width, height } = Dimensions.get('window'); -const SplashScreen = ({ accentColor, theme, holidayInfo }) => { +const SplashScreen = ({ accentColor, theme, holidayInfo, legendUnlocked = false }) => { const safeAccentColor = accentColor || 'green'; const colors = ACCENT_COLORS[safeAccentColor] || ACCENT_COLORS.green; const safeColors = colors || { primary: '#10b981', light: '#d1fae5' }; const glass = LIQUID_GLASS[theme] || LIQUID_GLASS.light; const isMatrix = theme === 'matrix'; - const backgroundColor = isMatrix ? (glass.backgroundSolid || '#0D0208') : glass.background; - const iconColor = (theme === 'dark' || isMatrix) ? safeColors.light : safeColors.primary; - const textColor = (theme === 'dark' || isMatrix) ? '#ffffff' : '#1c1c1e'; - const subtextColor = (theme === 'dark' || isMatrix) ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.4)'; + const isLegendSplash = legendUnlocked; + const legendPrimary = '#FFD666'; + const backgroundColor = isLegendSplash + ? '#140E05' + : (isMatrix ? (glass.backgroundSolid || '#0D0208') : glass.background); + const iconColor = isLegendSplash + ? legendPrimary + : ((theme === 'dark' || isMatrix) ? safeColors.light : safeColors.primary); + const textColor = isLegendSplash + ? '#F6E7C1' + : ((theme === 'dark' || isMatrix) ? '#ffffff' : '#1c1c1e'); + const subtextColor = isLegendSplash + ? 'rgba(255,214,102,0.72)' + : ((theme === 'dark' || isMatrix) ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.4)'); // Цвета праздников const getHolidayColors = (type) => { @@ -266,7 +276,7 @@ const SplashScreen = ({ accentColor, theme, holidayInfo }) => { width: blob.size, height: blob.size, borderRadius: blob.size / 2, - backgroundColor: safeColors.primary, + backgroundColor: isLegendSplash ? legendPrimary : safeColors.primary, opacity: blob.opacity, transform: [ { translateX: blobAnims[index].translateX }, @@ -378,7 +388,7 @@ const SplashScreen = ({ accentColor, theme, holidayInfo }) => { elevation: 8, }} > - + @@ -421,13 +431,30 @@ const SplashScreen = ({ accentColor, theme, holidayInfo }) => { }, ]} > - Твой университет в кармане + {isLegendSplash ? 'Легенда ИТИ ХГУ' : 'Твой университет в кармане'} + {isLegendSplash && ( + + + Все достижения собраны + + + )} + {/* Индикатор загрузки */} - + diff --git a/components/StudyProfileScreen.js b/components/StudyProfileScreen.js new file mode 100644 index 0000000..d70f6fe --- /dev/null +++ b/components/StudyProfileScreen.js @@ -0,0 +1,226 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Ionicons as Icon } from '@expo/vector-icons'; +import { LIQUID_GLASS, ACCENT_COLORS } from '../utils/constants'; +import { loadAllAttendance, getAttendanceStats } from '../utils/attendanceStorage'; +import { getAllHomeworkDeadlines, HOMEWORK_STATUSES } from '../utils/notesStorage'; +import notificationService from '../utils/notificationService'; +import { loadStudyProfile, saveStudyProfile } from '../utils/studyProfileStorage'; + +const formatDateTime = (timestamp) => { + try { + return new Date(timestamp).toLocaleString('ru-RU', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + } catch { + return ''; + } +}; + +const statusLabel = { + [HOMEWORK_STATUSES.TODO]: 'К выполнению', + [HOMEWORK_STATUSES.IN_PROGRESS]: 'В работе', + [HOMEWORK_STATUSES.DONE]: 'Сделано', +}; + +const StudyProfileScreen = ({ theme, accentColor }) => { + const [attendanceMap, setAttendanceMap] = useState({}); + const [deadlines, setDeadlines] = useState([]); + const [history, setHistory] = useState([]); + const [profile, setProfile] = useState({ targetAttendance: 85, weeklyStudyTargetHours: 12 }); + + const glass = LIQUID_GLASS[theme] || LIQUID_GLASS.light; + const colors = ACCENT_COLORS[accentColor] || ACCENT_COLORS.green; + + const loadData = async () => { + const [attendance, deadlinesData, historyData, profileData] = await Promise.all([ + loadAllAttendance(), + getAllHomeworkDeadlines(), + notificationService.getScheduleChangesHistory(7), + loadStudyProfile(), + ]); + setAttendanceMap(attendance); + setDeadlines(deadlinesData); + setHistory(historyData.slice(0, 20)); + setProfile(profileData); + }; + + useEffect(() => { + loadData(); + }, []); + + const attendanceStats = useMemo(() => getAttendanceStats(attendanceMap), [attendanceMap]); + const attendanceTotal = useMemo(() => { + let attended = 0; + let all = 0; + Object.values(attendanceMap).forEach((entry) => { + if (!entry || !entry.dateISO) return; + if (entry.status === 'attended') attended += 1; + all += 1; + }); + return { + attended, + all, + ratio: all > 0 ? Math.round((attended / all) * 100) : 0, + }; + }, [attendanceMap]); + + const deadlinesStats = useMemo(() => { + const result = { todo: 0, in_progress: 0, done: 0, overdue: 0 }; + deadlines.forEach((item) => { + if (item.isOverdue) result.overdue += 1; + if (item.status === HOMEWORK_STATUSES.DONE) result.done += 1; + else if (item.status === HOMEWORK_STATUSES.IN_PROGRESS) result.in_progress += 1; + else result.todo += 1; + }); + return result; + }, [deadlines]); + + const updateTargetAttendance = async (delta) => { + const next = Math.min(100, Math.max(50, Number(profile.targetAttendance || 85) + delta)); + const updated = { ...profile, targetAttendance: next }; + setProfile(updated); + await saveStudyProfile(updated); + }; + + return ( + + Персональный учебный профиль + + + Посещаемость + + Текущая: {attendanceTotal.ratio}% ({attendanceTotal.attended}/{attendanceTotal.all}) + + + + Цель посещаемости + + updateTargetAttendance(-5)} style={[styles.targetBtn, { borderColor: glass.border }]}> + + + {profile.targetAttendance}% + updateTargetAttendance(5)} style={[styles.targetBtn, { borderColor: glass.border }]}> + + + + + + = profile.targetAttendance ? '#10b981' : '#f59e0b', marginTop: 8, fontFamily: 'Montserrat_500Medium' }}> + {attendanceTotal.ratio >= profile.targetAttendance ? 'Цель выполняется' : 'Нужно усилить посещаемость'} + + + + + Дедлайны и ДЗ + К выполнению: {deadlinesStats.todo} + В работе: {deadlinesStats.in_progress} + Готово: {deadlinesStats.done} + Просрочено: {deadlinesStats.overdue} + + {deadlines.slice(0, 6).map((item) => ( + + + {item.subject} + + {item.homework} + + + + + {item.dueDate || 'Без даты'} + + + {item.isOverdue ? 'Просрочено' : (statusLabel[item.status] || 'К выполнению')} + + + + ))} + + + + История изменений расписания (7 дней) + {history.length === 0 && ( + Изменений пока нет. + )} + {history.map((entry) => ( + + + + {entry.type === 'changed' ? 'Изменена пара' : entry.type === 'added' ? 'Добавлена пара' : 'Удалена пара'} + + + {entry.lesson?.subject || 'Пара'} · {entry.lesson?.time || '-'} · {entry.scheduleKey} + + + + {formatDateTime(entry.timestamp)} + + + ))} + + + + Риск-зоны по предметам + {attendanceStats.slice(0, 5).map((item) => ( + + {item.subject} + + {Math.round((1 - item.absentRatio) * 100)}% + + + ))} + + + ); +}; + +const styles = StyleSheet.create({ + title: { + fontSize: 20, + fontFamily: 'Montserrat_700Bold', + marginBottom: 12, + }, + card: { + borderRadius: 14, + borderWidth: StyleSheet.hairlineWidth, + padding: 12, + }, + sectionTitle: { + fontSize: 14, + fontFamily: 'Montserrat_600SemiBold', + }, + text: { + marginTop: 4, + fontSize: 12, + fontFamily: 'Montserrat_400Regular', + }, + targetRow: { + marginTop: 10, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + targetBtn: { + width: 28, + height: 28, + borderRadius: 14, + borderWidth: StyleSheet.hairlineWidth, + justifyContent: 'center', + alignItems: 'center', + }, + listRow: { + marginTop: 8, + borderRadius: 10, + borderWidth: StyleSheet.hairlineWidth, + padding: 10, + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, +}); + +export default StudyProfileScreen; diff --git a/designs/rustore/9-16/mykhsu-rustore-1.png b/designs/rustore/9-16/mykhsu-rustore-1.png new file mode 100644 index 0000000..5b959c9 Binary files /dev/null and b/designs/rustore/9-16/mykhsu-rustore-1.png differ diff --git a/designs/rustore/9-16/mykhsu-rustore-10.png b/designs/rustore/9-16/mykhsu-rustore-10.png new file mode 100644 index 0000000..39ab69e Binary files /dev/null and b/designs/rustore/9-16/mykhsu-rustore-10.png differ diff --git a/designs/rustore/9-16/mykhsu-rustore-2.png b/designs/rustore/9-16/mykhsu-rustore-2.png new file mode 100644 index 0000000..644920a Binary files /dev/null and b/designs/rustore/9-16/mykhsu-rustore-2.png differ diff --git a/designs/rustore/9-16/mykhsu-rustore-3.png b/designs/rustore/9-16/mykhsu-rustore-3.png new file mode 100644 index 0000000..b1ae669 Binary files /dev/null and b/designs/rustore/9-16/mykhsu-rustore-3.png differ diff --git a/designs/rustore/9-16/mykhsu-rustore-4.png b/designs/rustore/9-16/mykhsu-rustore-4.png new file mode 100644 index 0000000..f346f63 Binary files /dev/null and b/designs/rustore/9-16/mykhsu-rustore-4.png differ diff --git a/designs/rustore/9-16/mykhsu-rustore-5.png b/designs/rustore/9-16/mykhsu-rustore-5.png new file mode 100644 index 0000000..08f8310 Binary files /dev/null and b/designs/rustore/9-16/mykhsu-rustore-5.png differ diff --git a/designs/rustore/9-16/mykhsu-rustore-6.png b/designs/rustore/9-16/mykhsu-rustore-6.png new file mode 100644 index 0000000..adcb364 Binary files /dev/null and b/designs/rustore/9-16/mykhsu-rustore-6.png differ diff --git a/designs/rustore/9-16/mykhsu-rustore-7.png b/designs/rustore/9-16/mykhsu-rustore-7.png new file mode 100644 index 0000000..1830297 Binary files /dev/null and b/designs/rustore/9-16/mykhsu-rustore-7.png differ diff --git a/designs/rustore/9-16/mykhsu-rustore-8.png b/designs/rustore/9-16/mykhsu-rustore-8.png new file mode 100644 index 0000000..977c52f Binary files /dev/null and b/designs/rustore/9-16/mykhsu-rustore-8.png differ diff --git a/designs/rustore/9-16/mykhsu-rustore-9.png b/designs/rustore/9-16/mykhsu-rustore-9.png new file mode 100644 index 0000000..e667165 Binary files /dev/null and b/designs/rustore/9-16/mykhsu-rustore-9.png differ diff --git a/docs/architecture.md b/docs/architecture.md index a13c84f..85806c6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,7 @@ # Архитектура приложения ## Технологический стек + - React Native 0.81 - Expo 54 - React 19 @@ -9,6 +10,7 @@ - Sentry для мониторинга ошибок ## Точка входа и композиция приложения + - Точка входа: `App.js` + `index.js`. - `App.js` отвечает за: - загрузку шрифтов и первичный загрузочный экран; @@ -17,70 +19,88 @@ - инициализацию уведомлений, фоновой логики и интеграции Sentry. Основной порядок вкладок определяется константой `TAB_ORDER` в `App.js`: + - расписание; - карта; -- первокурснику; +- первокурснику / студенту; - новости; - настройки. ## Слои ответственности ### 1. UI-слой: `components/` + Компоненты отвечают за: + - рендер интерфейса; - интеракции пользователя; - локальные анимации и визуальное состояние. Критичные экраны: -- `ScheduleScreen.js`: расписание (студент, преподаватель, аудитория), экспорт, заметки. -- `NewsScreen.js`: лента новостей, пагинация, refresh, fallback на кэш. + +- `ScheduleScreen.js`: расписание, экспорт, заметки, дедлайны ДЗ, избранное, посещаемость, свободные аудитории с выбором недели/дня/пары. +- `NewsScreen.js`: лента новостей, пагинация, refresh, поиск, fallback на кэш. - `MapScreen.js`: карта корпусов, фильтры, маршруты и список объектов. -- `SettingsScreen.js`: параметры темы/внешнего вида, уведомлений и системных функций. +- `SettingsScreen.js`: параметры темы, уведомлений, учебного профиля, истории изменений и системных функций. +- `AcademicCalendarScreen.js`: учебные события, добавление из панели действий фильтров, редактирование и экспорт в календарь и ICS. +- `AcademicEventModal.js`: модальное окно создания и редактирования учебного события в формате `pageSheet`, по реализации и размерам согласованное с окнами `FreeAuditoriesScreen` и `AttendanceStatsModal`. +- `ScheduleChangesHistoryScreen.js`: отдельная история изменений расписания с diff-представлением. ### 2. Логический слой: `hooks/` + - `useScheduleLogic.js` инкапсулирует состояние расписания: - - курс, группы, текущая неделя/дата; - - онлайн/оффлайн статус; + - курс, группы, текущая неделя или дата; + - онлайн или оффлайн статус; - загрузка расписания и времени пар; - переходы по датам и обновление данных. -Принцип: экран остается тонким, бизнес-логика выносится в хук/утилиты. +Принцип: экран остается тонким, бизнес-логика выносится в хук или утилиты. ### 3. Сервисный слой: `utils/` + - `api.js`: сетевые вызовы и адаптация ответов. - `cache.js`: TTL-кэш с валидацией и очисткой поврежденных записей. - `notificationService.js`: разрешения, настройки и логика уведомлений. - `backgroundService.js`: регистрация и выполнение фоновой проверки новостей. +- `favoritesStorage.js`: избранные сущности расписания. +- `attendanceStorage.js`: хранение и агрегирование посещаемости с ключом по дате и типу пары. +- `academicEventsStorage.js`: локальное хранилище учебных событий. +- `studyProfileStorage.js`: локальное хранилище учебного профиля. - `dateUtils.js`, `scheduleUtils.js`: вспомогательные вычисления дат и состояний расписания. - `constants.js`: централизованные константы приложения. ## Потоки данных -### Расписание +### Поток расписания + 1. Пользователь выбирает режим и фильтры на экране расписания. 2. `ScheduleScreen.js` использует `useScheduleLogic.js`. 3. Хук запрашивает данные через `utils/api.js`. -4. При сетевых проблемах используется fallback на кэш/локальные данные. +4. При сетевых проблемах используется fallback на кэш или локальные данные. 5. Экран показывает статус загрузки, ошибки и состояние кэша. -### Новости +### Поток новостей + 1. `NewsScreen.js` запрашивает новости через API. 2. Новости очищаются, нормализуются и дедуплицируются. 3. При недоступной сети применяется кэш, статус передается в хедер. 4. Фоновая задача может проверить наличие новых новостей и вызвать уведомление. -### Настройки +### Поток настроек + 1. Пользователь меняет настройки темы, уведомлений и режима отображения. 2. Значения сохраняются в локальное хранилище. 3. При повторном запуске настройки загружаются и применяются до основных сценариев. ## Архитектурные ограничения + - Не переносить тяжелую доменную логику в JSX-компоненты. - Не изменять формат локального хранилища без совместимого перехода. -- Не вносить широкие изменения сразу в UI + hooks + utils без необходимости. +- Не вносить широкие изменения сразу в UI, hooks и utils без необходимости. - Любой новый асинхронный поток должен иметь явный путь ошибки. ## Что проверять после архитектурных изменений + - Запуск приложения: `npm run start`. - Отсутствие ошибок импорта в затронутых файлах. - Базовые сценарии: расписание, новости, карта, настройки. diff --git a/docs/data-and-storage.md b/docs/data-and-storage.md index 412da6a..eeb9699 100644 --- a/docs/data-and-storage.md +++ b/docs/data-and-storage.md @@ -1,60 +1,95 @@ # Данные, API и хранилище ## Источники данных + Основной внешний источник данных обрабатывается через `utils/api.js`. Потребители данных: -- расписание и связанные сущности (группы, недели, время пар); + +- расписание и связанные сущности, включая группы, недели и время пар; - новости; - вспомогательные данные для экранов. ## Принципы работы с API + - Любые изменения сетевой логики вносить в сервисный слой, а не в JSX. - Не предполагать, что API всегда вернет валидные данные. - Обрабатывать частичные и пустые ответы без падения UI. - На ошибки сети всегда показывать понятный fallback. ## Кэширование + Кэш и TTL-логика централизованы в `utils/cache.js`. Что важно: + - запись хранится как объект с `value` и `expiry`; - невалидные и поврежденные записи удаляются; - просроченный кэш очищается автоматически; - есть защита от некорректно большого TTL. Практический эффект: + - приложение устойчивее при нестабильной сети; - уменьшается количество повторных запросов; - критично поддерживать обратную совместимость структуры кэша. ## Локальное хранилище + Используются два типа хранения: + - `AsyncStorage`: некритичные и кэшируемые данные. - `SecureStore`: чувствительные или важные настройки пользователя. Типичные категории локальных данных: -- выбранные курс/группа; + +- выбранные курс и группа; - настройки уведомлений; - кэш новостей и расписания; - пользовательские заметки к занятиям. +Дополнительно используются: + +- избранные расписания (`schedule_favorites`); +- отметки посещаемости (`attendance_*`) с учетом даты и типа занятия; +- снимки расписания для детекции изменений (`schedule_snapshot_*`); ключ для дневного режима строится по локальной дате устройства; +- история изменений расписания (`schedule_changes_history_v1`); +- IDs запланированных уведомлений о парах (`schedule_notification_ids`); +- учебные события (`academic_events_v1`); +- учебный профиль (`study_profile_v1`); +- флаги видимости функций расписания (`attendance_tracking_enabled`, `free_auditories_enabled`). + +## Заметки и дедлайны ДЗ + +Заметки к занятиям хранятся под ключами вида `lesson_note_*` в AsyncStorage. + +Поведение `homeworkDueDate` при сохранении: + +- если параметр не передаётся (`undefined`), используется существующее значение из хранилища; +- если параметр явно равен `null`, дедлайн сбрасывается; +- сравнение «сегодня» для поля `isOverdue` выполняется по локальной дате устройства (не UTC). + ## Совместимость данных + При изменении формата локальных данных: + 1. Предусмотреть безопасный fallback для старых значений. 2. Не удалять старые ключи до подтверждения миграции. 3. Обрабатывать ошибки парсинга и отсутствующие поля. 4. Избегать silent-fail: логировать и возвращать безопасный результат. ## Offline-first поведение + Минимальные требования: + - экран должен оставаться работоспособным при пропадании сети; - при наличии кэша пользователь получает данные с понятной маркировкой источника; - при отсутствии кэша пользователь получает информативное состояние ошибки; - после восстановления сети данные должны синхронизироваться без ручного восстановления состояния. ## Практический чеклист для изменений данных -- Проверена обработка `null`/`undefined`/пустых массивов. + +- Проверена обработка `null`, `undefined` и пустых массивов. - Проверены ошибки сети и ошибки парсинга. - Проверена работа кэша после обновления формата данных. - Проверено восстановление состояния после перезапуска приложения. @@ -63,63 +98,71 @@ ```mermaid flowchart LR - UI[ScheduleScreen] --> Hook[useScheduleLogic] - Hook --> API[ApiService] - API --> Net{Сеть доступна?} - Net -- Да --> Remote[Сервер API] - Remote --> API - API --> CacheWrite[Запись в кэш] - Net -- Нет --> CacheRead[Чтение кэша] - CacheRead --> Hook - CacheWrite --> Hook - Hook --> UI + UI[ScheduleScreen] --> Hook[useScheduleLogic] + Hook --> API[ApiService] + API --> Net{Сеть доступна?} + Net -- Да --> Remote[Сервер API] + Remote --> API + API --> CacheWrite[Запись в кэш] + Net -- Нет --> CacheRead[Чтение кэша] + CacheRead --> Hook + CacheWrite --> Hook + Hook --> UI ``` ## Состояния кэша ```mermaid stateDiagram-v2 - [*] --> Empty - Empty --> Fresh: Записаны новые данные - Fresh --> Fresh: Обновление до истечения TTL - Fresh --> Stale: TTL истек - Stale --> Fresh: Успешная синхронизация - Stale --> Invalid: Ошибка парсинга или повреждение - Invalid --> Empty: Очистка записи - Empty --> [*] + [*] --> Empty + Empty --> Fresh: Записаны новые данные + Fresh --> Fresh: Обновление до истечения TTL + Fresh --> Stale: TTL истек + Stale --> Fresh: Успешная синхронизация + Stale --> Invalid: Ошибка парсинга или повреждение + Invalid --> Empty: Очистка записи + Empty --> [*] ``` ## Последовательность fallback при оффлайне ```mermaid sequenceDiagram - participant Screen as Экран - participant Service as ApiService - participant Cache as AsyncStorage - participant User as Пользователь - - Screen->>Service: Запрос данных - Service->>Cache: Проверка валидного кэша - alt Валидный кэш найден - Cache-->>Service: Возврат данных - Service-->>Screen: Показывает кэш - else Валидного кэша нет - Service->>Cache: Проверка устаревшего кэша - alt Устаревший кэш есть - Cache-->>Service: Возврат устаревших данных - Service-->>Screen: Показывает stale + статус - else Кэша нет - Service-->>Screen: Ошибка NO_INTERNET - Screen-->>User: Информативный fallback - end - end + participant Screen as Экран + participant Service as ApiService + participant Cache as AsyncStorage + participant User as Пользователь + + Screen->>Service: Запрос данных + Service->>Cache: Проверка валидного кэша + alt Валидный кэш найден + Cache-->>Service: Возврат данных + Service-->>Screen: Показывает кэш + else Валидного кэша нет + Service->>Cache: Проверка устаревшего кэша + alt Устаревший кэш есть + Cache-->>Service: Возврат устаревших данных + Service-->>Screen: Показывает stale + статус + else Кэша нет + Service-->>Screen: Ошибка NO_INTERNET + Screen-->>User: Информативный fallback + end + end ``` -## Таблица хранения (концептуально) +## Таблица хранения | Категория | Пример места хранения | Назначение | Требование к совместимости | -|---|---|---|---| +| --- | --- | --- | --- | | Настройки уведомлений | SecureStore | Персональные параметры уведомлений | Обязательны значения по умолчанию | | Выбранные параметры расписания | SecureStore | Курс, группа, предпочтения | Нельзя ломать чтение старых ключей | | Кэш новостей и расписания | AsyncStorage | Offline-first и ускорение загрузки | TTL и очистка поврежденных записей | | Заметки к занятиям | AsyncStorage | Пользовательские заметки | Нельзя терять данные при обновлении | +| Дедлайны ДЗ | AsyncStorage | Дедлайн, статус и локальное напоминание для ДЗ | Нужен fallback для старых заметок без даты | +| Избранные расписания | AsyncStorage | Быстрое переключение между сущностями | Стабильные ID для group, teacher и auditory | +| Посещаемость | AsyncStorage | Локальная статистика посещаемости | Ключ должен учитывать дату и тип пары | +| Снимки расписания | AsyncStorage | Уведомления об изменениях расписания | Изоляция по scope: день, неделя, сущность | +| История изменений расписания | AsyncStorage | Отдельная лента изменений за последние 7 дней | Ограниченный rolling window и безопасный парсинг | +| Учебные события | AsyncStorage | Экзамены, зачеты, практика, каникулы, редактирование и экспорт | Стабильная сортировка по дате | +| Учебный профиль | AsyncStorage | Персональные цели по посещаемости | Значения по умолчанию при отсутствии ключа | +| Фиче-флаги расписания | SecureStore | Скрытие и показ инструментов UI | Значения по умолчанию при отсутствии ключа | diff --git a/docs/notifications-and-background.md b/docs/notifications-and-background.md index 3fe7a04..7c17755 100644 --- a/docs/notifications-and-background.md +++ b/docs/notifications-and-background.md @@ -1,57 +1,111 @@ # Уведомления и фоновые задачи ## Компоненты подсистемы + - `utils/notificationService.js`: управление разрешениями, каналами, настройками и триггерами уведомлений. - `utils/backgroundService.js`: регистрация и выполнение периодической фоновой проверки новостей. - `utils/newsNotifications.js`: дополнительная логика, связанная с уведомлениями новостей. -## Уведомления: базовый поток +## Базовый поток уведомлений + 1. Приложение инициализирует обработчик уведомлений. 2. Запрашиваются системные разрешения. -3. На Android создаются каналы (например, для расписания и новостей). +3. На Android создаются каналы, например для расписания и новостей. 4. Настройки пользователя читаются из хранилища. 5. На основе настроек включаются или отключаются соответствующие сценарии уведомлений. ## Настройки уведомлений + Минимально ожидаемые параметры: + - включены ли уведомления в целом; - новости; - расписание; -- события до/в начале/до конца/по окончании пары. +- изменения расписания; +- события до, в начале, до конца и по окончании пары. + +Дополнительные локальные сценарии: + +- напоминания о дедлайнах ДЗ; +- напоминания об учебных событиях. Требования к изменениям: + - не терять совместимость с уже сохраненными настройками; - при отсутствии ключей использовать значения по умолчанию; - не допускать ситуации, когда включенные настройки фактически игнорируются без причины. +## Уведомления об изменениях расписания + +Логика детекции изменений в расписании должна быть консервативной и предсказуемой: + +- проверка запускается только при разрешенных настройках уведомлений; +- для источников `cache` и `stale_cache` уведомления об изменениях не отправляются; +- сравнение выполняется по пересечению общих дней, чтобы переключение между днями не порождало ложные срабатывания; +- снимки расписания сохраняются отдельно по scope: день, неделя, сущность расписания; +- ключ scope для дневного режима строится по локальной дате (`YYYY-MM-DD` по таймзоне устройства), чтобы избежать смещения UTC. + +## Управление уведомлениями о парах + +При обновлении расписания уведомления о парах отменяются и перепланируются точечно: + +- IDs запланированных уведомлений о парах хранятся в `AsyncStorage` под ключом `schedule_notification_ids`; +- при пересчёте отменяются только эти конкретные уведомления через `cancelScheduledNotificationAsync`; +- уведомления других типов (`homework_deadline`, `academic_event`) не затрагиваются; +- новые IDs сохраняются после успешного планирования. + ## Фоновая проверка новостей + Фоновая задача определяется через Expo Task Manager. Ожидаемое поведение: + - перед загрузкой новостей проверяются пользовательские настройки; - если уведомления новостей выключены, задача завершается без побочных действий; - при успешной загрузке выполняется проверка новых новостей; - ошибки обрабатываются безопасно, без падения приложения. ## Платформенные ограничения + - Фоновые задачи могут вести себя по-разному на Android и iOS. - Нельзя гарантировать строгое выполнение по расписанию на всех устройствах. - Нельзя опираться на фоновую задачу как на единственный способ синхронизации данных. ## Риски и защита от регрессии + Риски: + - дублирование уведомлений; - слишком частые проверки и лишний расход батареи; - потеря уведомлений из-за некорректной проверки настроек; - нестабильность при отказе разрешений. +Дополнительные риски для расписания: + +- ложные уведомления при переключении между днями и кэшированными данными; +- сравнение неполных снимков расписания между разными scope. + Профилактика: + - идемпотентная логика проверки новых событий; - строгая проверка настроек перед отправкой уведомлений; - аккуратная обработка ошибок разрешений и сетевых ошибок; - тестирование на реальном устройстве. +Для новых локальных напоминаний: + +- перед постановкой напоминания явно проверять системное разрешение уведомлений; +- не планировать напоминание на дату в прошлом; +- при удалении дедлайна или события отменять связанное запланированное уведомление. + +Дополнительно для расписания: + +- фильтрация источников данных, включая игнорирование `cache` и `stale_cache`; +- хранение снимков расписания по изолированным ключам scope; +- сравнение только сопоставимых наборов данных, то есть общих weekdays. + ## Минимальная ручная проверка + 1. Включить и выключить уведомления в настройках. 2. Проверить, что состояние сохраняется после перезапуска. 3. Проверить отсутствие дублей уведомлений в типовом сценарии. @@ -61,64 +115,64 @@ ```mermaid flowchart TB - App[App.js] - NotificationService[notificationService.js] - BackgroundService[backgroundService.js] - API[ApiService] - Storage[SecureStore/AsyncStorage] - OS[ОС: iOS/Android] - - App --> NotificationService - App --> BackgroundService - NotificationService --> Storage - BackgroundService --> API - BackgroundService --> NotificationService - NotificationService --> OS + App[App.js] + NotificationService[notificationService.js] + BackgroundService[backgroundService.js] + API[ApiService] + Storage[SecureStore/AsyncStorage] + OS[ОС: iOS/Android] + + App --> NotificationService + App --> BackgroundService + NotificationService --> Storage + BackgroundService --> API + BackgroundService --> NotificationService + NotificationService --> OS ``` ## Жизненный цикл фоновой проверки новостей ```mermaid stateDiagram-v2 - [*] --> Idle - Idle --> Triggered: ОС запустила задачу - Triggered --> ValidateSettings: Чтение настроек - ValidateSettings --> Skipped: Новости выключены - ValidateSettings --> Fetching: Новости включены - Fetching --> Comparing: Данные получены - Comparing --> Notifying: Найдены новые новости - Comparing --> NoChanges: Новостей нет - Notifying --> SaveMarker: Сохранить маркер последней новости - SaveMarker --> Completed - NoChanges --> Completed - Skipped --> Completed - Fetching --> Failed: Сетевая ошибка - Failed --> Completed - Completed --> Idle + [*] --> Idle + Idle --> Triggered: ОС запустила задачу + Triggered --> ValidateSettings: Чтение настроек + ValidateSettings --> Skipped: Новости выключены + ValidateSettings --> Fetching: Новости включены + Fetching --> Comparing: Данные получены + Comparing --> Notifying: Найдены новые новости + Comparing --> NoChanges: Новостей нет + Notifying --> SaveMarker: Сохранить маркер последней новости + SaveMarker --> Completed + NoChanges --> Completed + Skipped --> Completed + Fetching --> Failed: Сетевая ошибка + Failed --> Completed + Completed --> Idle ``` ## Последовательность отправки уведомления о новости ```mermaid sequenceDiagram - participant Task as Background task - participant NS as notificationService - participant API as ApiService - participant Cache as Хранилище - participant User as Пользователь - - Task->>NS: getNotificationSettings() - NS-->>Task: enabled/news flags - Task->>API: getNews(0, 5) - API-->>Task: latest news - Task->>NS: checkForNewNews(data) - NS->>Cache: read last_notified_news - Cache-->>NS: previous marker - NS->>NS: compare and detect delta - alt Есть новая новость - NS-->>User: Push/local notification - NS->>Cache: write new marker - else Новых новостей нет - NS-->>Task: no-op - end + participant Task as Background task + participant NS as notificationService + participant API as ApiService + participant Cache as Хранилище + participant User as Пользователь + + Task->>NS: getNotificationSettings() + NS-->>Task: enabled/news flags + Task->>API: getNews(0, 5) + API-->>Task: latest news + Task->>NS: checkForNewNews(data) + NS->>Cache: read last_notified_news + Cache-->>NS: previous marker + NS->>NS: compare and detect delta + alt Есть новая новость + NS-->>User: Push или local notification + NS->>Cache: write new marker + else Новых новостей нет + NS-->>Task: no-op + end ``` diff --git a/docs/screens.md b/docs/screens.md index 0f7aa78..8ca9e4e 100644 --- a/docs/screens.md +++ b/docs/screens.md @@ -1,39 +1,62 @@ # Экраны и пользовательские сценарии ## Главная навигация + Приложение использует табовую навигацию через собственные компоненты и внутреннее состояние `activeScreen` в `App.js`. Основные экраны: + - Расписание - Карта -- Первокурснику +- Первокурснику / Студенту - Новости - Настройки ## Экран расписания (`components/ScheduleScreen.js`) -### Назначение +### Назначение расписания + Дать пользователю доступ к расписанию в нескольких режимах: + - студенческий режим; - режим преподавателя; - режим аудитории. -### Что поддерживается +### Возможности расписания + - выбор курса и группы; - переключение вида (день/неделя); - переход по датам и неделям; - отображение текущей пары и таймера до окончания; - экспорт расписания в календарь; +- при успешном экспорте в календарь разблокируется достижение `export_master`; - добавление заметок к парам; -- шэринг расписания как изображения. +- дедлайны ДЗ со статусом и локальным напоминанием; +- при отметке ДЗ как выполненного в заметке разблокируется достижение `homework_done`; +- шэринг расписания как изображения; +- избранные расписания (группы, преподаватели, аудитории); +- модальное окно избранного с быстрым переключением и удалением; +- отметка посещаемости прямо в карточке пары с учетом даты и типа занятия; +- статистика посещаемости по предметам; +- поиск свободных аудиторий по неделе, дню и паре; +- при использовании поиска свободных аудиторий разблокируется достижение `free_room_hunter`; +- переключатель недели в свободных аудиториях реализован в том же формате, что и в разделе Расписание (стрелки + подпись «Неделя N»); +- подсказки дат для дней недели в модальном окне свободных аудиторий; +- при показе расписания из fallback-кэша при сетевой ошибке разблокируется достижение `offline_hero`; +- отдельные настройки показа посещаемости и поиска свободных аудиторий. + +### Зависимости расписания -### Зависимости - Логика: `hooks/useScheduleLogic.js` - API и данные: `utils/api.js`, `utils/dateUtils.js`, `utils/scheduleUtils.js` - Заметки: `utils/notesStorage.js` +- Посещаемость: `utils/attendanceStorage.js`, `components/AttendanceStatsModal.js` +- Избранное: `utils/favoritesStorage.js` +- Свободные аудитории: `components/FreeAuditoriesScreen.js` - Экспорт: `utils/calendarExport.js` -### Критичные риски регрессии +### Риски регрессии расписания + - некорректная фильтрация групп/курсов; - сбой переключения день/неделя; - сломанное поведение при отсутствии сети; @@ -41,17 +64,22 @@ ## Экран новостей (`components/NewsScreen.js`) -### Назначение +### Назначение новостей + Показывать новости в виде ленты с поддержкой обновления и поведения оффлайн. -### Что поддерживается +### Возможности новостей + - загрузка и пагинация новостей; - pull-to-refresh; - фильтрация пустых и дублированных записей; +- локальный поиск по уже загруженным новостям; +- заглушка "Пока что новостей нет. Попробуйте заглянуть позже :)" при пустом ответе API; - fallback на кэш; - синхронизация cache-статуса с хедером. -### Критичные риски регрессии +### Риски регрессии новостей + - дубли новостей; - неправильная сортировка по дате; - отсутствие fallback при сетевой ошибке; @@ -59,46 +87,101 @@ ## Экран карты (`components/MapScreen.js`) -### Назначение +### Назначение карты + Показывать корпуса и объекты ХГУ с фильтрацией и маршрутизацией. -### Что поддерживается +### Возможности карты + - отображение карты и маркеров зданий; - фильтры категорий; - список корпусов; - открытие вариантов маршрута; - обработка сетевого и картографического fallback-состояния. -### Критичные риски регрессии +### Риски регрессии карты + - падение карты на части устройств; - рассинхрон фильтров и фактического списка маркеров; - отсутствие понятного состояния ошибки/загрузки. ## Экран настроек (`components/SettingsScreen.js`) -### Назначение +### Назначение настроек + Центральная точка управления параметрами приложения и служебными функциями. -### Что поддерживается +### Возможности настроек + - тема и визуальные параметры; - уведомления; - настройки формата расписания; +- календарь учебных событий; +- персональный учебный профиль; +- отдельная лента изменений расписания; - экран достижений и связанные действия; -- служебные действия (включая очистку заметок). +- при полном наборе достижений становятся доступны эксклюзивные тема и акцент оформления, а также особый вариант стартового splash-экрана; +- режим разработчика с инструментами тестирования (пинг API, инспектор ключей AsyncStorage, тесты и массовая разблокировка достижений); +- служебные действия, включая очистку заметок. + +### Риски регрессии настроек -### Критичные риски регрессии - несохранение настроек; - несовместимость старых значений после обновления; -- побочные эффекты при сбросе/очистке локальных данных. +- побочные эффекты при сбросе или очистке локальных данных. -## Экран первокурснику (`components/FreshmanScreen.js`) -Экран с информацией для новых студентов. Изменения должны сохранять читаемость, актуальность ссылок и стабильность навигации. +## Экран первокурснику / студенту (`components/FreshmanScreen.js`) + +Экран с полезными материалами не только для первокурсников, но и для текущих студентов. + +### Возможности раздела первокурснику / студенту + +- быстрые ссылки на ключевые ресурсы ИТИ/ХГУ; +- список корпусов университета; +- полезные группы и сообщества; +- расписание звонков с сервера; +- календарь учебных событий с экспортом в календарь и ICS; +- словарь аббревиатур; +- вложенные представления с корректным возвратом назад. + +## Экран учебных событий (`components/AcademicCalendarScreen.js`) + +### Назначение учебных событий + +Дать пользователю единое место для ручного ведения учебных дат: экзамены, зачеты, практика, каникулы и другие важные события. + +### Возможности учебных событий + +- создание события через отдельное модальное окно, вызываемое кнопкой `+`; +- кнопка `+` расположена в панели действий блока фильтров и не перекрывает нижний tab bar; +- модальное окно учебного события реализовано в формате `pageSheet` и по размерам совпадает с окнами "Свободные аудитории" и "Посещаемость"; +- отображение идет поверх tab bar через системный `Modal`-слой; +- редактирование ранее созданного события в том же модальном окне; +- создание события с нативным date picker; +- при первом открытии поля даты автоматически подставляется сегодняшняя дата для ускорения ввода; +- date picker корректно открывается и закрывается при активной клавиатуре; +- iOS date picker оформлен в карточном стиле модального окна (заголовок, действия Сегодня/Очистить/Готово); +- названия месяцев и календарные подписи отображаются на русском (`ru-RU`); +- отображение даты в формате `дд.мм.гггг`; +- фильтры по типу событий; +- локальные напоминания по событию; +- экспорт текущего списка событий в системный календарь и `.ics`. + +## Экран ленты изменений расписания (`components/ScheduleChangesHistoryScreen.js`) + +### Назначение ленты изменений + +Показывать отдельную историю изменений расписания за последние 7 дней с детализацией по полям пары. + +### Возможности ленты изменений + +- отдельный экран из настроек; +- diff по предмету, преподавателю, аудитории и номеру пары; +- pull-to-refresh для ручного обновления списка. ## Общие правила для изменений экранов -- Любой экран должен иметь согласованные состояния: - - загрузка; - - пустой результат; - - ошибка; - - успешный рендер данных. + +- Любой экран должен иметь согласованные состояния: загрузка, пустой результат, ошибка и успешный рендер данных. - Избегать переноса сложной логики в JSX. - Новые пользовательские действия должны иметь предсказуемый результат и сообщение об ошибке при сбое. +- Изменения должны сохранять читаемость, актуальность ссылок и стабильность навигации. diff --git a/hooks/useScheduleLogic.js b/hooks/useScheduleLogic.js index 56e3a86..49f6022 100644 --- a/hooks/useScheduleLogic.js +++ b/hooks/useScheduleLogic.js @@ -3,6 +3,7 @@ import NetInfo from '@react-native-community/netinfo'; import ApiService from '../utils/api'; import notificationService from '../utils/notificationService'; import { getWeekNumber, setServerWeekNumber } from '../utils/dateUtils'; +import { unlockAchievement } from '../utils/achievements'; import * as SecureStore from 'expo-secure-store'; export const useScheduleLogic = () => { @@ -217,6 +218,22 @@ export const useScheduleLogic = () => { } catch (notifyError) { console.error('Error scheduling notifications:', notifyError); } + + // Проверяем изменения в расписании и уведомляем пользователя + try { + const d = new Date(currentDate); + const localDateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + const scopeSuffix = viewMode === 'day' + ? `day_${localDateStr}` + : `week_${processedSchedule?.week_number || currentWeek}`; + await notificationService.checkScheduleChanges( + processedSchedule, + `${group}__${scopeSuffix}`, + { source: result.source } + ); + } catch (changeError) { + console.error('Error checking schedule changes:', changeError); + } console.log('Загружено расписание для группы', group, 'на', viewMode === 'day' ? currentDate.toDateString() : `неделю ${currentWeek}`); } catch (error) { @@ -227,6 +244,7 @@ export const useScheduleLogic = () => { setScheduleData(cachedScheduleData); setShowCachedData(true); setCacheInfo({ source: 'stale_cache', cacheInfo: { cacheDate: new Date().toISOString() } }); + unlockAchievement('offline_hero').catch(() => {}); } } finally { setLoadingSchedule(false); diff --git a/package-lock.json b/package-lock.json index 882a6e8..d54d5b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mykhsu", - "version": "2.3.1", + "version": "2.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mykhsu", - "version": "2.3.1", + "version": "2.3.2", "license": "LGPL-3.0", "dependencies": { "@2gis/mapgl": "^1.66.0", @@ -14,31 +14,32 @@ "@expo/metro-runtime": "~6.1.2", "@expo/vector-icons": "^15.0.2", "@react-native-async-storage/async-storage": "2.2.0", + "@react-native-community/datetimepicker": "8.4.4", "@react-native-community/netinfo": "11.4.1", "@react-native/gradle-plugin": "^0.81.4", "@sentry/react-native": "~7.2.0", - "expo": "~54.0.33", + "expo": "~54.0.34", "expo-background-fetch": "~14.0.9", "expo-blur": "~15.0.8", "expo-calendar": "~15.0.8", "expo-constants": "~18.0.9", - "expo-dev-client": "~6.0.20", + "expo-dev-client": "~6.0.21", "expo-device": "~8.0.10", - "expo-file-system": "~19.0.16", + "expo-file-system": "~19.0.22", "expo-font": "~14.0.7", "expo-insights": "~0.10.8", - "expo-linking": "~8.0.11", + "expo-linking": "~8.0.12", "expo-location": "~19.0.8", "expo-module-scripts": "^4.1.10", - "expo-notifications": "~0.32.16", + "expo-notifications": "~0.32.17", "expo-secure-store": "~15.0.8", "expo-sharing": "~14.0.8", "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", "expo-system-ui": "~6.0.9", "expo-task-manager": "~14.0.9", - "expo-updates": "~29.0.16", - "expo-web-browser": "~15.0.10", + "expo-updates": "~29.0.17", + "expo-web-browser": "~15.0.11", "react": "19.1.0", "react-dom": "19.1.0", "react-native": "0.81.5", @@ -124,9 +125,9 @@ } }, "node_modules/@babel/cli/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -465,9 +466,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2270,9 +2271,9 @@ } }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "peer": true, "dependencies": { @@ -2358,9 +2359,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "peer": true, "dependencies": { @@ -2589,9 +2590,9 @@ } }, "node_modules/@expo/fingerprint": { - "version": "0.15.4", - "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.15.4.tgz", - "integrity": "sha512-eYlxcrGdR2/j2M6pEDXo9zU9KXXF1vhP+V+Tl+lyY+bU8lnzrN6c637mz6Ye3em2ANy8hhUR03Raf8VsT9Ogng==", + "version": "0.15.5", + "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.15.5.tgz", + "integrity": "sha512-mdVoAMcux1WlM6kd1RoWiHRNqKqS+J6mKmWQ/BKgeh937S/fcW58EE68O6nc4KDXtWi3PBeNHskOFcgyIuD4hw==", "license": "MIT", "dependencies": { "@expo/spawn-async": "^1.7.2", @@ -2601,7 +2602,7 @@ "getenv": "^2.0.0", "glob": "^13.0.0", "ignore": "^5.3.1", - "minimatch": "^9.0.0", + "minimatch": "^10.2.2", "p-limit": "^3.1.0", "resolve-from": "^5.0.0", "semver": "^7.6.0" @@ -2620,9 +2621,9 @@ } }, "node_modules/@expo/fingerprint/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -2632,29 +2633,38 @@ } }, "node_modules/@expo/fingerprint/node_modules/glob": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", - "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "license": "BlueOak-1.0.0", "dependencies": { - "minimatch": "^10.1.1", - "minipass": "^7.1.2", - "path-scurry": "^2.0.0" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@expo/fingerprint/node_modules/glob/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "node_modules/@expo/fingerprint/node_modules/lru-cache": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@expo/fingerprint/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -2663,35 +2673,26 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@expo/fingerprint/node_modules/lru-cache": { - "version": "11.2.4", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", - "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@expo/fingerprint/node_modules/path-scurry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", - "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@expo/fingerprint/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -2757,279 +2758,18 @@ "dependencies": { "metro": "0.83.3", "metro-babel-transformer": "0.83.3", - "metro-cache": "0.83.3", - "metro-cache-key": "0.83.3", - "metro-config": "0.83.3", - "metro-core": "0.83.3", - "metro-file-map": "0.83.3", - "metro-minify-terser": "0.83.3", - "metro-resolver": "0.83.3", - "metro-runtime": "0.83.3", - "metro-source-map": "0.83.3", - "metro-symbolicate": "0.83.3", - "metro-transform-plugins": "0.83.3", - "metro-transform-worker": "0.83.3" - } - }, - "node_modules/@expo/metro-config": { - "version": "54.0.14", - "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.14.tgz", - "integrity": "sha512-hxpLyDfOR4L23tJ9W1IbJJsG7k4lv2sotohBm/kTYyiG+pe1SYCAWsRmgk+H42o/wWf/HQjE5k45S5TomGLxNA==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.20.0", - "@babel/core": "^7.20.0", - "@babel/generator": "^7.20.5", - "@expo/config": "~12.0.13", - "@expo/env": "~2.0.8", - "@expo/json-file": "~10.0.8", - "@expo/metro": "~54.2.0", - "@expo/spawn-async": "^1.7.2", - "browserslist": "^4.25.0", - "chalk": "^4.1.0", - "debug": "^4.3.2", - "dotenv": "~16.4.5", - "dotenv-expand": "~11.0.6", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "hermes-parser": "^0.29.1", - "jsc-safe-url": "^0.2.4", - "lightningcss": "^1.30.1", - "minimatch": "^9.0.0", - "postcss": "~8.4.32", - "resolve-from": "^5.0.0" - }, - "peerDependencies": { - "expo": "*" - }, - "peerDependenciesMeta": { - "expo": { - "optional": true - } - } - }, - "node_modules/@expo/metro-config/node_modules/@expo/config": { - "version": "12.0.13", - "resolved": "https://registry.npmjs.org/@expo/config/-/config-12.0.13.tgz", - "integrity": "sha512-Cu52arBa4vSaupIWsF0h7F/Cg//N374nYb7HAxV0I4KceKA7x2UXpYaHOL7EEYYvp7tZdThBjvGpVmr8ScIvaQ==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "~7.10.4", - "@expo/config-plugins": "~54.0.4", - "@expo/config-types": "^54.0.10", - "@expo/json-file": "^10.0.8", - "deepmerge": "^4.3.1", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "require-from-string": "^2.0.2", - "resolve-from": "^5.0.0", - "resolve-workspace-root": "^2.0.0", - "semver": "^7.6.0", - "slugify": "^1.3.4", - "sucrase": "~3.35.1" - } - }, - "node_modules/@expo/metro-config/node_modules/@expo/config-plugins": { - "version": "54.0.4", - "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-54.0.4.tgz", - "integrity": "sha512-g2yXGICdoOw5i3LkQSDxl2Q5AlQCrG7oniu0pCPPO+UxGb7He4AFqSvPSy8HpRUj55io17hT62FTjYRD+d6j3Q==", - "license": "MIT", - "dependencies": { - "@expo/config-types": "^54.0.10", - "@expo/json-file": "~10.0.8", - "@expo/plist": "^0.4.8", - "@expo/sdk-runtime-versions": "^1.0.0", - "chalk": "^4.1.2", - "debug": "^4.3.5", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "resolve-from": "^5.0.0", - "semver": "^7.5.4", - "slash": "^3.0.0", - "slugify": "^1.6.6", - "xcode": "^3.0.1", - "xml2js": "0.6.0" - } - }, - "node_modules/@expo/metro-config/node_modules/@expo/config-types": { - "version": "54.0.10", - "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-54.0.10.tgz", - "integrity": "sha512-/J16SC2an1LdtCZ67xhSkGXpALYUVUNyZws7v+PVsFZxClYehDSoKLqyRaGkpHlYrCc08bS0RF5E0JV6g50psA==", - "license": "MIT" - }, - "node_modules/@expo/metro-config/node_modules/@expo/config/node_modules/@babel/code-frame": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", - "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", - "license": "MIT", - "dependencies": { - "@babel/highlight": "^7.10.4" - } - }, - "node_modules/@expo/metro-config/node_modules/@expo/json-file": { - "version": "10.0.8", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.8.tgz", - "integrity": "sha512-9LOTh1PgKizD1VXfGQ88LtDH0lRwq9lsTb4aichWTWSWqy3Ugfkhfm3BhzBIkJJfQQ5iJu3m/BoRlEIjoCGcnQ==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "~7.10.4", - "json5": "^2.2.3" - } - }, - "node_modules/@expo/metro-config/node_modules/@expo/json-file/node_modules/@babel/code-frame": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", - "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", - "license": "MIT", - "dependencies": { - "@babel/highlight": "^7.10.4" - } - }, - "node_modules/@expo/metro-config/node_modules/@expo/plist": { - "version": "0.4.8", - "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.4.8.tgz", - "integrity": "sha512-pfNtErGGzzRwHP+5+RqswzPDKkZrx+Cli0mzjQaus1ZWFsog5ibL+nVT3NcporW51o8ggnt7x813vtRbPiyOrQ==", - "license": "MIT", - "dependencies": { - "@xmldom/xmldom": "^0.8.8", - "base64-js": "^1.2.3", - "xmlbuilder": "^15.1.1" - } - }, - "node_modules/@expo/metro-config/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@expo/metro-config/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@expo/metro-config/node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/@expo/metro-config/node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@expo/metro-config/node_modules/glob/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@expo/metro-config/node_modules/hermes-estree": { - "version": "0.29.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.29.1.tgz", - "integrity": "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ==", - "license": "MIT" - }, - "node_modules/@expo/metro-config/node_modules/hermes-parser": { - "version": "0.29.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.29.1.tgz", - "integrity": "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA==", - "license": "MIT", - "dependencies": { - "hermes-estree": "0.29.1" - } - }, - "node_modules/@expo/metro-config/node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@expo/metro-config/node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@expo/metro-config/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@expo/metro-config/node_modules/sucrase": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", - "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" + "metro-cache": "0.83.3", + "metro-cache-key": "0.83.3", + "metro-config": "0.83.3", + "metro-core": "0.83.3", + "metro-file-map": "0.83.3", + "metro-minify-terser": "0.83.3", + "metro-resolver": "0.83.3", + "metro-runtime": "0.83.3", + "metro-source-map": "0.83.3", + "metro-symbolicate": "0.83.3", + "metro-transform-plugins": "0.83.3", + "metro-transform-worker": "0.83.3" } }, "node_modules/@expo/metro-runtime": { @@ -3077,48 +2817,38 @@ } }, "node_modules/@expo/osascript": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.3.8.tgz", - "integrity": "sha512-/TuOZvSG7Nn0I8c+FcEaoHeBO07yu6vwDgk7rZVvAXoeAK5rkA09jRyjYsZo+0tMEFaToBeywA6pj50Mb3ny9w==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.6.0.tgz", + "integrity": "sha512-QvqDBlJXa8CS2vRORJ4wEflY1m0vVI07uSJdIRgBrLxRPBcsrXxrtU7+wXRXMqfq9zLwNP9XbvRsXF2omoDylg==", "license": "MIT", "dependencies": { - "@expo/spawn-async": "^1.7.2", - "exec-async": "^2.2.0" + "@expo/spawn-async": "^1.8.0" }, "engines": { "node": ">=12" } }, "node_modules/@expo/package-manager": { - "version": "1.9.10", - "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.9.10.tgz", - "integrity": "sha512-axJm+NOj3jVxep49va/+L3KkF3YW/dkV+RwzqUJedZrv4LeTqOG4rhrCaCPXHTvLqCTDKu6j0Xyd28N7mnxsGA==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.12.0.tgz", + "integrity": "sha512-SWr6093nwBjn94cvElsYZNUnhvs+XtUatUz3h0vAn0IbaWG0B6l/V5ZfOBptX/xq6rMpFG5ibIf/eckLSXw8Gg==", "license": "MIT", "dependencies": { - "@expo/json-file": "^10.0.8", - "@expo/spawn-async": "^1.7.2", + "@expo/json-file": "^10.2.0", + "@expo/spawn-async": "^1.8.0", "chalk": "^4.0.0", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "resolve-workspace-root": "^2.0.0" } }, - "node_modules/@expo/package-manager/node_modules/@babel/code-frame": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", - "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", - "license": "MIT", - "dependencies": { - "@babel/highlight": "^7.10.4" - } - }, "node_modules/@expo/package-manager/node_modules/@expo/json-file": { - "version": "10.0.8", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.8.tgz", - "integrity": "sha512-9LOTh1PgKizD1VXfGQ88LtDH0lRwq9lsTb4aichWTWSWqy3Ugfkhfm3BhzBIkJJfQQ5iJu3m/BoRlEIjoCGcnQ==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", + "integrity": "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==", "license": "MIT", "dependencies": { - "@babel/code-frame": "~7.10.4", + "@babel/code-frame": "^7.20.0", "json5": "^2.2.3" } }, @@ -3243,9 +2973,9 @@ } }, "node_modules/@expo/prebuild-config/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -3367,12 +3097,12 @@ "license": "MIT" }, "node_modules/@expo/spawn-async": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.7.2.tgz", - "integrity": "sha512-QdWi16+CHB9JYP7gma19OVVg0BFkvU8zNj9GjWorYI8Iv8FUxjOCcYRuAmX4s/h91e4e7BPsskc8cSrZYho9Ew==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.8.0.tgz", + "integrity": "sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw==", "license": "MIT", "dependencies": { - "cross-spawn": "^7.0.3" + "cross-spawn": "^7.0.6" }, "engines": { "node": ">=12" @@ -3402,9 +3132,9 @@ "license": "MIT" }, "node_modules/@expo/xcpretty": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.1.tgz", - "integrity": "sha512-KZNxZvnGCtiM2aYYZ6Wz0Ix5r47dAvpNLApFtZWnSoERzAdOMzVBOPysBoM0JlF6FKWZ8GPqgn6qt3dV/8Zlpg==", + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.4.tgz", + "integrity": "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==", "license": "BSD-3-Clause", "dependencies": { "@babel/code-frame": "^7.20.0", @@ -3881,9 +3611,9 @@ } }, "node_modules/@jest/reporters/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "peer": true, "dependencies": { @@ -4719,6 +4449,29 @@ "node": ">=10" } }, + "node_modules/@react-native-community/datetimepicker": { + "version": "8.4.4", + "resolved": "https://registry.npmjs.org/@react-native-community/datetimepicker/-/datetimepicker-8.4.4.tgz", + "integrity": "sha512-bc4ZixEHxZC9/qf5gbdYvIJiLZ5CLmEsC3j+Yhe1D1KC/3QhaIfGDVdUcid0PdlSoGOSEq4VlB93AWyetEyBSQ==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": ">=52.0.0", + "react": "*", + "react-native": "*", + "react-native-windows": "*" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "react-native-windows": { + "optional": true + } + } + }, "node_modules/@react-native-community/netinfo": { "version": "11.4.1", "resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-11.4.1.tgz", @@ -4831,9 +4584,9 @@ } }, "node_modules/@react-native/codegen/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -5805,9 +5558,9 @@ "license": "MIT" }, "node_modules/@xmldom/xmldom": { - "version": "0.8.11", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", - "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "version": "0.8.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", + "integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -5899,9 +5652,9 @@ } }, "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -6026,9 +5779,9 @@ } }, "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -6666,9 +6419,9 @@ } }, "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -8360,9 +8113,9 @@ } }, "node_modules/eslint-plugin-import/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -8461,9 +8214,9 @@ } }, "node_modules/eslint-plugin-node/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -8557,9 +8310,9 @@ } }, "node_modules/eslint-plugin-react/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -8666,9 +8419,9 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "peer": true, "dependencies": { @@ -8802,12 +8555,6 @@ "node": ">=6" } }, - "node_modules/exec-async": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/exec-async/-/exec-async-2.2.0.tgz", - "integrity": "sha512-87OpwcEiMia/DeiKFzaQNBNFeN3XkkpYIh9FyOqq5mS2oKv3CBE67PXoEKcr6nodWdXNogTiQ0jE2NGuoffXPw==", - "license": "MIT" - }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -8887,29 +8634,29 @@ } }, "node_modules/expo": { - "version": "54.0.33", - "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.33.tgz", - "integrity": "sha512-3yOEfAKqo+gqHcV8vKcnq0uA5zxlohnhA3fu4G43likN8ct5ZZ3LjAh9wDdKteEkoad3tFPvwxmXW711S5OHUw==", + "version": "54.0.34", + "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.34.tgz", + "integrity": "sha512-XkVHguZZDC8BcTQxHAd14/TQFbDp1Wt0Z/KApO9t68Ll5A127hLCPzU+a9gytfCIiyL/V1IpF1vIcOLKEVAoNQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.0", - "@expo/cli": "54.0.23", + "@expo/cli": "54.0.24", "@expo/config": "~12.0.13", "@expo/config-plugins": "~54.0.4", "@expo/devtools": "0.1.8", - "@expo/fingerprint": "0.15.4", + "@expo/fingerprint": "0.15.5", "@expo/metro": "~54.2.0", - "@expo/metro-config": "54.0.14", + "@expo/metro-config": "54.0.15", "@expo/vector-icons": "^15.0.3", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~54.0.10", - "expo-asset": "~12.0.12", + "expo-asset": "~12.0.13", "expo-constants": "~18.0.13", - "expo-file-system": "~19.0.21", + "expo-file-system": "~19.0.22", "expo-font": "~14.0.11", "expo-keep-awake": "~15.0.8", - "expo-modules-autolinking": "3.0.24", - "expo-modules-core": "3.0.29", + "expo-modules-autolinking": "3.0.25", + "expo-modules-core": "3.0.30", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-without-unicode": "8.0.0-3" @@ -8947,21 +8694,6 @@ "expo": "*" } }, - "node_modules/expo-asset": { - "version": "12.0.12", - "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.12.tgz", - "integrity": "sha512-CsXFCQbx2fElSMn0lyTdRIyKlSXOal6ilLJd+yeZ6xaC7I9AICQgscY5nj0QcwgA+KYYCCEQEBndMsmj7drOWQ==", - "license": "MIT", - "dependencies": { - "@expo/image-utils": "^0.8.8", - "expo-constants": "~18.0.12" - }, - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" - } - }, "node_modules/expo-background-fetch": { "version": "14.0.9", "resolved": "https://registry.npmjs.org/expo-background-fetch/-/expo-background-fetch-14.0.9.tgz", @@ -9098,9 +8830,9 @@ } }, "node_modules/expo-constants/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -9210,15 +8942,15 @@ } }, "node_modules/expo-dev-client": { - "version": "6.0.20", - "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-6.0.20.tgz", - "integrity": "sha512-5XjoVlj1OxakNxy55j/AUaGPrDOlQlB6XdHLLWAw61w5ffSpUDHDnuZzKzs9xY1eIaogOqTOQaAzZ2ddBkdXLA==", + "version": "6.0.21", + "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-6.0.21.tgz", + "integrity": "sha512-SWI6HD0pa4eJujkYFkvvpezUE1zmJXGLu+34azpu7+QJgO+FLutDYDj8BSTdeH/NYDEClDFjCGqVMcWETvmsCQ==", "license": "MIT", "dependencies": { - "expo-dev-launcher": "6.0.20", - "expo-dev-menu": "7.0.18", + "expo-dev-launcher": "6.0.21", + "expo-dev-menu": "7.0.19", "expo-dev-menu-interface": "2.0.0", - "expo-manifests": "~1.0.10", + "expo-manifests": "~1.0.11", "expo-updates-interface": "~2.0.0" }, "peerDependencies": { @@ -9226,23 +8958,23 @@ } }, "node_modules/expo-dev-launcher": { - "version": "6.0.20", - "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-6.0.20.tgz", - "integrity": "sha512-a04zHEeT9sB0L5EB38fz7sNnUKJ2Ar1pXpcyl60Ki8bXPNCs9rjY7NuYrDkP/irM8+1DklMBqHpyHiLyJ/R+EA==", + "version": "6.0.21", + "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-6.0.21.tgz", + "integrity": "sha512-QZ9gcKMZbp6EsIhzS0QoGB8Cf4xeVJhjbNgWUwcoBIk8gshoFz8CkCQOnX+HNv2sSY3rdCaNpx3Xo0Rflyq7rA==", "license": "MIT", "dependencies": { "ajv": "^8.11.0", - "expo-dev-menu": "7.0.18", - "expo-manifests": "~1.0.10" + "expo-dev-menu": "7.0.19", + "expo-manifests": "~1.0.11" }, "peerDependencies": { "expo": "*" } }, "node_modules/expo-dev-menu": { - "version": "7.0.18", - "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-7.0.18.tgz", - "integrity": "sha512-4kTdlHrnZCAWCT6tZRQHSSjZ7vECFisL4T+nsG/GJDo/jcHNaOVGV5qPV9wzlTxyMk3YOPggRw4+g7Ownrg5eA==", + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-7.0.19.tgz", + "integrity": "sha512-ju5MZiBCPhUKKvHy0ElZdnlhq01mkEEiR8jfrgQVvW26aWjzjLiOhppNAyXtvGbhk7WxJim3wYMiqFFrjGdfKA==", "license": "MIT", "dependencies": { "expo-dev-menu-interface": "2.0.0" @@ -9305,9 +9037,9 @@ "license": "MIT" }, "node_modules/expo-file-system": { - "version": "19.0.21", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz", - "integrity": "sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg==", + "version": "19.0.22", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.22.tgz", + "integrity": "sha512-l9pgahSc7sJD0bP9vBNeXvZjy8QKDpVHVxWmei/ESQOrzmoj5BidziqLVsyZdxsi+PfdbTtttLTAmddH/JafYA==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -9357,12 +9089,12 @@ } }, "node_modules/expo-linking": { - "version": "8.0.11", - "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.11.tgz", - "integrity": "sha512-+VSaNL5om3kOp/SSKO5qe6cFgfSIWnnQDSbA7XLs3ECkYzXRquk5unxNS3pg7eK5kNUmQ4kgLI7MhTggAEUBLA==", + "version": "8.0.12", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.12.tgz", + "integrity": "sha512-FpXeIpFgZuxihwT9lBo86YD3y6LphBuAhN680MMxm/Y7fmsc57vimn2d3vFu68VI0+Z9w457t494mu2wvlgWTQ==", "license": "MIT", "dependencies": { - "expo-constants": "~18.0.12", + "expo-constants": "~18.0.13", "invariant": "^2.2.4" }, "peerDependencies": { @@ -9380,12 +9112,12 @@ } }, "node_modules/expo-manifests": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-1.0.10.tgz", - "integrity": "sha512-oxDUnURPcL4ZsOBY6X1DGWGuoZgVAFzp6PISWV7lPP2J0r8u1/ucuChBgpK7u1eLGFp6sDIPwXyEUCkI386XSQ==", + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-1.0.11.tgz", + "integrity": "sha512-6zItytTewN37Cjhp3glUg0ozrgW2GwB8x9wtfzUNoJIMmxO38nnGdTLMaotYhRqdf5PP2Dzdmej1HDHXVNUpRw==", "license": "MIT", "dependencies": { - "@expo/config": "~12.0.11", + "@expo/config": "~12.0.13", "expo-json-utils": "~0.15.0" }, "peerDependencies": { @@ -9402,15 +9134,15 @@ } }, "node_modules/expo-manifests/node_modules/@expo/config": { - "version": "12.0.11", - "resolved": "https://registry.npmjs.org/@expo/config/-/config-12.0.11.tgz", - "integrity": "sha512-bGKNCbHirwgFlcOJHXpsAStQvM0nU3cmiobK0o07UkTfcUxl9q9lOQQh2eoMGqpm6Vs1IcwBpYye6thC3Nri/w==", + "version": "12.0.13", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-12.0.13.tgz", + "integrity": "sha512-Cu52arBa4vSaupIWsF0h7F/Cg//N374nYb7HAxV0I4KceKA7x2UXpYaHOL7EEYYvp7tZdThBjvGpVmr8ScIvaQ==", "license": "MIT", "dependencies": { "@babel/code-frame": "~7.10.4", - "@expo/config-plugins": "~54.0.3", - "@expo/config-types": "^54.0.9", - "@expo/json-file": "^10.0.7", + "@expo/config-plugins": "~54.0.4", + "@expo/config-types": "^54.0.10", + "@expo/json-file": "^10.0.8", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^13.0.0", @@ -9423,14 +9155,14 @@ } }, "node_modules/expo-manifests/node_modules/@expo/config-plugins": { - "version": "54.0.3", - "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-54.0.3.tgz", - "integrity": "sha512-tBIUZIxLQfCu5jmqTO+UOeeDUGIB0BbK6xTMkPRObAXRQeTLPPfokZRCo818d2owd+Bcmq1wBaDz0VY3g+glfw==", + "version": "54.0.4", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-54.0.4.tgz", + "integrity": "sha512-g2yXGICdoOw5i3LkQSDxl2Q5AlQCrG7oniu0pCPPO+UxGb7He4AFqSvPSy8HpRUj55io17hT62FTjYRD+d6j3Q==", "license": "MIT", "dependencies": { - "@expo/config-types": "^54.0.9", - "@expo/json-file": "~10.0.7", - "@expo/plist": "^0.4.7", + "@expo/config-types": "^54.0.10", + "@expo/json-file": "~10.0.8", + "@expo/plist": "^0.4.8", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", @@ -9444,22 +9176,60 @@ "xml2js": "0.6.0" } }, + "node_modules/expo-manifests/node_modules/@expo/config-plugins/node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/expo-manifests/node_modules/@expo/config-plugins/node_modules/@expo/json-file": { + "version": "10.0.15", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.15.tgz", + "integrity": "sha512-xLtsy1820Rf2myhhIc7WmfoUg5cWEJB9tEylhgGhRF/acYGuUXUVkKHYoHY31GbYf6CIZNvipTFxuvWRpVlXTw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" + } + }, "node_modules/expo-manifests/node_modules/@expo/config-types": { - "version": "54.0.9", - "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-54.0.9.tgz", - "integrity": "sha512-Llf4jwcrAnrxgE5WCdAOxtMf8FGwS4Sk0SSgI0NnIaSyCnmOCAm80GPFvsK778Oj19Ub4tSyzdqufPyeQPksWw==", + "version": "54.0.10", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-54.0.10.tgz", + "integrity": "sha512-/J16SC2an1LdtCZ67xhSkGXpALYUVUNyZws7v+PVsFZxClYehDSoKLqyRaGkpHlYrCc08bS0RF5E0JV6g50psA==", "license": "MIT" }, "node_modules/expo-manifests/node_modules/@expo/json-file": { - "version": "10.0.8", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.8.tgz", - "integrity": "sha512-9LOTh1PgKizD1VXfGQ88LtDH0lRwq9lsTb4aichWTWSWqy3Ugfkhfm3BhzBIkJJfQQ5iJu3m/BoRlEIjoCGcnQ==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", + "integrity": "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==", "license": "MIT", "dependencies": { - "@babel/code-frame": "~7.10.4", + "@babel/code-frame": "^7.20.0", "json5": "^2.2.3" } }, + "node_modules/expo-manifests/node_modules/@expo/json-file/node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/expo-manifests/node_modules/@expo/plist": { "version": "0.4.8", "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.4.8.tgz", @@ -9481,9 +9251,9 @@ } }, "node_modules/expo-manifests/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -9502,38 +9272,38 @@ } }, "node_modules/expo-manifests/node_modules/glob": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", - "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "license": "BlueOak-1.0.0", "dependencies": { - "minimatch": "^10.1.1", - "minipass": "^7.1.2", - "path-scurry": "^2.0.0" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/expo-manifests/node_modules/lru-cache": { - "version": "11.2.4", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", - "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } }, "node_modules/expo-manifests/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -9543,25 +9313,25 @@ } }, "node_modules/expo-manifests/node_modules/path-scurry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", - "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/expo-manifests/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -9732,9 +9502,9 @@ } }, "node_modules/expo-modules-autolinking": { - "version": "3.0.24", - "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.24.tgz", - "integrity": "sha512-TP+6HTwhL7orDvsz2VzauyQlXJcAWyU3ANsZ7JGL4DQu8XaZv/A41ZchbtAYLfozNA2Ya1Hzmhx65hXryBMjaQ==", + "version": "3.0.25", + "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.25.tgz", + "integrity": "sha512-YmHWctJlwvOuLZccg3cOXvSiXVJrPMKl7g2YR0YHWoGL9v2RvcmgaPJWPSLVW+voNEgEPsbo5UmUrAqbnYcBeg==", "license": "MIT", "dependencies": { "@expo/spawn-async": "^1.7.2", @@ -9748,9 +9518,9 @@ } }, "node_modules/expo-modules-core": { - "version": "3.0.29", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-3.0.29.tgz", - "integrity": "sha512-LzipcjGqk8gvkrOUf7O2mejNWugPkf3lmd9GkqL9WuNyeN2fRwU0Dn77e3ZUKI3k6sI+DNwjkq4Nu9fNN9WS7Q==", + "version": "3.0.30", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-3.0.30.tgz", + "integrity": "sha512-a6IrpAn/Jbmwxi9L+hMmXKpNqnkUpoF7WHOpn02rVLyax2J0gB1vvCVE5rNydplEnt41Q6WxQwvcOjZaIkcSUg==", "license": "MIT", "dependencies": { "invariant": "^2.2.4" @@ -9761,9 +9531,9 @@ } }, "node_modules/expo-notifications": { - "version": "0.32.16", - "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.16.tgz", - "integrity": "sha512-QQD/UA6v7LgvwIJ+tS7tSvqJZkdp0nCSj9MxsDk/jU1GttYdK49/5L2LvE/4U0H7sNBz1NZAyhDZozg8xgBLXw==", + "version": "0.32.17", + "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.17.tgz", + "integrity": "sha512-lwwzn7tImuzTzn9PAglZlS2VfZEvsfFGJTK9Eb8I4cqkGh2DI23YJFJH+WPEIu4QhDvk5JeBjklenJ8IZbmA4A==", "license": "MIT", "dependencies": { "@expo/image-utils": "^0.8.8", @@ -9790,9 +9560,9 @@ } }, "node_modules/expo-server": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz", - "integrity": "sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.6.tgz", + "integrity": "sha512-vb5TBtskvEdzYuW79lATXutOEBfW5m6U4EFpNjCVZTnI7S//SAsLQkYEpn+EDfn84m6VQfzSGkIVR6YPaScKFA==", "license": "MIT", "engines": { "node": ">=20.16.0" @@ -9872,9 +9642,9 @@ } }, "node_modules/expo-updates": { - "version": "29.0.16", - "resolved": "https://registry.npmjs.org/expo-updates/-/expo-updates-29.0.16.tgz", - "integrity": "sha512-E9/fxRz/Eurtc7hxeI/6ZPyHH3To9Xoccm1kXoICZTRojmuTo+dx0Xv53UHyHn4G5zGMezyaKF2Qtj3AKcT93w==", + "version": "29.0.17", + "resolved": "https://registry.npmjs.org/expo-updates/-/expo-updates-29.0.17.tgz", + "integrity": "sha512-9h78cs6Q2rs/dEY7zAgyEm/m6J5rHy8RNpRyhilEAvrzrGLHChVZJT+bSR2RwNJg1DtwUNEjCgZrxDlM7LnNkg==", "license": "MIT", "dependencies": { "@expo/code-signing-certificates": "^0.0.6", @@ -9884,7 +9654,7 @@ "chalk": "^4.1.2", "debug": "^4.3.4", "expo-eas-client": "~1.0.8", - "expo-manifests": "~1.0.10", + "expo-manifests": "~1.0.11", "expo-structured-headers": "~5.0.0", "expo-updates-interface": "~2.0.0", "getenv": "^2.0.0", @@ -9937,9 +9707,9 @@ } }, "node_modules/expo-updates/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -10006,28 +9776,19 @@ } }, "node_modules/expo-web-browser": { - "version": "15.0.10", - "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.10.tgz", - "integrity": "sha512-fvDhW4bhmXAeWFNFiInmsGCK83PAqAcQaFyp/3pE/jbdKmFKoRCWr46uZGIfN4msLK/OODhaQ/+US7GSJNDHJg==", + "version": "15.0.11", + "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.11.tgz", + "integrity": "sha512-r2LS4Ro6DgUPZkcaEfgt8mp9eJuoA93x11Jh7S6utFe0FEzvUNn2yFhxg8XVwESaaHGt2k5V8LuK36rsp0BeIw==", "license": "MIT", "peerDependencies": { "expo": "*", "react-native": "*" } }, - "node_modules/expo/node_modules/@babel/code-frame": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", - "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", - "license": "MIT", - "dependencies": { - "@babel/highlight": "^7.10.4" - } - }, "node_modules/expo/node_modules/@expo/cli": { - "version": "54.0.23", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.23.tgz", - "integrity": "sha512-km0h72SFfQCmVycH/JtPFTVy69w6Lx1cHNDmfLfQqgKFYeeHTjx7LVDP4POHCtNxFP2UeRazrygJhlh4zz498g==", + "version": "54.0.24", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.24.tgz", + "integrity": "sha512-5xse1bEgnVUBhOrtttc6xTNJVvjyTRavpzuF0/0nuj+312vfSbk7EiRbG+xJ2pW/iZxnhLPJkFCrPYG0nmheAQ==", "license": "MIT", "dependencies": { "@0no-co/graphql.web": "^1.0.8", @@ -10039,7 +9800,7 @@ "@expo/image-utils": "^0.8.8", "@expo/json-file": "^10.0.8", "@expo/metro": "~54.2.0", - "@expo/metro-config": "~54.0.14", + "@expo/metro-config": "~54.0.15", "@expo/osascript": "^2.3.8", "@expo/package-manager": "^1.9.10", "@expo/plist": "^0.4.8", @@ -10062,16 +9823,16 @@ "connect": "^3.7.0", "debug": "^4.3.4", "env-editor": "^0.4.1", - "expo-server": "^1.0.5", + "expo-server": "^1.0.6", "freeport-async": "^2.0.0", "getenv": "^2.0.0", "glob": "^13.0.0", - "lan-network": "^0.1.6", + "lan-network": "^0.2.1", "minimatch": "^9.0.0", "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", - "picomatch": "^3.0.1", + "picomatch": "^4.0.3", "pretty-bytes": "^5.6.0", "pretty-format": "^29.7.0", "progress": "^2.0.3", @@ -10154,19 +9915,85 @@ "xml2js": "0.6.0" } }, + "node_modules/expo/node_modules/@expo/config-plugins/node_modules/@expo/json-file": { + "version": "10.0.15", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.15.tgz", + "integrity": "sha512-xLtsy1820Rf2myhhIc7WmfoUg5cWEJB9tEylhgGhRF/acYGuUXUVkKHYoHY31GbYf6CIZNvipTFxuvWRpVlXTw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" + } + }, "node_modules/expo/node_modules/@expo/config-types": { "version": "54.0.10", "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-54.0.10.tgz", "integrity": "sha512-/J16SC2an1LdtCZ67xhSkGXpALYUVUNyZws7v+PVsFZxClYehDSoKLqyRaGkpHlYrCc08bS0RF5E0JV6g50psA==", "license": "MIT" }, + "node_modules/expo/node_modules/@expo/config/node_modules/@babel/code-frame": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", + "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", + "license": "MIT", + "dependencies": { + "@babel/highlight": "^7.10.4" + } + }, "node_modules/expo/node_modules/@expo/json-file": { - "version": "10.0.8", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.8.tgz", - "integrity": "sha512-9LOTh1PgKizD1VXfGQ88LtDH0lRwq9lsTb4aichWTWSWqy3Ugfkhfm3BhzBIkJJfQQ5iJu3m/BoRlEIjoCGcnQ==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", + "integrity": "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==", "license": "MIT", "dependencies": { - "@babel/code-frame": "~7.10.4", + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" + } + }, + "node_modules/expo/node_modules/@expo/metro-config": { + "version": "54.0.15", + "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.15.tgz", + "integrity": "sha512-SqIya4VZ9KHM1S9g+xR0A+QKw1Tfs7Gacx6bQNJ98vs4+O7I5+QP5mHZIB0QSZLUV8opiXebHYTiTu+0OAsIUw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.20.0", + "@babel/generator": "^7.20.5", + "@expo/config": "~12.0.13", + "@expo/env": "~2.0.8", + "@expo/json-file": "~10.0.8", + "@expo/metro": "~54.2.0", + "@expo/spawn-async": "^1.7.2", + "browserslist": "^4.25.0", + "chalk": "^4.1.0", + "debug": "^4.3.2", + "dotenv": "~16.4.5", + "dotenv-expand": "~11.0.6", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "hermes-parser": "^0.29.1", + "jsc-safe-url": "^0.2.4", + "lightningcss": "^1.30.1", + "picomatch": "^4.0.3", + "postcss": "~8.4.32", + "resolve-from": "^5.0.0" + }, + "peerDependencies": { + "expo": "*" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } + } + }, + "node_modules/expo/node_modules/@expo/metro-config/node_modules/@expo/json-file": { + "version": "10.0.15", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.15.tgz", + "integrity": "sha512-xLtsy1820Rf2myhhIc7WmfoUg5cWEJB9tEylhgGhRF/acYGuUXUVkKHYoHY31GbYf6CIZNvipTFxuvWRpVlXTw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", "json5": "^2.2.3" } }, @@ -10375,9 +10202,9 @@ } }, "node_modules/expo/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -10393,6 +10220,21 @@ "node": ">= 6" } }, + "node_modules/expo/node_modules/expo-asset": { + "version": "12.0.13", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.13.tgz", + "integrity": "sha512-x/p7WvQUnkn6K43b9eL6SPeq5Vnf1E8BDe9bDrWrvMqzyUvJnUFvl+ctg3034s/+UHe7Ne2pAmc0+yzbl8CrDQ==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.8.8", + "expo-constants": "~18.0.13" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo/node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", @@ -10420,9 +10262,9 @@ } }, "node_modules/expo/node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -10432,12 +10274,12 @@ } }, "node_modules/expo/node_modules/glob/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -10462,9 +10304,9 @@ } }, "node_modules/expo/node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -10486,22 +10328,10 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/expo/node_modules/picomatch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz", - "integrity": "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/expo/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -10580,9 +10410,9 @@ "peer": true }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -10612,9 +10442,9 @@ } }, "node_modules/fast-xml-parser": { - "version": "5.5.6", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.6.tgz", - "integrity": "sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw==", + "version": "5.5.12", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.12.tgz", + "integrity": "sha512-nUR0q8PPfoA/svPM43Gup7vLOZWppaNrYgGmrVqrAVJa7cOH4hMG6FX9M4mQ8dZA1/ObGZHzES7Ed88hxEBSJg==", "devOptional": true, "funding": [ { @@ -10625,8 +10455,8 @@ "license": "MIT", "dependencies": { "fast-xml-builder": "^1.1.4", - "path-expression-matcher": "^1.1.3", - "strnum": "^2.1.2" + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" @@ -12415,9 +12245,9 @@ } }, "node_modules/jest-config/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "peer": true, "dependencies": { @@ -12846,9 +12676,9 @@ } }, "node_modules/jest-runtime/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "peer": true, "dependencies": { @@ -12968,9 +12798,9 @@ } }, "node_modules/jest-util/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -13394,9 +13224,9 @@ } }, "node_modules/lan-network": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/lan-network/-/lan-network-0.1.7.tgz", - "integrity": "sha512-mnIlAEMu4OyEvUNdzco9xpuB9YVcPkQec+QsgycBCtPZvEqWPCDPfbAE4OJMdBBWpZWtpCn1xw9jJYlwjWI5zQ==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/lan-network/-/lan-network-0.2.1.tgz", + "integrity": "sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==", "license": "MIT", "bin": { "lan-network": "dist/lan-network-cli.js" @@ -13462,9 +13292,9 @@ "license": "MIT" }, "node_modules/lightningcss": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", - "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -13477,23 +13307,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.31.1", - "lightningcss-darwin-arm64": "1.31.1", - "lightningcss-darwin-x64": "1.31.1", - "lightningcss-freebsd-x64": "1.31.1", - "lightningcss-linux-arm-gnueabihf": "1.31.1", - "lightningcss-linux-arm64-gnu": "1.31.1", - "lightningcss-linux-arm64-musl": "1.31.1", - "lightningcss-linux-x64-gnu": "1.31.1", - "lightningcss-linux-x64-musl": "1.31.1", - "lightningcss-win32-arm64-msvc": "1.31.1", - "lightningcss-win32-x64-msvc": "1.31.1" + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", - "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "cpu": [ "arm64" ], @@ -13511,9 +13341,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", - "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ "arm64" ], @@ -13531,9 +13361,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", - "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "cpu": [ "x64" ], @@ -13551,9 +13381,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", - "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "cpu": [ "x64" ], @@ -13571,9 +13401,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", - "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "cpu": [ "arm" ], @@ -13591,12 +13421,15 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", - "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -13611,12 +13444,15 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", - "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -13631,12 +13467,15 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", - "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -13651,12 +13490,15 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", - "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -13671,9 +13513,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", - "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "cpu": [ "arm64" ], @@ -13691,9 +13533,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", - "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" ], @@ -13732,9 +13574,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.debounce": { @@ -14478,9 +14320,9 @@ } }, "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -14615,9 +14457,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "funding": [ { "type": "github", @@ -14684,9 +14526,9 @@ } }, "node_modules/node-forge": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", - "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", "license": "(BSD-3-Clause OR GPL-2.0)", "engines": { "node": ">= 6.13.0" @@ -14743,9 +14585,9 @@ } }, "node_modules/npm-package-arg/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -15250,9 +15092,9 @@ } }, "node_modules/path-expression-matcher": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz", - "integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", "devOptional": true, "funding": [ { @@ -15318,9 +15160,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -16108,9 +15950,9 @@ } }, "node_modules/react-native/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -16258,9 +16100,9 @@ } }, "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "optional": true, "engines": { @@ -16573,9 +16415,9 @@ } }, "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -17529,9 +17371,9 @@ } }, "node_modules/strnum": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", - "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", + "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", "devOptional": true, "funding": [ { @@ -17656,9 +17498,9 @@ } }, "node_modules/tar": { - "version": "7.5.11", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz", - "integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==", + "version": "7.5.15", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", + "integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -17744,9 +17586,9 @@ } }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -18219,9 +18061,9 @@ } }, "node_modules/undici": { - "version": "6.24.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", - "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "version": "6.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", + "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", "license": "MIT", "engines": { "node": ">=18.17" @@ -18649,9 +18491,9 @@ } }, "node_modules/wonka": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.5.tgz", - "integrity": "sha512-SSil+ecw6B4/Dm7Pf2sAshKQ5hWFvfyGlfPbEd6A14dOH6VDjrmbY86u6nZvy9omGwwIPFR8V41+of1EezgoUw==", + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.6.tgz", + "integrity": "sha512-MXH+6mDHAZ2GuMpgKS055FR6v0xVP3XwquxIMYXgiW+FejHQlMGlvVRZT4qMCxR+bEo/FCtIdKxwej9WV3YQag==", "license": "MIT" }, "node_modules/word-wrap": { @@ -18884,15 +18726,18 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "license": "ISC", "bin": { "yaml": "bin.mjs" }, "engines": { "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/yargs": { diff --git a/package.json b/package.json index db684a7..fdf82a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mykhsu", - "version": "2.3.1", + "version": "2.3.2", "license": "LGPL-3.0", "main": "index.js", "scripts": { @@ -15,31 +15,32 @@ "@expo/metro-runtime": "~6.1.2", "@expo/vector-icons": "^15.0.2", "@react-native-async-storage/async-storage": "2.2.0", + "@react-native-community/datetimepicker": "8.4.4", "@react-native-community/netinfo": "11.4.1", "@react-native/gradle-plugin": "^0.81.4", "@sentry/react-native": "~7.2.0", - "expo": "~54.0.33", + "expo": "~54.0.34", "expo-background-fetch": "~14.0.9", "expo-blur": "~15.0.8", "expo-calendar": "~15.0.8", "expo-constants": "~18.0.9", - "expo-dev-client": "~6.0.20", + "expo-dev-client": "~6.0.21", "expo-device": "~8.0.10", - "expo-file-system": "~19.0.16", + "expo-file-system": "~19.0.22", "expo-font": "~14.0.7", "expo-insights": "~0.10.8", - "expo-linking": "~8.0.11", + "expo-linking": "~8.0.12", "expo-location": "~19.0.8", "expo-module-scripts": "^4.1.10", - "expo-notifications": "~0.32.16", + "expo-notifications": "~0.32.17", "expo-secure-store": "~15.0.8", "expo-sharing": "~14.0.8", "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", "expo-system-ui": "~6.0.9", "expo-task-manager": "~14.0.9", - "expo-updates": "~29.0.16", - "expo-web-browser": "~15.0.10", + "expo-updates": "~29.0.17", + "expo-web-browser": "~15.0.11", "react": "19.1.0", "react-dom": "19.1.0", "react-native": "0.81.5", diff --git a/utils/academicEventsStorage.js b/utils/academicEventsStorage.js new file mode 100644 index 0000000..4f6bc21 --- /dev/null +++ b/utils/academicEventsStorage.js @@ -0,0 +1,67 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const ACADEMIC_EVENTS_KEY = 'academic_events_v1'; + +export const ACADEMIC_EVENT_TYPES = [ + { key: 'exam', label: 'Экзамены' }, + { key: 'credit', label: 'Зачеты' }, + { key: 'practice', label: 'Практика' }, + { key: 'holiday', label: 'Каникулы' }, + { key: 'other', label: 'Другое' }, +]; + +const normalizeEvent = (event) => ({ + id: String(event.id || `${Date.now()}_${Math.random().toString(16).slice(2, 8)}`), + title: String(event.title || '').trim(), + date: String(event.date || '').trim(), + type: event.type || 'other', + description: String(event.description || '').trim(), + reminderEnabled: !!event.reminderEnabled, + reminderTime: event.reminderTime || '09:00', + notificationId: event.notificationId || null, + createdAt: Number(event.createdAt || Date.now()), +}); + +const sortEvents = (events) => + [...events].sort((a, b) => { + if (a.date && b.date) return a.date.localeCompare(b.date); + if (a.date && !b.date) return -1; + if (!a.date && b.date) return 1; + return b.createdAt - a.createdAt; + }); + +export const getAcademicEvents = async () => { + try { + const raw = await AsyncStorage.getItem(ACADEMIC_EVENTS_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return sortEvents(parsed.map(normalizeEvent)); + } catch { + return []; + } +}; + +export const saveAcademicEvents = async (events) => { + const normalized = sortEvents((events || []).map(normalizeEvent)); + await AsyncStorage.setItem(ACADEMIC_EVENTS_KEY, JSON.stringify(normalized)); + return normalized; +}; + +export const addAcademicEvent = async (event) => { + const all = await getAcademicEvents(); + const next = normalizeEvent(event); + return saveAcademicEvents([next, ...all]); +}; + +export const updateAcademicEvent = async (id, patch) => { + const all = await getAcademicEvents(); + const updated = all.map((event) => (event.id === id ? normalizeEvent({ ...event, ...patch }) : event)); + return saveAcademicEvents(updated); +}; + +export const deleteAcademicEvent = async (id) => { + const all = await getAcademicEvents(); + const updated = all.filter((event) => event.id !== id); + return saveAcademicEvents(updated); +}; diff --git a/utils/achievements.js b/utils/achievements.js index 31390ad..d686298 100644 --- a/utils/achievements.js +++ b/utils/achievements.js @@ -98,6 +98,50 @@ export const ACHIEVEMENT_DEFINITIONS = { realDescription: 'Ты нашёл то, что не должен был найти. Или наоборот?', realIcon: 'eye', }, + homework_done: { + id: 'homework_done', + title: 'Отличник', + description: 'Отметь домашнее задание как выполненное', + icon: 'checkmark-circle', + color: '#10B981', + rarity: 'common', + }, + free_room_hunter: { + id: 'free_room_hunter', + title: 'Охотник за аудиториями', + description: 'Найди свободную аудиторию', + icon: 'search', + color: '#06B6D4', + rarity: 'uncommon', + }, + export_master: { + id: 'export_master', + title: 'Экспортёр', + description: 'Экспортируй расписание в календарь', + icon: 'calendar', + color: '#3B82F6', + rarity: 'uncommon', + }, + offline_hero: { + id: 'offline_hero', + title: 'Партизан', + description: 'Открой расписание без интернета', + icon: 'cloud-offline', + color: '#6B7280', + rarity: 'uncommon', + }, + snake_champion: { + id: 'snake_champion', + title: '???', + description: 'Найди секрет...', + icon: 'help', + color: '#10B981', + rarity: 'legendary', + secret: true, + realTitle: 'Повелитель змей', + realDescription: 'Ты нашёл пасхалку и набрал 30+ очков в игре!', + realIcon: 'game-controller', + }, }; // Описания редкости @@ -166,6 +210,11 @@ export const getAchievementsCount = async () => { return { unlocked, total }; }; +export const hasUnlockedAllAchievements = async () => { + const { unlocked, total } = await getAchievementsCount(); + return total > 0 && unlocked >= total; +}; + // Инкремент счётчиков для трекинга прогресса const COUNTERS_KEY = 'achievement_counters'; diff --git a/utils/attendanceStorage.js b/utils/attendanceStorage.js new file mode 100644 index 0000000..d736810 --- /dev/null +++ b/utils/attendanceStorage.js @@ -0,0 +1,141 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const ATTENDANCE_PREFIX = 'attendance_'; + +/** Порог пропусков для предупреждения (25%) */ +export const WARN_MISSED_THRESHOLD = 0.25; + +const normalizePart = (value) => String(value || '').trim().toLowerCase().replace(/\s+/g, '_'); + +const buildLegacyKey = (subject, weekday, timeSlot, group) => { + const normalized = [subject, weekday, timeSlot, group] + .map(v => String(v || '').trim().toLowerCase().replace(/\s+/g, '_')) + .join('__'); + return `${ATTENDANCE_PREFIX}${normalized}`; +}; + +const buildKey = (subject, weekday, timeSlot, group, dateISO, lessonType) => { + const normalized = [subject, weekday, timeSlot, group, dateISO, lessonType] + .map(normalizePart) + .join('__'); + return `${ATTENDANCE_PREFIX}${normalized}`; +}; + +/** + * Сохраняет/обновляет отметку посещаемости для занятия + * status: 'attended' | 'missed' | 'excused' + */ +export const saveAttendance = async ({ subject, weekday, timeSlot, group, status, dateISO, lessonType }) => { + const key = buildKey(subject, weekday, timeSlot, group, dateISO, lessonType); + const legacyKey = buildLegacyKey(subject, weekday, timeSlot, group); + const entry = { + subject, + weekday: String(weekday), + timeSlot: String(timeSlot), + group: group || '', + dateISO: dateISO || '', + lessonType: lessonType || '', + status, + markedAt: Date.now(), + version: 2, + }; + await AsyncStorage.setItem(key, JSON.stringify(entry)); + if (legacyKey !== key) { + await AsyncStorage.removeItem(legacyKey); + } + return entry; +}; + +/** + * Получает отметку посещаемости для конкретного занятия + */ +export const getAttendance = async ({ subject, weekday, timeSlot, group, dateISO, lessonType }) => { + try { + const key = buildKey(subject, weekday, timeSlot, group, dateISO, lessonType); + const legacyKey = buildLegacyKey(subject, weekday, timeSlot, group); + const raw = await AsyncStorage.getItem(key) || await AsyncStorage.getItem(legacyKey); + if (!raw) return null; + return JSON.parse(raw); + } catch { + return null; + } +}; + +/** + * Удаляет отметку посещаемости для конкретного занятия + */ +export const removeAttendance = async ({ subject, weekday, timeSlot, group, dateISO, lessonType }) => { + const key = buildKey(subject, weekday, timeSlot, group, dateISO, lessonType); + const legacyKey = buildLegacyKey(subject, weekday, timeSlot, group); + await AsyncStorage.multiRemove(key === legacyKey ? [key] : [key, legacyKey]); +}; + +/** + * Загружает все отметки посещаемости + */ +export const loadAllAttendance = async () => { + try { + const keys = await AsyncStorage.getAllKeys(); + const aKeys = keys.filter(k => k.startsWith(ATTENDANCE_PREFIX)); + if (aKeys.length === 0) return {}; + const pairs = await AsyncStorage.multiGet(aKeys); + const result = {}; + for (const [key, value] of pairs) { + if (value) { + try { result[key] = JSON.parse(value); } catch {} + } + } + return result; + } catch { + return {}; + } +}; + +/** + * Строит ключ для отметки посещаемости (для сопоставления с пакетной загрузкой) + */ +export const buildAttendanceKey = (subject, weekday, timeSlot, group) => + buildKey(subject, weekday, timeSlot, group, '', ''); + +export const buildAttendanceKeyV2 = (subject, weekday, timeSlot, group, dateISO, lessonType) => + buildKey(subject, weekday, timeSlot, group, dateISO, lessonType); + +/** + * Удаляет все отметки посещаемости + */ +export const clearAllAttendance = async () => { + try { + const keys = await AsyncStorage.getAllKeys(); + const aKeys = keys.filter(k => k.startsWith(ATTENDANCE_PREFIX)); + if (aKeys.length > 0) await AsyncStorage.multiRemove(aKeys); + return aKeys.length; + } catch { + return 0; + } +}; + +/** + * Считает статистику посещаемости по предметам из всех отметок + */ +export const getAttendanceStats = (attendanceMap) => { + const subjectMap = {}; + for (const entry of Object.values(attendanceMap)) { + if (!entry || !entry.subject || !entry.dateISO) continue; + const key = entry.subject; + if (!subjectMap[key]) subjectMap[key] = { attended: 0, missed: 0, excused: 0 }; + if (entry.status === 'attended') subjectMap[key].attended++; + else if (entry.status === 'missed') subjectMap[key].missed++; + else if (entry.status === 'excused') subjectMap[key].excused++; + } + return Object.entries(subjectMap).map(([subject, counts]) => { + const total = counts.attended + counts.missed + counts.excused; + const absentRatio = total > 0 ? (counts.missed + counts.excused) / total : 0; + return { + subject, + ...counts, + total, + absentRatio, + isWarning: absentRatio >= WARN_MISSED_THRESHOLD, + }; + }).sort((a, b) => b.missed - a.missed); +}; diff --git a/utils/calendarExport.js b/utils/calendarExport.js index 7ba65c5..bb35d7d 100644 --- a/utils/calendarExport.js +++ b/utils/calendarExport.js @@ -16,6 +16,13 @@ const formatICSDate = (date) => { return `${year}${month}${day}T${hours}${minutes}${seconds}`; }; +const formatICSDateOnly = (date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}${month}${day}`; +}; + // Экранирование текста для ICS const escapeICSText = (text) => { if (!text) return ''; @@ -45,6 +52,13 @@ const buildDescription = (lesson, isTeacher, isAuditory) => { return parts.join('\\n'); }; +const buildAcademicEventDescription = (event) => { + const parts = []; + if (event.typeLabel) parts.push(`Тип: ${event.typeLabel}`); + if (event.description) parts.push(event.description); + return parts.join('\n'); +}; + // Преобразование расписания в массив событий ICS const scheduleTOEvents = (days, weekNumber, pairsTime, options = {}) => { const { isTeacher = false, isAuditory = false } = options; @@ -165,11 +179,16 @@ const generateICSContent = (events, calendarName = 'Расписание MyKHSU' events.forEach(event => { lines.push('BEGIN:VEVENT'); lines.push(`UID:${event.uid}`); - lines.push(`DTSTART:${formatICSDate(event.startDate)}`); - lines.push(`DTEND:${formatICSDate(event.endDate)}`); + if (event.allDay) { + lines.push(`DTSTART;VALUE=DATE:${formatICSDateOnly(event.startDate)}`); + lines.push(`DTEND;VALUE=DATE:${formatICSDateOnly(event.endDate)}`); + } else { + lines.push(`DTSTART:${formatICSDate(event.startDate)}`); + lines.push(`DTEND:${formatICSDate(event.endDate)}`); + } lines.push(`SUMMARY:${escapeICSText(event.summary)}`); if (event.description) { - lines.push(`DESCRIPTION:${event.description}`); + lines.push(`DESCRIPTION:${escapeICSText(event.description)}`); } if (event.location) { lines.push(`LOCATION:${escapeICSText(event.location)}`); @@ -294,6 +313,7 @@ const exportToSystemCalendar = async (events, calendarName) => { title: event.summary, startDate: event.startDate, endDate: event.endDate, + allDay: !!event.allDay, location: event.location || undefined, notes: buildPlainDescription(event), timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, @@ -321,33 +341,26 @@ const exportToICSFile = async (events, calendarName, fileName) => { await Sharing.shareAsync(file.uri, { mimeType: 'text/calendar', - dialogTitle: 'Экспорт расписания', + dialogTitle: 'Экспорт календаря', UTI: 'com.apple.ical.ics', }); }; -/** - * Экспорт расписания — показывает диалог выбора способа экспорта - */ -export const exportScheduleToCalendar = (params) => { +const exportEventsCollectionToCalendar = ({ + events, + calendarName, + fileName, + dialogTitle = 'Экспорт календаря', + emptyErrorCode = 'NO_EVENTS', +}) => { return new Promise((resolve, reject) => { - // Сначала собираем события, чтобы проверить что экспортировать есть что - let collected; - try { - collected = collectEvents(params); - } catch (err) { - return reject(err); - } - - const { events, calendarName, fileName } = collected; - - if (events.length === 0) { - return reject(new Error('NO_EVENTS')); + if (!events || events.length === 0) { + return reject(new Error(emptyErrorCode)); } Alert.alert( - 'Экспорт расписания', - `Найдено занятий: ${events.length}\nКуда экспортировать?`, + dialogTitle, + `Найдено событий: ${events.length}\nКуда экспортировать?`, [ { text: 'Отмена', style: 'cancel', onPress: () => resolve(false) }, { @@ -382,3 +395,48 @@ export const exportScheduleToCalendar = (params) => { ); }); }; + +/** + * Экспорт расписания — показывает диалог выбора способа экспорта + */ +export const exportScheduleToCalendar = (params) => { + const collected = collectEvents(params); + return exportEventsCollectionToCalendar({ + ...collected, + dialogTitle: 'Экспорт расписания', + emptyErrorCode: 'NO_EVENTS', + }); +}; + +export const exportAcademicEventsToCalendar = (academicEvents, options = {}) => { + const { title = 'Учебные события MyKHSU', fileName = 'academic_events' } = options; + + const events = (academicEvents || []).flatMap(event => { + if (!event?.date) return []; + + const [year, month, day] = String(event.date).split('-').map(Number); + const startDate = new Date(year, month - 1, day, 0, 0, 0, 0); + if (Number.isNaN(startDate.getTime())) return []; + + const endDate = new Date(startDate); + endDate.setDate(endDate.getDate() + 1); + + return [{ + uid: generateUID({ subject: event.title || 'Событие', time: event.type || 'event' }, startDate), + startDate, + endDate, + allDay: true, + summary: event.title || 'Учебное событие', + description: buildAcademicEventDescription(event), + location: event.location || '', + }]; + }); + + return exportEventsCollectionToCalendar({ + events, + calendarName: title, + fileName, + dialogTitle: 'Экспорт учебных событий', + emptyErrorCode: 'NO_ACADEMIC_EVENTS', + }); +}; diff --git a/utils/constants.js b/utils/constants.js index d7451af..293e425 100644 --- a/utils/constants.js +++ b/utils/constants.js @@ -39,6 +39,13 @@ export const ACCENT_COLORS = { dark: '#00CC33', glass: 'rgba(0, 255, 65, 0.12)', glassBorder: 'rgba(0, 255, 65, 0.25)', + }, + legend: { + primary: '#FFD666', + light: '#FFF7E0', + dark: '#8A6A1F', + glass: 'rgba(255, 214, 102, 0.14)', + glassBorder: 'rgba(255, 214, 102, 0.28)', } }; @@ -128,15 +135,44 @@ export const LIQUID_GLASS = { // Blur интенсивность blurIntensity: 80, blurTint: 'systemChromeMaterialDark', + }, + legend: { + // Поверхности + surfacePrimary: 'rgba(41, 32, 18, 0.78)', + surfaceSecondary: 'rgba(41, 32, 18, 0.62)', + surfaceTertiary: 'rgba(255, 214, 102, 0.10)', + surfaceCard: 'rgba(41, 32, 18, 0.72)', + // Фон + background: '#120D06', + backgroundSolid: '#120D06', + backgroundElevated: '#1B140A', + // Границы + border: 'rgba(255, 214, 102, 0.18)', + borderStrong: 'rgba(255, 214, 102, 0.34)', + // Текст + text: '#F5E7C4', + textSecondary: '#C8B787', + textTertiary: '#8D7D55', + // Навигация (glass) + headerGlass: 'rgba(30, 22, 12, 0.86)', + tabBarGlass: 'rgba(30, 22, 12, 0.72)', + tabBarBlurIntensity: 60, + tabBarBlurTint: 'systemThinMaterialDark', + // Тени + shadowColor: 'rgba(255, 214, 102, 0.18)', + shadowStrong: 'rgba(255, 214, 102, 0.30)', + // Blur интенсивность + blurIntensity: 80, + blurTint: 'systemChromeMaterialDark', } }; // Версия приложения -export const APP_VERSION = '2.3.1'; +export const APP_VERSION = '2.3.2'; export const APP_DEVELOPERS = 'студентами группы 125-1 в составе команды PRO100BYTE Team'; export const APP_SUPPORTERS = 'ХГУ им. Н.Ф. Катанова и ООО "Скалк Софт"'; -export const BUILD_VER = 'git-efaefa9'; -export const BUILD_DATE = '20.04.2026'; +export const BUILD_VER = 'git-0d44c62'; +export const BUILD_DATE = '25.05.2026'; // Дни недели export const WEEKDAYS = [ @@ -153,7 +189,7 @@ export const WEEKDAYS = [ export const SCREENS = { SCHEDULE: 'Расписание', MAP: 'Карта', - FRESHMAN: 'Первокурснику', + FRESHMAN: 'Первокурснику / Студенту', NEWS: 'Новости', SETTINGS: 'Настройки' }; diff --git a/utils/favoritesStorage.js b/utils/favoritesStorage.js new file mode 100644 index 0000000..a054a7a --- /dev/null +++ b/utils/favoritesStorage.js @@ -0,0 +1,47 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const FAVORITES_KEY = 'schedule_favorites'; + +export const getFavorites = async () => { + try { + const raw = await AsyncStorage.getItem(FAVORITES_KEY); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } +}; + +const saveFavorites = async (list) => { + try { + await AsyncStorage.setItem(FAVORITES_KEY, JSON.stringify(list)); + } catch (e) { + console.error('Error saving favorites:', e); + } +}; + +export const addFavorite = async (favorite) => { + const list = await getFavorites(); + if (list.some(f => f.id === favorite.id)) return false; + await saveFavorites([...list, favorite]); + return true; +}; + +export const removeFavorite = async (id) => { + const list = await getFavorites(); + await saveFavorites(list.filter(f => f.id !== id)); +}; + +export const isFavorite = async (id) => { + const list = await getFavorites(); + return list.some(f => f.id === id); +}; + +/** + * Строит уникальный ID для избранного элемента + */ +export const buildFavoriteId = (type, data) => { + if (type === 'student') return `student__${data.group || ''}`; + if (type === 'teacher') return `teacher__${data.teacherName || ''}`; + if (type === 'auditory') return `auditory__${data.auditoryName || ''}`; + return ''; +}; diff --git a/utils/notesStorage.js b/utils/notesStorage.js index ef726a9..1eb2936 100644 --- a/utils/notesStorage.js +++ b/utils/notesStorage.js @@ -1,6 +1,17 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; const NOTES_PREFIX = 'lesson_note_'; +export const HOMEWORK_STATUSES = { + TODO: 'todo', + IN_PROGRESS: 'in_progress', + DONE: 'done', +}; + +const normalizeHomeworkStatus = (status) => { + if (status === HOMEWORK_STATUSES.IN_PROGRESS) return HOMEWORK_STATUSES.IN_PROGRESS; + if (status === HOMEWORK_STATUSES.DONE) return HOMEWORK_STATUSES.DONE; + return HOMEWORK_STATUSES.TODO; +}; /** * Генерирует уникальный ключ для заметки по параметрам занятия @@ -15,7 +26,17 @@ const getNoteKey = (subject, weekday, timeSlot, group) => { /** * Сохраняет заметку/ДЗ к занятию */ -export const saveNote = async ({ subject, weekday, timeSlot, group, noteText, homework }) => { +export const saveNote = async ({ + subject, + weekday, + timeSlot, + group, + noteText, + homework, + homeworkStatus, + homeworkDueDate, + homeworkReminderNotificationId, +}) => { const key = getNoteKey(subject, weekday, timeSlot, group); // Загружаем существующую заметку, чтобы сохранить homeworkTargetDate при обычном редактировании let existing = null; @@ -25,9 +46,21 @@ export const saveNote = async ({ subject, weekday, timeSlot, group, noteText, ho } catch {} const oldHomework = existing?.homework || ''; const newHomework = homework || ''; + const resolvedStatus = newHomework + ? normalizeHomeworkStatus(homeworkStatus || existing?.homeworkStatus) + : null; + // homeworkDueDate === null означает явную очистку дедлайна; undefined — поле не передавалось (используем existing) + const resolvedDueDate = newHomework + ? (homeworkDueDate !== undefined ? homeworkDueDate : (existing?.homeworkDueDate ?? null)) + : null; const data = { noteText: noteText || '', homework: newHomework, + homeworkStatus: resolvedStatus, + homeworkDueDate: resolvedDueDate, + homeworkReminderNotificationId: resolvedDueDate + ? (homeworkReminderNotificationId || existing?.homeworkReminderNotificationId || null) + : null, updatedAt: Date.now(), // Сбрасываем targetDate только если ДЗ изменилось (новое/обновлённое) homeworkTargetDate: (newHomework && newHomework !== oldHomework) ? null : (existing?.homeworkTargetDate || null), @@ -131,6 +164,8 @@ export const findHomeworkBySubject = (allNotes, subject, group) => { latestTimestamp = note.updatedAt; latestResult = { homework: note.homework, + homeworkStatus: normalizeHomeworkStatus(note.homeworkStatus), + homeworkDueDate: note.homeworkDueDate || null, updatedAt: note.updatedAt, homeworkTargetDate: note.homeworkTargetDate || null, sourceKey: key, @@ -141,6 +176,58 @@ export const findHomeworkBySubject = (allNotes, subject, group) => { return latestResult; }; +const parseNoteKey = (noteKey) => { + const parts = noteKey.replace(NOTES_PREFIX, '').split('__'); + if (parts.length < 4) return null; + const [subject, weekday, timeSlot, group] = parts; + return { + subject: String(subject || '').replace(/_/g, ' '), + weekday: Number(weekday) || null, + timeSlot: String(timeSlot || ''), + group: String(group || '').replace(/_/g, ' '), + }; +}; + +export const getAllHomeworkDeadlines = async () => { + const all = await loadAllNotes(); + const _now = new Date(); + const todayISO = `${_now.getFullYear()}-${String(_now.getMonth() + 1).padStart(2, '0')}-${String(_now.getDate()).padStart(2, '0')}`; + const deadlines = []; + + for (const [key, note] of Object.entries(all)) { + if (!note || !note.homework) continue; + const parsed = parseNoteKey(key); + if (!parsed) continue; + const status = normalizeHomeworkStatus(note.homeworkStatus); + const dueDate = note.homeworkDueDate || null; + const isOverdue = !!dueDate && status !== HOMEWORK_STATUSES.DONE && dueDate < todayISO; + + deadlines.push({ + id: key, + subject: parsed.subject, + group: parsed.group, + weekday: parsed.weekday, + timeSlot: parsed.timeSlot, + homework: note.homework, + status, + dueDate, + isOverdue, + updatedAt: note.updatedAt || Date.now(), + }); + } + + return deadlines.sort((a, b) => { + if (a.status !== b.status) { + if (a.status === HOMEWORK_STATUSES.DONE) return 1; + if (b.status === HOMEWORK_STATUSES.DONE) return -1; + } + if (a.dueDate && b.dueDate) return a.dueDate.localeCompare(b.dueDate); + if (a.dueDate && !b.dueDate) return -1; + if (!a.dueDate && b.dueDate) return 1; + return b.updatedAt - a.updatedAt; + }); +}; + /** * Помечает ДЗ как "доставленное" на конкретную дату. * Записывает homeworkTargetDate в исходную заметку. diff --git a/utils/notificationService.js b/utils/notificationService.js index 364bd16..2f2335e 100644 --- a/utils/notificationService.js +++ b/utils/notificationService.js @@ -2,9 +2,15 @@ import * as Notifications from 'expo-notifications'; import * as Device from 'expo-device'; import { Platform } from 'react-native'; import * as SecureStore from 'expo-secure-store'; +import AsyncStorage from '@react-native-async-storage/async-storage'; import { getWithExpiry, setWithExpiry } from './cache'; import { getDateByWeekAndDay } from './dateUtils'; +const SCHEDULE_CHANGES_HISTORY_KEY = 'schedule_changes_history_v1'; +const SCHEDULE_CHANGES_HISTORY_DAYS = 7; +const SCHEDULE_CHANGES_HISTORY_LIMIT = 200; +const SCHEDULE_NOTIFICATION_IDS_KEY = 'schedule_notification_ids'; + // Конфигурация уведомлений Notifications.setNotificationHandler({ handleNotification: async () => ({ @@ -37,6 +43,7 @@ class NotificationService { enabled: false, news: false, schedule: false, + scheduleChanges: true, beforeLesson: true, lessonStart: true, beforeLessonEnd: true, @@ -101,8 +108,14 @@ class NotificationService { } } - // Отменяем предыдущие уведомления о парах - await Notifications.cancelAllScheduledNotificationsAsync(); + // Отменяем предыдущие уведомления о парах (только schedule-уведомления, не трогая homework_deadline/academic_event) + try { + const prevIdsRaw = await AsyncStorage.getItem(SCHEDULE_NOTIFICATION_IDS_KEY); + const prevIds = prevIdsRaw ? JSON.parse(prevIdsRaw) : []; + for (const id of prevIds) { + try { await Notifications.cancelScheduledNotificationAsync(id); } catch {} + } + } catch {} const notifications = []; const now = new Date(); @@ -155,16 +168,21 @@ class NotificationService { } } - // Планируем все уведомления + // Планируем все уведомления и сохраняем их ID let scheduled = 0; + const newNotificationIds = []; for (const notification of notifications) { try { - await Notifications.scheduleNotificationAsync(notification); + const notifId = await Notifications.scheduleNotificationAsync(notification); + newNotificationIds.push(notifId); scheduled++; } catch (e) { console.error('Error scheduling notification:', e); } } + try { + await AsyncStorage.setItem(SCHEDULE_NOTIFICATION_IDS_KEY, JSON.stringify(newNotificationIds)); + } catch {} console.log(`Запланировано ${scheduled} уведомлений о парах`); } catch (error) { @@ -372,6 +390,202 @@ class NotificationService { return newsItem.date; } + /** + * Проверяет изменения в расписании относительно предыдущего сохранённого снимка. + * При обнаружении изменений отправляет push-уведомление. + * @param {object} newScheduleData — обработанные данные расписания + * @param {string} scheduleKey — уникальный ключ (имя группы / преподавателя / аудитории) + */ + async checkScheduleChanges(newScheduleData, scheduleKey, options = {}) { + try { + if (!newScheduleData || !scheduleKey) return; + + const settings = await this.getNotificationSettings(); + const scheduleChangesEnabled = settings.scheduleChanges !== false; + if (!settings.enabled || !settings.schedule || !scheduleChangesEnabled) return; + + // Для кэша и устаревшего кэша не проверяем изменения, чтобы не спамить уведомлениями. + if (options.source === 'cache' || options.source === 'stale_cache') return; + + // Формируем компактный снимок занятий + const days = newScheduleData.days || (newScheduleData.lessons ? [{ weekday: 0, lessons: newScheduleData.lessons }] : []); + if (days.length === 0) return; + + const normalizedDays = days + .map(d => ({ + weekday: d.weekday, + lessons: (d.lessons || []) + .map(l => l ? { + time: String(l.time ?? ''), + subject: l.subject || '', + teacher: l.teacher || '', + auditory: l.auditory || '', + } : null) + .filter(Boolean) + .sort((a, b) => Number(a.time) - Number(b.time)), + })) + .filter(d => d.weekday != null) + .sort((a, b) => a.weekday - b.weekday); + + const newSnapshot = JSON.stringify(normalizedDays); + + const snapshotKey = `schedule_snapshot_${scheduleKey}`; + const prevSnapshot = await AsyncStorage.getItem(snapshotKey); + + // Всегда сохраняем актуальный снимок + await AsyncStorage.setItem(snapshotKey, newSnapshot); + + if (!prevSnapshot) return; // первая загрузка — не с чем сравнивать + if (prevSnapshot === newSnapshot) return; // изменений нет + + const prevDays = JSON.parse(prevSnapshot); + const newDays = JSON.parse(newSnapshot); + const changes = this.detectScheduleChanges(prevDays, newDays); + + if (changes.length > 0) { + await this.appendScheduleChangeHistory(changes, scheduleKey); + await this.sendScheduleChangeNotification(changes); + } + } catch (error) { + console.error('Error checking schedule changes:', error); + } + } + + async appendScheduleChangeHistory(changes, scheduleKey) { + try { + const now = Date.now(); + const raw = await AsyncStorage.getItem(SCHEDULE_CHANGES_HISTORY_KEY); + const current = raw ? JSON.parse(raw) : []; + const nextEntries = (changes || []).map((change) => ({ + id: `${now}_${Math.random().toString(16).slice(2, 8)}`, + timestamp: now, + scheduleKey, + type: change.type, + weekday: change.weekday, + lesson: change.lesson || null, + prev: change.prev || null, + })); + + const minTs = now - SCHEDULE_CHANGES_HISTORY_DAYS * 24 * 60 * 60 * 1000; + const merged = [...nextEntries, ...(Array.isArray(current) ? current : [])] + .filter((entry) => Number(entry?.timestamp || 0) >= minTs) + .slice(0, SCHEDULE_CHANGES_HISTORY_LIMIT); + + await AsyncStorage.setItem(SCHEDULE_CHANGES_HISTORY_KEY, JSON.stringify(merged)); + } catch (error) { + console.error('Error appending schedule history:', error); + } + } + + async getScheduleChangesHistory(days = SCHEDULE_CHANGES_HISTORY_DAYS) { + try { + const raw = await AsyncStorage.getItem(SCHEDULE_CHANGES_HISTORY_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + const minTs = Date.now() - Number(days || SCHEDULE_CHANGES_HISTORY_DAYS) * 24 * 60 * 60 * 1000; + return parsed + .filter((entry) => Number(entry?.timestamp || 0) >= minTs) + .sort((a, b) => Number(b?.timestamp || 0) - Number(a?.timestamp || 0)); + } catch { + return []; + } + } + + detectScheduleChanges(prevDays, newDays) { + const changes = []; + const buildDayMap = (days) => { + const map = new Map(); + for (const day of days || []) { + if (!day || day.weekday == null) continue; + const lessonsMap = new Map(); + for (const lesson of (day.lessons || [])) { + if (!lesson || lesson.time == null) continue; + lessonsMap.set(String(lesson.time), lesson); + } + map.set(day.weekday, lessonsMap); + } + return map; + }; + + const prevByDay = buildDayMap(prevDays); + const nextByDay = buildDayMap(newDays); + + // Сравниваем только общие дни, чтобы переключение между днями не считалось изменением расписания. + const commonWeekdays = [...nextByDay.keys()].filter(weekday => prevByDay.has(weekday)); + if (commonWeekdays.length === 0) return changes; + + for (const weekday of commonWeekdays) { + const prevLessons = prevByDay.get(weekday); + const nextLessons = nextByDay.get(weekday); + + for (const [time, nextLesson] of nextLessons.entries()) { + const prevLesson = prevLessons.get(time); + if (!prevLesson) { + changes.push({ type: 'added', lesson: nextLesson, weekday }); + continue; + } + + if ( + (prevLesson.subject || '') !== (nextLesson.subject || '') || + (prevLesson.teacher || '') !== (nextLesson.teacher || '') || + (prevLesson.auditory || '') !== (nextLesson.auditory || '') + ) { + changes.push({ type: 'changed', lesson: nextLesson, prev: prevLesson, weekday }); + } + } + + for (const [time, prevLesson] of prevLessons.entries()) { + if (!nextLessons.has(time)) { + changes.push({ type: 'removed', lesson: prevLesson, weekday }); + } + } + } + + return changes; + } + + async sendScheduleChangeNotification(changes) { + try { + if (!this.isConfigured) { + const granted = await this.requestPermissions(); + if (!granted) return; + } + const channelId = Platform.OS === 'android' ? 'schedule' : undefined; + const wdays = ['', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']; + const change = changes[0]; + let body = ''; + if (change.type === 'changed') { + const parts = []; + if (change.prev.auditory !== change.lesson.auditory) + parts.push(`ауд. ${change.lesson.auditory || 'не указана'}`); + if (change.prev.teacher !== change.lesson.teacher) + parts.push(`преп. ${change.lesson.teacher || 'не указан'}`); + if (change.prev.subject !== change.lesson.subject) + parts.push(`предмет изменён`); + body = `Пара ${change.lesson.time} (${wdays[change.weekday] || ''}): ${change.lesson.subject}. Изм.: ${parts.join(', ')}.`; + } else if (change.type === 'added') { + body = `Добавлена пара ${change.lesson.time} (${wdays[change.weekday] || ''}): ${change.lesson.subject}.`; + } else if (change.type === 'removed') { + body = `Отменена пара ${change.lesson.time} (${wdays[change.weekday] || ''}): ${change.lesson.subject}.`; + } + if (!body) return; + const suffix = changes.length > 1 ? ` (+${changes.length - 1} др.)` : ''; + await Notifications.scheduleNotificationAsync({ + content: { + title: '📋 Изменение в расписании', + body: body + suffix, + data: { type: 'schedule_change' }, + sound: true, + ...(channelId && { channelId }), + }, + trigger: null, + }); + } catch (error) { + console.error('Error sending schedule change notification:', error); + } + } + async initialize() { try { await this.requestPermissions(); diff --git a/utils/studyProfileStorage.js b/utils/studyProfileStorage.js new file mode 100644 index 0000000..bdedac2 --- /dev/null +++ b/utils/studyProfileStorage.js @@ -0,0 +1,31 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const STUDY_PROFILE_KEY = 'study_profile_v1'; + +const defaultProfile = { + targetAttendance: 85, + weeklyStudyTargetHours: 12, +}; + +export const loadStudyProfile = async () => { + try { + const raw = await AsyncStorage.getItem(STUDY_PROFILE_KEY); + if (!raw) return defaultProfile; + const parsed = JSON.parse(raw); + return { + ...defaultProfile, + ...parsed, + }; + } catch { + return defaultProfile; + } +}; + +export const saveStudyProfile = async (profile) => { + const next = { + ...defaultProfile, + ...(profile || {}), + }; + await AsyncStorage.setItem(STUDY_PROFILE_KEY, JSON.stringify(next)); + return next; +};