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