Add date and time handling, models, schedule management, storage, and widget support
- Introduced `Dates.swift` for handling date and time in the university's timezone. - Created `Models.swift` to define data structures for timetable, lessons, and directory search results. - Implemented `Schedule.swift` for managing lesson slots and timetable functionalities. - Developed `Storage.swift` for user settings and data persistence. - Added `Widgets.swift` to create widgets displaying lessons and upcoming classes.
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,3 +3,6 @@ dist/
|
||||
.data/
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
*.xcuserstate
|
||||
|
||||
30
README.md
30
README.md
@@ -131,6 +131,32 @@ cd android
|
||||
|
||||
Отладочная сборка ходит на `http://10.0.2.2:3010/` — это сервер из `docker compose` на компьютере, видимый из эмулятора (`APP_PORT=3010`). Другой адрес: `-PPSU_API_URL=…`. Релизная сборка принимает только HTTPS-адрес. Идентификатор приложения `ritm.schedule` — рабочий, перед публикацией его стоит заменить своим.
|
||||
|
||||
## iOS-приложение
|
||||
|
||||
В папке `ios/` — нативный клиент на SwiftUI для iOS 26 и новее с Liquid Glass. Функции те же, что у Android-приложения, данные — из того же API. Проект `ios/PSUSchedule.xcodeproj` открывается в Xcode 27; папки подключены синхронизированными группами, поэтому новые файлы попадают в сборку сами.
|
||||
|
||||
- **Онбординг:** приветствие (три стеклянные «капли» сливаются в одну), затем группа, подгруппа (только если группа делится на подгруппы) и напоминания. Разрешение на уведомления спрашивается на последнем шаге.
|
||||
- **Стеклянная панель вкладок:** три раздела — «Обзор», «Расписание», «Планы» — и поиск отдельной круглой кнопкой справа (роль `.search`). Нажатие превращает кнопку в поле ввода над клавиатурой, как в приложениях Apple (`tabViewSearchActivation(.searchTabSelection)`). «Календарь» открывается кнопкой в «Планах», «Предметы» — карточкой в «Обзоре». На iPad панель превращается в боковое меню. Группа и настройки открываются из панели «Обзора»; тема меняется сразу во всех окнах, включая открытые настройки.
|
||||
- **Обзор:** неделя (сколько пар осталось, чип на каждый день), обратный отсчёт до сессии или конца семестра, следующие занятия с отсчётом «Через 20 мин», посещаемость одним касанием, задания и ближайший учебный день. На iPad рядом показывается недельная сетка.
|
||||
- **Расписание:** дни листаются в сторону под плавающей стеклянной лентой недели. На зелёных неделях стекло окрашено зелёным. Есть недельная сетка по часам и строки Word, если семестр не найден. В подробностях пары: время по звонкам, посещаемость, задания, исходная запись Word и скрытие таких занятий.
|
||||
- **Планы:** три макета — повестка дня с лентой недели, список и доска. Можно показывать пары дня и выполненные задания. Кнопка «+» — стеклянная.
|
||||
- **Календарь:** месяц с отметками пар и планов, зелёные недели подсвечены; ниже — план выбранного дня.
|
||||
- **Предметы:** нагрузка недели на диаграмме Swift Charts; у каждого предмета — следующее занятие, прогресс недели, посещаемость и задания.
|
||||
- **Поиск «Кто где»:** занятия преподавателя или аудитории на день по всем документам каталога.
|
||||
- **Виджеты WidgetKit:** «Сегодня» (малый, средний, большой) и «Следующая пара» (малый и на экране блокировки). Таймлайн обновляется в начале и конце каждой пары; нажатие открывает день в приложении (`psuschedule://day/ГГГГ-ММ-ДД`).
|
||||
- **Офлайн:** расписание, экзамены, задания, посещаемость и настройки хранятся в общей папке App Group `group.org.xtimms.psu.schedule`, её читают и виджеты.
|
||||
- Напоминания — локальные уведомления на неделю вперёд (до 48).
|
||||
- Раз в несколько часов `BGAppRefreshTask` обновляет расписание.
|
||||
- Ответы сервера кэшируются `URLCache`: неизменённый семестр приходит пустым `304`.
|
||||
|
||||
Адрес сервера задаётся ключом `PSUAPIURL` в `ios/Config/App-Info.plist` (по умолчанию `https://psu.rumblur.by/`). В Debug-сборке его можно заменить переменной окружения `PSU_API_URL`, например для локального сервера на этом Mac:
|
||||
|
||||
```sh
|
||||
SIMCTL_CHILD_PSU_API_URL=http://localhost:3010/ xcrun simctl launch booted org.xtimms.psu.schedule
|
||||
```
|
||||
|
||||
Для установки на устройство в Xcode нужно выбрать команду разработчика и зарегистрировать App Group.
|
||||
|
||||
## Структура
|
||||
|
||||
- `src/App.tsx` — интерфейс, каталог и импорт с предпросмотром.
|
||||
@@ -146,6 +172,10 @@ cd android
|
||||
- `server/calendar.js` — разбор таблицы недель и расписания звонков.
|
||||
- `server/parser.js` — таблицы `.docx`, условия и преобразование `.doc`.
|
||||
- `server/index.js` — API и выдача собранной PWA.
|
||||
- `server/limits.js` — ограничения импорта: число одновременных разборов и запросов с одного адреса.
|
||||
- `ios/Shared/` — модели, даты в часовом поясе Минска, расчёт пар и общее хранилище; общие для приложения и виджетов.
|
||||
- `ios/PSUSchedule/` — приложение iOS: `AppModel.swift` (состояние и загрузка), `API.swift`, `Reminders.swift`, экраны в `Views/`.
|
||||
- `ios/Widgets/` — виджеты WidgetKit.
|
||||
- `scripts/serviceworker.js` — генерация полного офлайн-кэша при сборке.
|
||||
|
||||
Это независимый клиент; университет остаётся источником расписания. Шрифт Roboto Flex поставляется локально под SIL Open Font License (`public/fonts/OFL-RobotoFlex.txt`).
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
32
ios/Config/App-Info.plist
Normal file
32
ios/Config/App-Info.plist
Normal file
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>org.xtimms.psu.schedule.refresh</string>
|
||||
</array>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>org.xtimms.psu.schedule</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>psuschedule</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>PSUAPIURL</key>
|
||||
<string>https://psu.rumblur.by/</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
11
ios/Config/App.entitlements
Normal file
11
ios/Config/App.entitlements
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- The app and its widgets share the saved timetable, notes and settings. -->
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.org.xtimms.psu.schedule</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
1
ios/Config/AppIcon-Dark.svg
Normal file
1
ios/Config/AppIcon-Dark.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 512 512"><rect width="512" height="512" fill="#0c0e13"/><path d="M142 172h228v200H142z" fill="none" stroke="#9ecaff" stroke-width="24" stroke-linejoin="round"/><path d="M142 226h228M204 146v48M308 146v48" fill="none" stroke="#9ecaff" stroke-width="24" stroke-linecap="round"/><path d="m205 296 35 35 69-71" fill="none" stroke="#81c784" stroke-width="25" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
|
After Width: | Height: | Size: 489 B |
1
ios/Config/AppIcon.svg
Normal file
1
ios/Config/AppIcon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 512 512"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#1a7fd1"/><stop offset="1" stop-color="#004f87"/></linearGradient></defs><rect width="512" height="512" fill="url(#g)"/><path d="M142 172h228v200H142z" fill="none" stroke="#fff" stroke-width="24" stroke-linejoin="round"/><path d="M142 226h228M204 146v48M308 146v48" fill="none" stroke="#fff" stroke-width="24" stroke-linecap="round"/><path d="m205 296 35 35 69-71" fill="none" stroke="#c8efb8" stroke-width="25" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
|
After Width: | Height: | Size: 642 B |
11
ios/Config/Widgets-Info.plist
Normal file
11
ios/Config/Widgets-Info.plist
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
11
ios/Config/Widgets.entitlements
Normal file
11
ios/Config/Widgets.entitlements
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- The app and its widgets share the saved timetable, notes and settings. -->
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.org.xtimms.psu.schedule</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
420
ios/PSUSchedule.xcodeproj/project.pbxproj
Normal file
420
ios/PSUSchedule.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,420 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
4D46DD4AF5CDCC2545A8EF3B /* PSUScheduleWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 1C7052EC5FC02E5BD365EC48 /* PSUScheduleWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
431387EB7262E1CFC79B125E /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 4DC844ABCCFC0D132D087C1C /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = E8173D1080A28ECDFD3BF6B2;
|
||||
remoteInfo = PSUScheduleWidgets;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
CF85AC55444E92E2A151D05E /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
4D46DD4AF5CDCC2545A8EF3B /* PSUScheduleWidgets.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
1C7052EC5FC02E5BD365EC48 /* PSUScheduleWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = PSUScheduleWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
81918909E83595C8F4212CCC /* PSUSchedule.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PSUSchedule.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
6CB77480E0F0214B7AC1764B /* PSUSchedule */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = PSUSchedule;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9D254543540250A73DA109E9 /* Shared */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = Shared;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
EB8CA8D1CCE6CA70621B7C5C /* Widgets */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = Widgets;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
0248D6DB510EB0F7E9C05175 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
C2B571F11AF4AD9F122E48C3 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
86024CAD1E83101D97359D73 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
81918909E83595C8F4212CCC /* PSUSchedule.app */,
|
||||
1C7052EC5FC02E5BD365EC48 /* PSUScheduleWidgets.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FAD58DE7366495DB4650CFEF = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
6CB77480E0F0214B7AC1764B /* PSUSchedule */,
|
||||
9D254543540250A73DA109E9 /* Shared */,
|
||||
EB8CA8D1CCE6CA70621B7C5C /* Widgets */,
|
||||
86024CAD1E83101D97359D73 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
3D4C91345015A4DD0A099400 /* PSUSchedule */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 780754F98F0CCAA36D4D0A49 /* Build configuration list for PBXNativeTarget "PSUSchedule" */;
|
||||
buildPhases = (
|
||||
DA10D9DD089DF2AFEBC5ED5A /* Sources */,
|
||||
0248D6DB510EB0F7E9C05175 /* Frameworks */,
|
||||
82A968CB662751220D4DFE2D /* Resources */,
|
||||
CF85AC55444E92E2A151D05E /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
E54DEBD65D8142E7785275AA /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
6CB77480E0F0214B7AC1764B /* PSUSchedule */,
|
||||
9D254543540250A73DA109E9 /* Shared */,
|
||||
);
|
||||
name = PSUSchedule;
|
||||
productName = PSUSchedule;
|
||||
productReference = 81918909E83595C8F4212CCC /* PSUSchedule.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
E8173D1080A28ECDFD3BF6B2 /* PSUScheduleWidgets */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = E5E361A19AF73B31E4F0D905 /* Build configuration list for PBXNativeTarget "PSUScheduleWidgets" */;
|
||||
buildPhases = (
|
||||
C861C1B2713F1DA729267477 /* Sources */,
|
||||
C2B571F11AF4AD9F122E48C3 /* Frameworks */,
|
||||
3EB9B1A63CB0AF905AC09EAB /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
9D254543540250A73DA109E9 /* Shared */,
|
||||
EB8CA8D1CCE6CA70621B7C5C /* Widgets */,
|
||||
);
|
||||
name = PSUScheduleWidgets;
|
||||
productName = PSUScheduleWidgets;
|
||||
productReference = 1C7052EC5FC02E5BD365EC48 /* PSUScheduleWidgets.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
4DC844ABCCFC0D132D087C1C /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2710;
|
||||
LastUpgradeCheck = 2710;
|
||||
TargetAttributes = {
|
||||
3D4C91345015A4DD0A099400 = {
|
||||
CreatedOnToolsVersion = 27.1;
|
||||
};
|
||||
E8173D1080A28ECDFD3BF6B2 = {
|
||||
CreatedOnToolsVersion = 27.1;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 71371EFA15DA3BB408E93DEB /* Build configuration list for PBXProject "PSUSchedule" */;
|
||||
developmentRegion = ru;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
ru,
|
||||
Base,
|
||||
);
|
||||
mainGroup = FAD58DE7366495DB4650CFEF;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 86024CAD1E83101D97359D73 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
3D4C91345015A4DD0A099400 /* PSUSchedule */,
|
||||
E8173D1080A28ECDFD3BF6B2 /* PSUScheduleWidgets */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
3EB9B1A63CB0AF905AC09EAB /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
82A968CB662751220D4DFE2D /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
C861C1B2713F1DA729267477 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
DA10D9DD089DF2AFEBC5ED5A /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
E54DEBD65D8142E7785275AA /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = E8173D1080A28ECDFD3BF6B2 /* PSUScheduleWidgets */;
|
||||
targetProxy = 431387EB7262E1CFC79B125E /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
191734982F22ABEBB64A1AE0 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = F753S4PN2W;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Расписание ПГУ";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.xtimms.psu.schedule;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
2D74CA302FF4E1F0293F50B2 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = Config/Widgets.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = F753S4PN2W;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "Config/Widgets-Info.plist";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Расписание ПГУ";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.xtimms.psu.schedule.widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
72FB3ED29B4639D83EC5DB14 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = F753S4PN2W;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Расписание ПГУ";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.xtimms.psu.schedule;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
999D00FC968E12EBDA6FF541 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = Config/Widgets.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = F753S4PN2W;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "Config/Widgets-Info.plist";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Расписание ПГУ";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.xtimms.psu.schedule.widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
FA9139DD8B03E3F922556BF8 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
FFE2104CCFAB79205AC64460 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_VERSION = 5.0;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
71371EFA15DA3BB408E93DEB /* Build configuration list for PBXProject "PSUSchedule" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
FA9139DD8B03E3F922556BF8 /* Debug */,
|
||||
FFE2104CCFAB79205AC64460 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
780754F98F0CCAA36D4D0A49 /* Build configuration list for PBXNativeTarget "PSUSchedule" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
72FB3ED29B4639D83EC5DB14 /* Debug */,
|
||||
191734982F22ABEBB64A1AE0 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
E5E361A19AF73B31E4F0D905 /* Build configuration list for PBXNativeTarget "PSUScheduleWidgets" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
2D74CA302FF4E1F0293F50B2 /* Debug */,
|
||||
999D00FC968E12EBDA6FF541 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 4DC844ABCCFC0D132D087C1C /* Project object */;
|
||||
}
|
||||
7
ios/PSUSchedule.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
ios/PSUSchedule.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme LastUpgradeVersion = "2710" version = "1.7">
|
||||
<BuildAction parallelizeBuildables = "YES" buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry buildForTesting = "YES" buildForRunning = "YES" buildForProfiling = "YES" buildForArchiving = "YES" buildForAnalyzing = "YES">
|
||||
<BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "3D4C91345015A4DD0A099400" BuildableName = "PSUSchedule.app" BlueprintName = "PSUSchedule" ReferencedContainer = "container:PSUSchedule.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable runnableDebuggingMode = "0">
|
||||
<BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "3D4C91345015A4DD0A099400" BuildableName = "PSUSchedule.app" BlueprintName = "PSUSchedule" ReferencedContainer = "container:PSUSchedule.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction buildConfiguration = "Release" shouldUseLaunchSchemeArgsEnv = "YES" savedToolIdentifier = "" useCustomWorkingDirectory = "NO" debugDocumentVersioning = "YES">
|
||||
</ProfileAction>
|
||||
<AnalyzeAction buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction buildConfiguration = "Release" revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
113
ios/PSUSchedule/API.swift
Normal file
113
ios/PSUSchedule/API.swift
Normal file
@@ -0,0 +1,113 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
/// An error with a message ready to show; `offline` when the server could not be reached.
|
||||
struct APIError: LocalizedError {
|
||||
var message: String
|
||||
var offline = false
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
/// The schedule server's HTTP API, as the Android client's Api.kt.
|
||||
struct API: Sendable {
|
||||
let base: URL
|
||||
|
||||
/// The server address: Info.plist's PSUAPIURL; Debug builds may set PSU_API_URL instead.
|
||||
static let shared: API = {
|
||||
#if DEBUG
|
||||
if let env = ProcessInfo.processInfo.environment["PSU_API_URL"], let url = URL(string: env) {
|
||||
return API(base: url)
|
||||
}
|
||||
#endif
|
||||
let plist = Bundle.main.object(forInfoDictionaryKey: "PSUAPIURL") as? String
|
||||
return API(base: URL(string: plist ?? "https://psu.rumblur.by/")!)
|
||||
}()
|
||||
|
||||
private static let log = Logger(subsystem: "org.xtimms.psu.schedule", category: "API")
|
||||
private static let offline = "Нет связи с сервером расписания."
|
||||
private static let retries = 3
|
||||
|
||||
// The server answers "no-cache" with an ETag: URLSession keeps the answer and asks again
|
||||
// with If-None-Match, so an unchanged semester comes back as an empty 304.
|
||||
private static let session: URLSession = {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = 60
|
||||
config.urlCache = URLCache(memoryCapacity: 2 << 20, diskCapacity: 10 << 20)
|
||||
config.requestCachePolicy = .useProtocolCachePolicy
|
||||
return URLSession(configuration: config)
|
||||
}()
|
||||
|
||||
private func get<T: Decodable & Sendable>(_ path: String, _ query: [(String, String?)] = []) async throws -> T {
|
||||
var components = URLComponents(url: base.appendingPathComponent(path), resolvingAgainstBaseURL: false)!
|
||||
let items = query.compactMap { name, value in value.flatMap { $0.isEmpty ? nil : URLQueryItem(name: name, value: $0) } }
|
||||
if !items.isEmpty { components.queryItems = items }
|
||||
let url = components.url!
|
||||
let (data, response) = try await withRetry(url)
|
||||
guard let http = response as? HTTPURLResponse else { throw APIError(message: Self.offline, offline: true) }
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
let message = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["error"] as? String
|
||||
// The server's catch-all 404 means it runs an older version without this endpoint.
|
||||
if http.statusCode == 404 && message == "Адрес не найден." {
|
||||
throw APIError(message: "Сервер расписания не знает запрос /\(path) — на нём старая версия. Обновите сервер.")
|
||||
}
|
||||
throw APIError(message: message ?? "Сервер ответил ошибкой \(http.statusCode).")
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
} catch {
|
||||
Self.log.error("Decoding \(path) failed: \(error)")
|
||||
throw APIError(message: "Не удалось прочитать ответ сервера.")
|
||||
}
|
||||
}
|
||||
|
||||
/// A dropped or timed-out connection is retried twice (after 1 and 3 seconds):
|
||||
/// a flaky mobile network often recovers within seconds. Server answers are not retried.
|
||||
private func withRetry(_ url: URL) async throws -> (Data, URLResponse) {
|
||||
var wait: UInt64 = 1_000_000_000
|
||||
for attempt in 1...Self.retries {
|
||||
do {
|
||||
return try await Self.session.data(from: url)
|
||||
} catch let error as URLError where error.code != .cancelled {
|
||||
Self.log.warning("GET \(url) failed (attempt \(attempt)): \(error.localizedDescription)")
|
||||
if attempt == Self.retries { break }
|
||||
try await Task.sleep(nanoseconds: wait)
|
||||
wait *= 3
|
||||
}
|
||||
}
|
||||
throw APIError(message: Self.offline, offline: true)
|
||||
}
|
||||
|
||||
/// The whole current semester; a server without range=semester answers the from/days window.
|
||||
func timetable(doc: String, group: String, from: CivilDate, days: Int) async throws -> Timetable {
|
||||
try await get("api/timetable", [("doc", doc), ("group", group), ("from", from.iso), ("days", String(days)), ("range", "semester")])
|
||||
}
|
||||
|
||||
func groups(query: String = "", faculty: String = "", course: String = "") async throws -> GroupsResponse {
|
||||
try await get("api/groups", [("q", query), ("faculty", faculty), ("course", course)])
|
||||
}
|
||||
|
||||
func searchDirectory(_ query: String) async throws -> DirectorySearch {
|
||||
try await get("api/directory/search", [("q", query)])
|
||||
}
|
||||
|
||||
func teacherDay(_ name: String, date: CivilDate) async throws -> DirectoryDay {
|
||||
try await get("api/directory/day", [("teacher", name), ("date", date.iso)])
|
||||
}
|
||||
|
||||
func roomDay(_ room: String, date: CivilDate) async throws -> DirectoryDay {
|
||||
try await get("api/directory/day", [("room", room), ("date", date.iso)])
|
||||
}
|
||||
|
||||
func calendar() async throws -> CalendarInfo { try await get("api/calendar") }
|
||||
|
||||
func exams(group: String, faculty: String) async throws -> ExamsResponse {
|
||||
try await get("api/exams", [("group", group), ("faculty", faculty)])
|
||||
}
|
||||
}
|
||||
|
||||
extension Timetable: @unchecked Sendable {}
|
||||
extension GroupsResponse: @unchecked Sendable {}
|
||||
extension DirectorySearch: @unchecked Sendable {}
|
||||
extension DirectoryDay: @unchecked Sendable {}
|
||||
extension CalendarInfo: @unchecked Sendable {}
|
||||
extension ExamsResponse: @unchecked Sendable {}
|
||||
270
ios/PSUSchedule/AppModel.swift
Normal file
270
ios/PSUSchedule/AppModel.swift
Normal file
@@ -0,0 +1,270 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import WidgetKit
|
||||
|
||||
/// The last request's outcome, shown as a banner with "Повторить" when it failed.
|
||||
struct Status: Equatable {
|
||||
var loading = false
|
||||
var message: String?
|
||||
var offline = false
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's state, as the Android MainViewModel and Repository together: settings, the open
|
||||
* group's timetable and exams, tasks and attendance. The last answers stay on disk, so the
|
||||
* schedule, widgets and reminders work without a network.
|
||||
*/
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AppModel {
|
||||
let api = API.shared
|
||||
|
||||
private(set) var settings: UserSettings
|
||||
private(set) var timetable: Timetable?
|
||||
private(set) var exams: ExamsResponse?
|
||||
private(set) var allNotes: [Note]
|
||||
private(set) var allMarks: [Mark]
|
||||
private(set) var status = Status()
|
||||
/** Moves every half minute while the app is open: the clock "Идёт · ещё 20 мин" counts from. */
|
||||
private(set) var now = Date()
|
||||
|
||||
/** The fallback window for a server that cannot send the whole semester. */
|
||||
private let span = 35
|
||||
/** The request that failed last, so a "Повторить" button can run it again. */
|
||||
private var lastFailed: (() async throws -> Timetable)?
|
||||
|
||||
init() {
|
||||
settings = Storage.loadSettings()
|
||||
timetable = Storage.loadTimetable()
|
||||
exams = Storage.read(ExamsResponse.self, "exams.json")
|
||||
allNotes = Storage.read([Note].self, "notes.json") ?? []
|
||||
allMarks = Storage.read([Mark].self, "attendance.json") ?? []
|
||||
Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(30))
|
||||
self?.now = Date()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var today: CivilDate { CivilDate.today(now) }
|
||||
var clock: ClockTime { ClockTime.now(now) }
|
||||
var subgroup: String? { settings.subgroup }
|
||||
var hidden: Set<String> { settings.hidden }
|
||||
var groupKey: String? { settings.current?.key }
|
||||
|
||||
/** Tasks and notes of the open group. */
|
||||
var notes: [Note] { allNotes.filter { $0.group == groupKey } }
|
||||
/** Attendance marks of the open group. */
|
||||
var marks: [Mark] { allMarks.filter { $0.group == groupKey } }
|
||||
|
||||
func slots(_ t: Timetable, on date: CivilDate) -> [Slot] {
|
||||
t.slots(on: date, subgroup: subgroup, hidden: hidden)
|
||||
}
|
||||
|
||||
/** Upcoming exams of the open group, soonest first; the cache of another group is ignored. */
|
||||
var upcomingExams: [Exam] {
|
||||
guard let t = timetable, let e = exams, e.group == t.group else { return [] }
|
||||
return e.exams.filter { (CivilDate($0.date) ?? today) >= today }
|
||||
}
|
||||
|
||||
// MARK: - Loading
|
||||
|
||||
func refresh() async {
|
||||
guard let group = settings.current else {
|
||||
status = Status(message: "Группа не выбрана.")
|
||||
return
|
||||
}
|
||||
await perform { [api, span] in
|
||||
try await api.timetable(doc: group.docId, group: group.group, from: CivilDate.today().monday, days: span)
|
||||
}
|
||||
}
|
||||
|
||||
/** true when the timetable loaded; on failure the status carries the message. */
|
||||
@discardableResult
|
||||
func open(_ result: GroupResult) async -> Bool {
|
||||
await open(SavedGroup(docId: result.fileId, group: result.group, faculty: result.faculty, course: result.course))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func open(_ group: SavedGroup) async -> Bool {
|
||||
change { s in
|
||||
s.current = group
|
||||
s.recent = Array(([group] + s.recent.filter { $0.key != group.key }).prefix(8))
|
||||
}
|
||||
return await perform { [api, span] in
|
||||
try await api.timetable(doc: group.docId, group: group.group, from: CivilDate.today().monday, days: span)
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs the request that failed last once more (the banners' "Повторить"). */
|
||||
func retry() async {
|
||||
guard let block = lastFailed else { return }
|
||||
await perform(block)
|
||||
}
|
||||
|
||||
func dismissMessage() { status.message = nil }
|
||||
|
||||
@discardableResult
|
||||
private func perform(_ block: @escaping () async throws -> Timetable) async -> Bool {
|
||||
status.loading = true
|
||||
do {
|
||||
let fresh = try await block()
|
||||
timetable = fresh
|
||||
Storage.write(fresh, "timetable.json")
|
||||
lastFailed = nil
|
||||
status = Status()
|
||||
afterChange()
|
||||
// Exams come from the whole catalog index; their failure never fails the timetable.
|
||||
let faculty = fresh.document.faculty.isEmpty ? (settings.current?.faculty ?? "") : fresh.document.faculty
|
||||
if let e = try? await api.exams(group: fresh.group, faculty: faculty) {
|
||||
exams = e
|
||||
Storage.write(e, "exams.json")
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
let e = error as? APIError ?? APIError(message: error.localizedDescription)
|
||||
lastFailed = block
|
||||
// Offline with a timetable on the device still leaves something to look at.
|
||||
status = Status(
|
||||
loading: false,
|
||||
message: e.offline && timetable != nil ? e.message + " Показана сохранённая копия." : e.message,
|
||||
offline: e.offline
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
private func change(_ edit: (inout UserSettings) -> Void) {
|
||||
edit(&settings)
|
||||
Storage.saveSettings(settings)
|
||||
}
|
||||
|
||||
func setSubgroup(_ label: String?) {
|
||||
guard let key = groupKey else { return }
|
||||
change { $0.subgroups[key] = label }
|
||||
afterChange()
|
||||
}
|
||||
|
||||
func setTheme(_ mode: ThemeMode) { change { $0.theme = mode } }
|
||||
func setDisplay(_ d: Display) { change { $0.display = d } }
|
||||
func setPlans(_ p: PlansPrefs) { change { $0.plans = p } }
|
||||
|
||||
/** Hides or shows again a recurring lesson of the open group. */
|
||||
func setHidden(_ key: String, _ hidden: Bool) {
|
||||
guard let group = groupKey else { return }
|
||||
change { s in
|
||||
var list = s.hiddenByGroup[group] ?? []
|
||||
list.removeAll { $0 == key }
|
||||
if hidden { list.append(key) }
|
||||
s.hiddenByGroup[group] = list
|
||||
}
|
||||
afterChange()
|
||||
}
|
||||
|
||||
func setReminders(_ on: Bool) {
|
||||
change { $0.reminders = on }
|
||||
afterChange()
|
||||
}
|
||||
|
||||
func setReminderMinutes(_ minutes: Int) {
|
||||
change { $0.reminderMinutes = minutes }
|
||||
afterChange()
|
||||
}
|
||||
|
||||
// MARK: - Notes and attendance
|
||||
|
||||
func save(_ note: Note) {
|
||||
if let i = allNotes.firstIndex(where: { $0.id == note.id }) { allNotes[i] = note } else { allNotes.append(note) }
|
||||
Storage.write(allNotes, "notes.json")
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
|
||||
func delete(_ note: Note) {
|
||||
allNotes.removeAll { $0.id == note.id }
|
||||
Storage.write(allNotes, "notes.json")
|
||||
}
|
||||
|
||||
func toggle(_ note: Note) {
|
||||
guard let i = allNotes.firstIndex(where: { $0.id == note.id }) else { return }
|
||||
allNotes[i].done.toggle()
|
||||
Storage.write(allNotes, "notes.json")
|
||||
}
|
||||
|
||||
/** Marks a lesson as attended (true), missed (false) or clears the mark (nil). */
|
||||
func mark(_ lesson: Lesson, on date: CivilDate, present: Bool?) {
|
||||
guard let group = groupKey else { return }
|
||||
let mark = Mark(group: group, subject: lesson.subject, date: date.iso, start: lesson.start, present: present ?? false)
|
||||
allMarks.removeAll { $0.id == mark.id }
|
||||
if present != nil { allMarks.append(mark) }
|
||||
Storage.write(allMarks, "attendance.json")
|
||||
}
|
||||
|
||||
/** Reminders and widgets follow every change of the timetable or the choices. */
|
||||
private func afterChange() {
|
||||
Reminders.reschedule(timetable: timetable, settings: settings)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lookups shared by the screens
|
||||
|
||||
extension Array where Element == Note {
|
||||
func forLesson(_ subject: String, _ date: CivilDate, _ start: String) -> [Note] {
|
||||
let key = subjectKey(subject)
|
||||
return filter { $0.date == date.iso && $0.start == start && subjectKey($0.subject) == key }
|
||||
}
|
||||
|
||||
/** Unfinished tasks and notes: what a card should hint at. */
|
||||
var open: [Note] { filter { !$0.task || !$0.done } }
|
||||
}
|
||||
|
||||
extension Array where Element == Mark {
|
||||
func forLesson(_ subject: String, _ date: CivilDate, _ start: String) -> Mark? {
|
||||
let key = subjectKey(subject)
|
||||
return first { $0.date == date.iso && $0.start == start && subjectKey($0.subject) == key }
|
||||
}
|
||||
|
||||
/** Present and marked lessons of a subject. */
|
||||
func stats(for subject: String) -> (present: Int, total: Int) {
|
||||
let own = filter { subjectKey($0.subject) == subjectKey(subject) }
|
||||
return (own.filter(\.present).count, own.count)
|
||||
}
|
||||
}
|
||||
|
||||
extension Note {
|
||||
func overdue(today: CivilDate) -> Bool { task && !done && (CivilDate(date) ?? today) < today }
|
||||
|
||||
/** "к сегодня, 11:45", "к завтра, 10:05", "к пн, 19 октября". */
|
||||
func dueLabel(today: CivilDate) -> String {
|
||||
let d = CivilDate(date) ?? today
|
||||
let at = start.isEmpty ? "" : ", \(start)"
|
||||
let day: String
|
||||
if d == today { day = "сегодня" + at }
|
||||
else if d == today.adding(days: 1) { day = "завтра" + at }
|
||||
else { day = d.shortWeekday.lowercased() + ", " + d.longLabel }
|
||||
return task ? "к " + day : day
|
||||
}
|
||||
}
|
||||
|
||||
extension AppModel {
|
||||
/** The next lesson of the same subject after this one, within the loaded weeks. */
|
||||
func nextOccurrence(of subject: String, after date: CivilDate, start: String) -> (CivilDate, Lesson)? {
|
||||
guard let t = timetable else { return nil }
|
||||
let key = subjectKey(subject)
|
||||
for d in t.dates where d >= date {
|
||||
let found = t.lessons(on: d, subgroup: subgroup, hidden: hidden)
|
||||
.filter { subjectKey($0.subject) == key && (d > date || $0.start > start) }
|
||||
.sorted { $0.start < $1.start }
|
||||
if let first = found.first { return (d, first) }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/** Attendance makes sense once a lesson has begun. */
|
||||
func started(_ date: CivilDate, _ start: String) -> Bool {
|
||||
date < today || (date == today && start <= clock.description)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"colors" : [
|
||||
{ "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", "red" : "0x00", "green" : "0x61", "blue" : "0xA4" } }, "idiom" : "universal" },
|
||||
{ "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", "red" : "0x9E", "green" : "0xCA", "blue" : "0xFF" } }, "idiom" : "universal" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
BIN
ios/PSUSchedule/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
BIN
ios/PSUSchedule/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "AppIcon.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" },
|
||||
{ "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "filename" : "AppIcon-Dark.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
1
ios/PSUSchedule/Assets.xcassets/Contents.json
Normal file
1
ios/PSUSchedule/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "info" : { "author" : "xcode", "version" : 1 } }
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"colors" : [
|
||||
{ "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", "red" : "0x2E", "green" : "0x7D", "blue" : "0x32" } }, "idiom" : "universal" },
|
||||
{ "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", "red" : "0x81", "green" : "0xC7", "blue" : "0x84" } }, "idiom" : "universal" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
65
ios/PSUSchedule/PSUScheduleApp.swift
Normal file
65
ios/PSUSchedule/PSUScheduleApp.swift
Normal file
@@ -0,0 +1,65 @@
|
||||
import BackgroundTasks
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
struct PSUScheduleApp: App {
|
||||
@State private var model = AppModel()
|
||||
@Environment(\.scenePhase) private var phase
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
.environment(model)
|
||||
// The theme is set on the window, not with preferredColorScheme: sheets that are
|
||||
// already open (the settings themselves) switch along with the screens behind them.
|
||||
.onChange(of: model.settings.theme, initial: true) { old, new in Theme.apply(new, animated: old != new) }
|
||||
.task { Background.schedule() }
|
||||
}
|
||||
.onChange(of: phase) { _, now in
|
||||
// Back in the app after a while: a quiet refresh, as the Android client does on start.
|
||||
if now == .active, model.settings.current != nil { Task { await model.refresh() } }
|
||||
if now == .background { Background.schedule() }
|
||||
}
|
||||
// Every few hours iOS wakes the app to refresh the timetable, widgets and reminders.
|
||||
.backgroundTask(.appRefresh(Background.identifier)) {
|
||||
await Background.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Theme {
|
||||
static func apply(_ mode: ThemeMode, animated: Bool) {
|
||||
let style: UIUserInterfaceStyle = switch mode {
|
||||
case .system: .unspecified
|
||||
case .light: .light
|
||||
case .dark: .dark
|
||||
}
|
||||
for scene in UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }) {
|
||||
for window in scene.windows where window.overrideUserInterfaceStyle != style {
|
||||
if animated {
|
||||
UIView.transition(with: window, duration: 0.3, options: .transitionCrossDissolve) {
|
||||
window.overrideUserInterfaceStyle = style
|
||||
}
|
||||
} else {
|
||||
window.overrideUserInterfaceStyle = style
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Background {
|
||||
static let identifier = "org.xtimms.psu.schedule.refresh"
|
||||
|
||||
static func schedule() {
|
||||
let request = BGAppRefreshTaskRequest(identifier: identifier)
|
||||
request.earliestBeginDate = Date().addingTimeInterval(6 * 3600)
|
||||
try? BGTaskScheduler.shared.submit(request)
|
||||
}
|
||||
|
||||
static func refresh() async {
|
||||
schedule()
|
||||
await AppModel().refresh()
|
||||
}
|
||||
}
|
||||
57
ios/PSUSchedule/Reminders.swift
Normal file
57
ios/PSUSchedule/Reminders.swift
Normal file
@@ -0,0 +1,57 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
/**
|
||||
* Local reminders before each lesson of the coming week, from the saved timetable: they work
|
||||
* offline, no push server is involved. iOS keeps at most 64 pending requests per app.
|
||||
*/
|
||||
enum Reminders {
|
||||
private static let prefix = "lesson-"
|
||||
private static let limit = 48
|
||||
|
||||
/** Asks for permission; false when the student declined. */
|
||||
static func authorize() async -> Bool {
|
||||
(try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge])) ?? false
|
||||
}
|
||||
|
||||
/** Replaces every scheduled reminder with the ones the timetable needs now. */
|
||||
static func reschedule(timetable: Timetable?, settings: UserSettings) {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
Task {
|
||||
let pending = await center.pendingNotificationRequests().map(\.identifier).filter { $0.hasPrefix(prefix) }
|
||||
center.removePendingNotificationRequests(withIdentifiers: pending)
|
||||
guard settings.reminders, let t = timetable else { return }
|
||||
let now = Date()
|
||||
// A week ahead, so a Friday evening already holds Monday's reminders.
|
||||
let horizon = now.addingTimeInterval(7 * 24 * 3600)
|
||||
var count = 0
|
||||
for day in t.days {
|
||||
guard let date = CivilDate(day.date), count < limit else { continue }
|
||||
// One reminder per start time: parallel subgroups share the notification.
|
||||
for slot in t.slots(on: date, subgroup: settings.subgroup, hidden: settings.hidden) {
|
||||
let begins = date.at(slot.start)
|
||||
let at = begins.addingTimeInterval(-Double(settings.reminderMinutes) * 60)
|
||||
guard at > now, begins < horizon, count < limit else { continue }
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = slot.lessons.map(\.subject).joined(separator: " / ")
|
||||
let first = slot.lessons[0]
|
||||
let place = Array(Set(slot.lessons.flatMap(\.rooms))).sorted().joined(separator: ", ")
|
||||
var text = slot.start.description
|
||||
if let pair = first.pair { text += " · \(pair) пара" }
|
||||
if !place.isEmpty { text += " · ауд. \(place)" }
|
||||
if slot.lessons.contains(where: \.uncertain) { text += " · сверьте недели" }
|
||||
content.body = text
|
||||
content.sound = .default
|
||||
content.interruptionLevel = .timeSensitive
|
||||
let parts = minskCalendar.dateComponents(in: minsk, from: at)
|
||||
let trigger = UNCalendarNotificationTrigger(
|
||||
dateMatching: DateComponents(timeZone: minsk, year: parts.year, month: parts.month, day: parts.day, hour: parts.hour, minute: parts.minute),
|
||||
repeats: false
|
||||
)
|
||||
try? await center.add(UNNotificationRequest(identifier: prefix + slot.id, content: content, trigger: trigger))
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
132
ios/PSUSchedule/RootView.swift
Normal file
132
ios/PSUSchedule/RootView.swift
Normal file
@@ -0,0 +1,132 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Three sections and search: the calendar lives inside the plans, the subjects inside the overview.
|
||||
enum AppTab: Hashable {
|
||||
case home, schedule, plans, search
|
||||
}
|
||||
|
||||
/// Where the screens send the student: another tab, a day of the schedule, the groups or settings.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class Router {
|
||||
var tab: AppTab = .home
|
||||
var groups = false
|
||||
var settings = false
|
||||
/** A day or week the schedule should show next (from the overview, calendar, widgets). */
|
||||
var focus: CivilDate?
|
||||
/** The schedule as a week grid instead of days; iPad starts with the grid. */
|
||||
var weekView: Bool?
|
||||
/** The plans as a month calendar instead of a list. */
|
||||
var plansCalendar = false
|
||||
/** What the search tab looks for: its field lives in the tab bar. */
|
||||
var searchQuery = ""
|
||||
|
||||
func openDay(_ date: CivilDate) {
|
||||
focus = date
|
||||
weekView = false
|
||||
tab = .schedule
|
||||
}
|
||||
|
||||
func openWeek(_ date: CivilDate) {
|
||||
focus = date
|
||||
weekView = true
|
||||
tab = .schedule
|
||||
}
|
||||
}
|
||||
|
||||
struct RootView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
// Decided once: picking the group mid-onboarding must not cut the remaining steps short.
|
||||
@State private var onboarding = Storage.loadSettings().current == nil
|
||||
@State private var router = Router()
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if onboarding {
|
||||
OnboardingView { withAnimation(.smooth(duration: 0.5)) { onboarding = false } }
|
||||
.transition(.opacity.combined(with: .scale(scale: 1.04)))
|
||||
} else {
|
||||
MainTabs()
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.environment(router)
|
||||
.onOpenURL { url in
|
||||
// A widget tapped: psuschedule://day/2026-10-19 opens that day.
|
||||
if url.host() == "day", let date = CivilDate(url.lastPathComponent) { router.openDay(date) }
|
||||
else if url.host() == "day" { router.openDay(model.today) }
|
||||
}
|
||||
.environment(\.palette, Palette(slots: model.timetable?.subjectSlots ?? [:], enabled: model.settings.display.colors))
|
||||
.environment(\.display, model.settings.display)
|
||||
}
|
||||
}
|
||||
|
||||
/// The sections in the Liquid Glass tab bar; on iPad they become a sidebar.
|
||||
struct MainTabs: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(Router.self) private var router
|
||||
|
||||
var body: some View {
|
||||
@Bindable var router = router
|
||||
TabView(selection: $router.tab) {
|
||||
Tab("Обзор", systemImage: "house", value: AppTab.home) { HomeView() }
|
||||
Tab("Расписание", systemImage: "calendar.day.timeline.left", value: AppTab.schedule) { ScheduleView() }
|
||||
Tab("Планы", systemImage: "checklist", value: AppTab.plans) { PlansTab() }
|
||||
Tab("Поиск", systemImage: "magnifyingglass", value: AppTab.search, role: .search) {
|
||||
SearchView().searchable(text: $router.searchQuery, prompt: "Фамилия или номер аудитории")
|
||||
}
|
||||
}
|
||||
// Search stands apart from the three sections as its own glass button; tapping it turns
|
||||
// the button into the field above the keyboard, as in Apple's own apps. The field belongs
|
||||
// to the search tab only, so the other sections keep their bars clean.
|
||||
.tabViewSearchActivation(.searchTabSelection)
|
||||
.tabViewStyle(.sidebarAdaptable)
|
||||
.tabBarMinimizeBehavior(.onScrollDown)
|
||||
.sheet(isPresented: $router.groups) {
|
||||
NavigationStack { GroupsView(onboarding: false) { router.groups = false } }
|
||||
}
|
||||
.sheet(isPresented: $router.settings) { SettingsView() }
|
||||
}
|
||||
}
|
||||
|
||||
/// The open group as a glass capsule in the toolbar: one tap from switching.
|
||||
struct GroupButton: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(Router.self) private var router
|
||||
var body: some View {
|
||||
let group = model.timetable?.group ?? model.settings.current?.group
|
||||
Button { router.groups = true } label: {
|
||||
Label(group ?? "Группа", systemImage: "person.2.fill").labelStyle(.titleAndIcon)
|
||||
}
|
||||
.accessibilityLabel(group.map { "Группа \($0), сменить" } ?? "Выбрать группу")
|
||||
}
|
||||
}
|
||||
|
||||
/// The plans tab: the list of plans or the month calendar, switched from the toolbar.
|
||||
struct PlansTab: View {
|
||||
@Environment(Router.self) private var router
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if router.plansCalendar { CalendarView().transition(.opacity) } else { PlansView().transition(.opacity) }
|
||||
}
|
||||
.animation(.smooth(duration: 0.3), value: router.plansCalendar)
|
||||
}
|
||||
}
|
||||
|
||||
/// Switches the plans between the list and the calendar, as the schedule switches day and week.
|
||||
struct PlansModeButton: View {
|
||||
@Environment(Router.self) private var router
|
||||
var body: some View {
|
||||
Button(router.plansCalendar ? "Показать список" : "Показать календарь", systemImage: router.plansCalendar ? "list.bullet" : "calendar") {
|
||||
router.plansCalendar.toggle()
|
||||
}
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsButton: View {
|
||||
@Environment(Router.self) private var router
|
||||
var body: some View {
|
||||
Button("Настройки", systemImage: "gearshape") { router.settings = true }
|
||||
}
|
||||
}
|
||||
236
ios/PSUSchedule/Views/CalendarView.swift
Normal file
236
ios/PSUSchedule/Views/CalendarView.swift
Normal file
@@ -0,0 +1,236 @@
|
||||
import SwiftUI
|
||||
|
||||
/**
|
||||
* The month with marks on days with lessons and with plans, green weeks highlighted;
|
||||
* below it, the chosen day's plans and lessons.
|
||||
*/
|
||||
struct CalendarView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||
@State private var month: CivilDate?
|
||||
@State private var selected: CivilDate?
|
||||
@State private var details: LessonRef?
|
||||
@State private var editor: EditorRequest?
|
||||
@State private var adding = false
|
||||
@State private var forward = true
|
||||
|
||||
var body: some View {
|
||||
let date = selected ?? model.today
|
||||
let shown = month ?? date.firstOfMonth
|
||||
NavigationStack {
|
||||
Group {
|
||||
if sizeClass == .regular {
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
ScrollView { monthCard(shown, date).padding(16) }.frame(width: 460)
|
||||
ScrollView { agenda(date).padding(16) }
|
||||
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 28, style: .continuous))
|
||||
.padding([.trailing, .bottom], 16)
|
||||
}
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
monthCard(shown, date)
|
||||
agenda(date)
|
||||
}
|
||||
.padding(16)
|
||||
.padding(.bottom, 60)
|
||||
.readableWidth()
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.navigationTitle("Планы")
|
||||
.navigationSubtitle(shown.monthTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
if date != model.today || shown != model.today.firstOfMonth {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Сегодня", systemImage: "calendar.badge.clock") {
|
||||
withAnimation(.smooth) {
|
||||
forward = model.today > date
|
||||
selected = model.today
|
||||
month = model.today.firstOfMonth
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) { PlansModeButton() }
|
||||
}
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
Button { adding = true } label: {
|
||||
Image(systemName: "plus").font(.title2.weight(.semibold)).frame(width: 36, height: 36)
|
||||
}
|
||||
.buttonStyle(.glassProminent)
|
||||
.buttonBorderShape(.circle)
|
||||
.controlSize(.large)
|
||||
.padding(20)
|
||||
.accessibilityLabel("Новый план")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $adding) {
|
||||
AddPlanSheet(day: date) { target in
|
||||
adding = false
|
||||
editor = EditorRequest(target: target)
|
||||
}
|
||||
}
|
||||
.sheet(item: $editor) { NoteEditor(request: $0) }
|
||||
.sheet(item: $details) { LessonDetails(ref: $0).presentationDetents([.medium, .large]) }
|
||||
}
|
||||
|
||||
private func monthCard(_ shown: CivilDate, _ date: CivilDate) -> some View {
|
||||
VStack(spacing: 12) {
|
||||
HStack {
|
||||
Text(shown.monthTitle).font(.title3.bold())
|
||||
Spacer()
|
||||
GlassEffectContainer(spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Button("Предыдущий месяц", systemImage: "chevron.left") { go(shown.adding(months: -1)) }
|
||||
Button("Следующий месяц", systemImage: "chevron.right") { go(shown.adding(months: 1)) }
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.buttonStyle(.glass)
|
||||
}
|
||||
}
|
||||
MonthGrid(month: shown, selected: date) { d in
|
||||
withAnimation(.smooth) {
|
||||
forward = d > date
|
||||
selected = d
|
||||
if d.firstOfMonth != shown { month = d.firstOfMonth }
|
||||
}
|
||||
}
|
||||
.id(shown)
|
||||
.transition(.push(from: forward ? .trailing : .leading))
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 30).onEnded { g in
|
||||
if abs(g.translation.width) > abs(g.translation.height) { go(shown.adding(months: g.translation.width < 0 ? 1 : -1)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
.card(padding: 16)
|
||||
.clipped()
|
||||
}
|
||||
|
||||
private func go(_ target: CivilDate) {
|
||||
withAnimation(.smooth) {
|
||||
forward = target > (month ?? model.today.firstOfMonth)
|
||||
month = target.firstOfMonth
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func agenda(_ date: CivilDate) -> some View {
|
||||
DayAgenda(date: date, details: $details, editor: $editor)
|
||||
.id(date)
|
||||
.transition(.push(from: forward ? .trailing : .leading))
|
||||
}
|
||||
}
|
||||
|
||||
private struct MonthGrid: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var month: CivilDate
|
||||
var selected: CivilDate
|
||||
var onSelect: (CivilDate) -> Void
|
||||
|
||||
var body: some View {
|
||||
let first = month.firstOfMonth.monday
|
||||
let next = month.adding(months: 1).firstOfMonth
|
||||
let rows = stride(from: 0, to: 42, by: 7).map { first.adding(days: $0) }.filter { $0 < next }
|
||||
VStack(spacing: 4) {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(["п", "в", "с", "ч", "п", "с", "в"].indices, id: \.self) { i in
|
||||
Text(["П", "В", "С", "Ч", "П", "С", "В"][i]).font(.caption.weight(.semibold)).foregroundStyle(.secondary).frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.accessibilityHidden(true)
|
||||
ForEach(rows, id: \.self) { monday in
|
||||
let week = model.timetable?.day(monday)?.week
|
||||
HStack(spacing: 0) {
|
||||
ForEach(0..<7, id: \.self) { i in
|
||||
cell(monday.adding(days: i))
|
||||
}
|
||||
}
|
||||
.background {
|
||||
// The university's green weeks, as on the week strips.
|
||||
if let week, !week.white {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous).fill(Color.greenWeek.opacity(0.16))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cell(_ d: CivilDate) -> some View {
|
||||
let inMonth = d.month == month.month
|
||||
let isSelected = d == selected
|
||||
let isToday = d == model.today
|
||||
let lessons = model.timetable.map { !model.slots($0, on: d).isEmpty } ?? false
|
||||
let plans = model.notes.contains { $0.date == d.iso }
|
||||
let color: Color = isSelected ? .onBrand : isToday ? .brand : inMonth ? .primary : Color(.tertiaryLabel)
|
||||
return Button { onSelect(d) } label: {
|
||||
VStack(spacing: 3) {
|
||||
Text("\(d.day)")
|
||||
.font(.callout.weight(isToday || isSelected ? .bold : .regular))
|
||||
.foregroundStyle(color)
|
||||
.frame(width: 34, height: 34)
|
||||
.background { if isSelected { Circle().fill(Color.brand) } else if isToday { Circle().strokeBorder(Color.brand, lineWidth: 1.5) } }
|
||||
HStack(spacing: 3) {
|
||||
Circle().fill(lessons ? Color.secondary : .clear).frame(width: 4, height: 4)
|
||||
if plans { Circle().fill(Color.orange).frame(width: 4, height: 4) }
|
||||
}
|
||||
.frame(height: 4)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 2)
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(d.weekdayName + ", " + d.longLabel + (lessons ? ", есть пары" : "") + (plans ? ", есть планы" : ""))
|
||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
|
||||
/// The chosen day: its plans first, then its lessons on a timeline.
|
||||
private struct DayAgenda: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var date: CivilDate
|
||||
@Binding var details: LessonRef?
|
||||
@Binding var editor: EditorRequest?
|
||||
|
||||
var body: some View {
|
||||
let slots = model.timetable.map { model.slots($0, on: date) } ?? []
|
||||
let notes = model.notes.filter { $0.date == date.iso }
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .lastTextBaseline) {
|
||||
Text(date == model.today ? "Сегодня" : date.weekdayName).font(.title2.bold())
|
||||
Spacer()
|
||||
Text(date.longLabel).font(.subheadline).foregroundStyle(.secondary)
|
||||
if let week = model.timetable?.day(date)?.week { WeekMark(week: week, short: true) }
|
||||
}
|
||||
if slots.isEmpty && notes.isEmpty {
|
||||
let outside = model.timetable.map { t in t.semester.map { date.iso < $0.start || date.iso > $0.end } ?? true } ?? true
|
||||
EmptyState(
|
||||
title: model.timetable == nil ? "Расписание не загружено" : "Свободный день",
|
||||
text: outside && model.timetable != nil ? "Эта дата вне текущего семестра. Планов на \(date.longLabel) нет." : "\(date.weekdayName), \(date.longLabel): ни пар, ни планов.",
|
||||
symbol: "sofa"
|
||||
)
|
||||
}
|
||||
if !notes.isEmpty {
|
||||
Text("Ваши планы").font(.headline)
|
||||
VStack(spacing: 4) {
|
||||
ForEach(notes) { note in NoteRow(note: note, showDue: false) { editor = EditorRequest(editing: note) } }
|
||||
}
|
||||
.card(padding: 14)
|
||||
}
|
||||
if !slots.isEmpty {
|
||||
Text("Ваши занятия").font(.headline)
|
||||
ForEach(slots) { slot in
|
||||
ForEach(slot.lessons, id: \.key) { lesson in
|
||||
Button { details = LessonRef(lesson: lesson, date: date) } label: {
|
||||
LessonCard(lesson: lesson, time: slot.timeLabel, date: date)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
281
ios/PSUSchedule/Views/Components.swift
Normal file
281
ios/PSUSchedule/Views/Components.swift
Normal file
@@ -0,0 +1,281 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Colours
|
||||
|
||||
extension Color {
|
||||
init(hex: UInt32) {
|
||||
self.init(.sRGB, red: Double((hex >> 16) & 0xFF) / 255, green: Double((hex >> 8) & 0xFF) / 255, blue: Double(hex & 0xFF) / 255)
|
||||
}
|
||||
|
||||
/// The app's blue (#0061A4 in light, #9ECAFF in dark), from the asset catalog.
|
||||
static let brand = Color("AccentColor")
|
||||
/// Text and icons on the brand blue: white on the dark light-theme blue, black on the pale dark-theme one.
|
||||
static let onBrand = Color(.systemBackground)
|
||||
// Color.greenWeek, the university's green weeks, is generated from the asset catalog.
|
||||
}
|
||||
|
||||
/// A subject's colours: `strong`/`onStrong` for small fills (dots, week grid),
|
||||
/// `soft`/`onSoft` for lesson cards; every pair keeps 4.5:1 contrast.
|
||||
struct SubjectColor {
|
||||
var strong: Color
|
||||
var onStrong: Color
|
||||
var soft: Color
|
||||
var onSoft: Color
|
||||
}
|
||||
|
||||
// Material 700 tones (strong in light theme) and 200 tones (strong in dark theme), as on Android.
|
||||
private let tone700: [UInt32] = [0x1565C0, 0x7B1FA2, 0xC62828, 0x2E7D32, 0xD84315, 0x00695C, 0x283593, 0xAD1457, 0x5D4037, 0x00838F, 0x4527A0, 0x455A64]
|
||||
private let tone200: [UInt32] = [0x90CAF9, 0xCE93D8, 0xEF9A9A, 0xA5D6A7, 0xFFAB91, 0x80CBC4, 0x9FA8DA, 0xF48FB1, 0xBCAAA4, 0x80DEEA, 0xB39DDB, 0xB0BEC5]
|
||||
|
||||
private func mix(_ a: UInt32, _ b: UInt32, _ t: Double) -> Color {
|
||||
func c(_ v: UInt32, _ s: UInt32) -> Double { Double((v >> s) & 0xFF) / 255 }
|
||||
func m(_ s: UInt32) -> Double { c(a, s) + (c(b, s) - c(a, s)) * t }
|
||||
return Color(.sRGB, red: m(16), green: m(8), blue: m(0))
|
||||
}
|
||||
|
||||
/// Subject colours of the open timetable, put into the environment at the root.
|
||||
struct Palette {
|
||||
var slots: [String: Int] = [:]
|
||||
var enabled = true
|
||||
|
||||
func color(_ subject: String, dark: Bool) -> SubjectColor {
|
||||
guard enabled else {
|
||||
return SubjectColor(strong: .brand, onStrong: dark ? .black : .white, soft: Color(.secondarySystemGroupedBackground), onSoft: .primary)
|
||||
}
|
||||
let key = subjectKey(subject)
|
||||
// Subjects of other groups (search results) still get a stable colour.
|
||||
let i = slots[key].map { $0 % tone700.count } ?? Int(UInt(bitPattern: key.hashValue) % UInt(tone700.count))
|
||||
return dark
|
||||
? SubjectColor(strong: Color(hex: tone200[i]), onStrong: Color(hex: 0x1D1B20), soft: mix(0x211F26, tone700[i], 0.5), onSoft: mix(tone200[i], 0xFFFFFF, 0.4))
|
||||
: SubjectColor(strong: Color(hex: tone700[i]), onStrong: .white, soft: mix(tone200[i], 0xFFFFFF, 0.35), onSoft: mix(tone700[i], 0x000000, 0.35))
|
||||
}
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
@Entry var palette = Palette()
|
||||
@Entry var display = Display()
|
||||
}
|
||||
|
||||
/// Reads a subject's colours inside a view body.
|
||||
struct WithSubjectColor<Content: View>: View {
|
||||
@Environment(\.palette) private var palette
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
var subject: String
|
||||
@ViewBuilder var content: (SubjectColor) -> Content
|
||||
var body: some View { content(palette.color(subject, dark: scheme == .dark)) }
|
||||
}
|
||||
|
||||
struct SubjectDot: View {
|
||||
var subject: String
|
||||
var size: CGFloat = 12
|
||||
var body: some View {
|
||||
WithSubjectColor(subject: subject) { c in
|
||||
Circle().fill(c.strong).frame(width: size, height: size)
|
||||
}
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cards
|
||||
|
||||
extension View {
|
||||
/// Content cards lie on the grouped background; Liquid Glass is kept for the controls above them.
|
||||
func card(padding: CGFloat = 20) -> some View {
|
||||
self.padding(padding)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 26, style: .continuous))
|
||||
}
|
||||
|
||||
/// A readable column on iPad and in landscape.
|
||||
func readableWidth(_ width: CGFloat = 720) -> some View {
|
||||
frame(maxWidth: width).frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
/// A titled card: the overview's building block.
|
||||
struct SectionCard<Content: View>: View {
|
||||
var symbol: String
|
||||
var title: String
|
||||
var trailing: String?
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: symbol)
|
||||
.font(.footnote.weight(.bold))
|
||||
.foregroundStyle(Color.onBrand)
|
||||
.frame(width: 30, height: 30)
|
||||
.background(Color.brand, in: .circle)
|
||||
Text(title).font(.headline).foregroundStyle(Color.brand)
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
Spacer()
|
||||
if let trailing { Text(trailing).font(.headline).foregroundStyle(.secondary) }
|
||||
}
|
||||
content
|
||||
}
|
||||
.card()
|
||||
}
|
||||
}
|
||||
|
||||
/// An error or offline message with "Повторить" and "OK".
|
||||
struct Banner: View {
|
||||
var text: String
|
||||
var offline: Bool
|
||||
var onClose: (() -> Void)?
|
||||
var onRetry: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Image(systemName: offline ? "icloud.slash" : "exclamationmark.triangle")
|
||||
.foregroundStyle(offline ? Color.secondary : Color.red)
|
||||
Text(text).font(.subheadline).frame(maxWidth: .infinity, alignment: .leading)
|
||||
if let onRetry { Button("Повторить", action: onRetry).font(.subheadline.weight(.semibold)) }
|
||||
if let onClose { Button("OK", action: onClose).font(.subheadline.weight(.semibold)) }
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
(offline ? Color(.tertiarySystemFill) : Color.red.opacity(0.12)),
|
||||
in: .rect(cornerRadius: 18, style: .continuous)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The status banner of the app model, if it has something to say.
|
||||
struct StatusBanner: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var body: some View {
|
||||
if let message = model.status.message {
|
||||
Banner(
|
||||
text: message,
|
||||
offline: model.status.offline,
|
||||
onClose: { model.dismissMessage() },
|
||||
onRetry: model.status.offline ? { Task { await model.retry() } } : nil
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An empty state: a symbol, a title, a calm explanation and an optional action.
|
||||
struct EmptyState: View {
|
||||
var title: String
|
||||
var text: String
|
||||
var symbol: String = "calendar.badge.exclamationmark"
|
||||
var action: (label: String, run: () -> Void)?
|
||||
|
||||
var body: some View {
|
||||
ContentUnavailableView {
|
||||
Label(title, systemImage: symbol)
|
||||
} description: {
|
||||
Text(text)
|
||||
} actions: {
|
||||
if let action {
|
||||
Button(action.label, action: action.run).buttonStyle(.glassProminent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Weeks
|
||||
|
||||
/// "белая неделя" / "зелёная неделя": the university's week colour, never by colour alone.
|
||||
struct WeekMark: View {
|
||||
var week: Week
|
||||
var short = false
|
||||
var body: some View {
|
||||
HStack(spacing: 5) {
|
||||
Circle()
|
||||
.fill(week.white ? Color.white : Color.greenWeek)
|
||||
.overlay(Circle().strokeBorder(Color.secondary.opacity(0.5), lineWidth: week.white ? 1 : 0))
|
||||
.frame(width: 10, height: 10)
|
||||
Text(short ? (week.white ? "белая" : "зелёная") : (week.white ? "белая неделя" : "зелёная неделя"))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
/// The tint a week's glass header takes: none for white weeks, a light green for green ones.
|
||||
func weekTint(_ week: Week?) -> Color? {
|
||||
guard let week, !week.white else { return nil }
|
||||
return Color.greenWeek.opacity(0.28)
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
/// Chips that wrap onto the next line.
|
||||
struct FlowLayout: Layout {
|
||||
var spacing: CGFloat = 8
|
||||
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
let rows = arrange(proposal.width ?? .infinity, subviews)
|
||||
let height = rows.last.map { $0.y + $0.height } ?? 0
|
||||
let width = rows.map(\.width).max() ?? 0
|
||||
return CGSize(width: proposal.width ?? width, height: height)
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
for row in arrange(bounds.width, subviews) {
|
||||
var x = bounds.minX
|
||||
for i in row.items {
|
||||
let size = subviews[i].sizeThatFits(.unspecified)
|
||||
subviews[i].place(at: CGPoint(x: x, y: bounds.minY + row.y), proposal: ProposedViewSize(size))
|
||||
x += size.width + spacing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct Row { var items: [Int] = []; var y: CGFloat = 0; var width: CGFloat = 0; var height: CGFloat = 0 }
|
||||
|
||||
private func arrange(_ maxWidth: CGFloat, _ subviews: Subviews) -> [Row] {
|
||||
var rows: [Row] = []
|
||||
var row = Row()
|
||||
for (i, view) in subviews.enumerated() {
|
||||
let size = view.sizeThatFits(.unspecified)
|
||||
if !row.items.isEmpty && row.width + spacing + size.width > maxWidth {
|
||||
rows.append(row)
|
||||
row = Row(y: row.y + row.height + spacing)
|
||||
}
|
||||
row.width += (row.items.isEmpty ? 0 : spacing) + size.width
|
||||
row.height = max(row.height, size.height)
|
||||
row.items.append(i)
|
||||
}
|
||||
if !row.items.isEmpty { rows.append(row) }
|
||||
return rows
|
||||
}
|
||||
}
|
||||
|
||||
/// A small rounded tag: "Лекция", "белые недели", "общее занятие".
|
||||
struct Tag: View {
|
||||
var text: String
|
||||
var symbol: String?
|
||||
var foreground: Color = .secondary
|
||||
var background: Color = Color(.tertiarySystemFill)
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
if let symbol { Image(systemName: symbol).font(.caption.weight(.bold)) }
|
||||
Text(text.prefix(1).uppercased() + text.dropFirst()).font(.footnote.weight(.medium))
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.foregroundStyle(foreground)
|
||||
.background(background, in: .rect(cornerRadius: 8, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
/// An icon and a line of text, for rooms, teachers and other details.
|
||||
struct Meta: View {
|
||||
var symbol: String
|
||||
var text: String
|
||||
var body: some View {
|
||||
Label {
|
||||
Text(text)
|
||||
} icon: {
|
||||
Image(systemName: symbol).imageScale(.small)
|
||||
}
|
||||
.font(.subheadline)
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
}
|
||||
246
ios/PSUSchedule/Views/GroupsView.swift
Normal file
246
ios/PSUSchedule/Views/GroupsView.swift
Normal file
@@ -0,0 +1,246 @@
|
||||
import SwiftUI
|
||||
|
||||
private let plainPeriod = #/^(осенний|весенний) семестр$|^расписание из word$/#.ignoresCase()
|
||||
|
||||
private func describe(_ faculty: String, _ course: String, _ form: String = "", _ period: String = "") -> String {
|
||||
var parts: [String] = []
|
||||
if let f = facultyShort(faculty) { parts.append(f) }
|
||||
if !course.isEmpty { parts.append(course) }
|
||||
let form = form.replacingOccurrences(of: " форма", with: "").lowercased()
|
||||
if !form.isEmpty { parts.append(form) }
|
||||
if !period.isEmpty && (try? plainPeriod.wholeMatch(in: period)) == nil { parts.append(period.lowercased()) }
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
/**
|
||||
* Picking a group: search by number across the whole catalog, recent groups, the catalog by
|
||||
* faculty and course, and the subgroup of the open group. Students think in groups, not files.
|
||||
*/
|
||||
struct GroupsView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
/** A step of the onboarding: its own header, and the subgroup is asked on a page of its own. */
|
||||
var onboarding: Bool
|
||||
var onOpened: () -> Void
|
||||
|
||||
@State private var query = ""
|
||||
@State private var faculty = ""
|
||||
@State private var course = ""
|
||||
@State private var data: GroupsResponse?
|
||||
@State private var error: String?
|
||||
@State private var retries = 0
|
||||
@State private var opening: String?
|
||||
@State private var askSubgroup: [String]?
|
||||
|
||||
var body: some View {
|
||||
let current = model.settings.current
|
||||
let searching = query.trimmed.count >= 2
|
||||
List {
|
||||
if onboarding || current == nil {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Найдите свою группу").font(.largeTitle.bold())
|
||||
Text("Расписание откроется сразу и будет доступно без интернета.").foregroundStyle(.secondary)
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 4, bottom: 4, trailing: 4))
|
||||
}
|
||||
Section {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
|
||||
TextField("Номер группы, например 23-БА", text: $query)
|
||||
.textInputAutocapitalization(.characters)
|
||||
.autocorrectionDisabled()
|
||||
.submitLabel(.search)
|
||||
if !query.isEmpty {
|
||||
Button("Очистить", systemImage: "xmark.circle.fill") { query = "" }
|
||||
.labelStyle(.iconOnly).foregroundStyle(.secondary).buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.glassEffect(.regular.interactive(), in: .capsule)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets())
|
||||
}
|
||||
if let error {
|
||||
Banner(text: error, offline: true, onRetry: { self.error = nil; retries += 1 })
|
||||
.listRowBackground(Color.clear).listRowInsets(EdgeInsets())
|
||||
}
|
||||
if let message = model.status.message {
|
||||
Banner(text: message, offline: model.status.offline, onClose: { model.dismissMessage() }, onRetry: model.status.offline ? { Task { await model.retry() } } : nil)
|
||||
.listRowBackground(Color.clear).listRowInsets(EdgeInsets())
|
||||
}
|
||||
|
||||
if searching {
|
||||
let results = data?.results ?? []
|
||||
if !results.isEmpty {
|
||||
Section("Найдено: \(results.count)") { rows(results) }
|
||||
} else if data != nil {
|
||||
EmptyState(title: "Группа не найдена", text: "Группы «\(query.trimmed)» нет в каталоге \(data?.year ?? ""). Проверьте номер.", symbol: "magnifyingglass")
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
} else {
|
||||
if let current, !onboarding {
|
||||
Section("Сейчас открыта") {
|
||||
GroupRow(group: current.group, subtitle: describe(current.faculty, current.course))
|
||||
if let subgroups = model.timetable?.subgroups, !subgroups.isEmpty {
|
||||
Picker("Подгруппа", selection: Binding(get: { model.subgroup }, set: { model.setSubgroup($0) })) {
|
||||
Text("Все").tag(String?.none)
|
||||
ForEach(subgroups, id: \.self) { Text($0.replacingOccurrences(of: "Подгруппа ", with: "")).tag(Optional($0)) }
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
}
|
||||
}
|
||||
let recent = model.settings.recent.filter { $0.key != current?.key }
|
||||
if !recent.isEmpty {
|
||||
Section("Недавние") {
|
||||
ForEach(recent, id: \.key) { g in
|
||||
Button { Task { await model.open(g); opened() } } label: {
|
||||
GroupRow(group: g.group, subtitle: describe(g.faculty, g.course), chevron: true)
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let d = data {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Факультет").font(.subheadline.weight(.semibold)).foregroundStyle(.secondary)
|
||||
FlowLayout(spacing: 8) {
|
||||
ForEach(d.facets.faculties, id: \.name) { f in
|
||||
Chip(label: facultyShort(f.name) ?? f.name, on: faculty == f.name) {
|
||||
faculty = faculty == f.name ? "" : f.name
|
||||
course = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
if !faculty.isEmpty {
|
||||
Text("Курс").font(.subheadline.weight(.semibold)).foregroundStyle(.secondary).padding(.top, 4)
|
||||
FlowLayout(spacing: 8) {
|
||||
ForEach(d.facets.courses, id: \.name) { c in
|
||||
Chip(label: c.name, on: course == c.name) { course = course == c.name ? "" : c.name }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("Выберите факультет или введите номер группы. Всего в каталоге \(d.total) групп.")
|
||||
.font(.subheadline).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
if !faculty.isEmpty { rows(d.results) }
|
||||
} header: {
|
||||
Text("Каталог \(d.year)")
|
||||
}
|
||||
} else if error == nil {
|
||||
ProgressView().frame(maxWidth: .infinity).listRowBackground(Color.clear)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.navigationTitle(onboarding ? "" : "Группы")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
if !onboarding && current != nil {
|
||||
ToolbarItem(placement: .confirmationAction) { Button("Готово", systemImage: "checkmark") { dismiss() } }
|
||||
}
|
||||
}
|
||||
.task(id: "\(query)|\(faculty)|\(course)|\(retries)") {
|
||||
if !query.isEmpty { try? await Task.sleep(for: .milliseconds(250)) }
|
||||
guard !Task.isCancelled else { return }
|
||||
do {
|
||||
data = try await model.api.groups(query: query.trimmed, faculty: faculty, course: course)
|
||||
error = nil
|
||||
} catch is CancellationError {
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
.alert("Ваша подгруппа", isPresented: Binding(get: { askSubgroup != nil }, set: { if !$0 { askSubgroup = nil } })) {
|
||||
ForEach(askSubgroup ?? [], id: \.self) { s in
|
||||
Button(s) { model.setSubgroup(s); askSubgroup = nil; onOpened() }
|
||||
}
|
||||
Button("Все подгруппы") { model.setSubgroup(nil); askSubgroup = nil; onOpened() }
|
||||
Button("Позже", role: .cancel) { askSubgroup = nil; onOpened() }
|
||||
} message: {
|
||||
Text("В группе \(model.timetable?.group ?? "") занятия делятся на подгруппы. Выберите свою — чужие пары скроются из расписания, виджетов и напоминаний. Изменить можно в разделе «Группы».")
|
||||
}
|
||||
.onAppear { if faculty.isEmpty { faculty = current?.faculty ?? "" } }
|
||||
}
|
||||
|
||||
@ViewBuilder private func rows(_ results: [GroupResult]) -> some View {
|
||||
ForEach(results) { r in
|
||||
Button {
|
||||
opening = r.id
|
||||
Task {
|
||||
let ok = await model.open(r)
|
||||
opening = nil
|
||||
if ok { opened() }
|
||||
}
|
||||
} label: {
|
||||
GroupRow(group: r.group, subtitle: describe(r.faculty, r.course, r.form, r.period), chevron: opening != r.id, busy: opening == r.id)
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
.disabled(opening != nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// A group with subgroups asks which one is yours, unless you already chose it.
|
||||
private func opened() {
|
||||
let subgroups = model.timetable?.subgroups ?? []
|
||||
if !subgroups.isEmpty && model.subgroup == nil && !onboarding {
|
||||
askSubgroup = subgroups
|
||||
} else {
|
||||
onOpened()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct GroupRow: View {
|
||||
@Environment(\.palette) private var palette
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
var group: String
|
||||
var subtitle: String
|
||||
var chevron = false
|
||||
var busy = false
|
||||
|
||||
var body: some View {
|
||||
let label = String(group.split(separator: "-").dropFirst().first ?? Substring(group)).prefix(3)
|
||||
let c = palette.color(String(label), dark: scheme == .dark)
|
||||
HStack(spacing: 14) {
|
||||
Text(label.uppercased())
|
||||
.font(.footnote.weight(.bold))
|
||||
.foregroundStyle(c.onStrong)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(c.strong, in: .circle)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(group).font(.headline)
|
||||
Text(subtitle).font(.subheadline).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if busy { ProgressView() } else if chevron { Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(.tertiary) }
|
||||
}
|
||||
.contentShape(.rect)
|
||||
}
|
||||
}
|
||||
|
||||
private struct Chip: View {
|
||||
var label: String
|
||||
var on: Bool
|
||||
var action: () -> Void
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 4) {
|
||||
if on { Image(systemName: "checkmark").font(.caption.weight(.bold)) }
|
||||
Text(label).font(.subheadline.weight(.medium))
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.foregroundStyle(on ? Color.onBrand : .primary)
|
||||
.background(on ? Color.brand : Color(.tertiarySystemFill), in: .capsule)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityAddTraits(on ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
566
ios/PSUSchedule/Views/HomeView.swift
Normal file
566
ios/PSUSchedule/Views/HomeView.swift
Normal file
@@ -0,0 +1,566 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The overview: this week at a glance, the session countdown, today's lessons,
|
||||
/// attendance, tasks and the next study day. On iPad the week grid sits beside it.
|
||||
struct HomeView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(Router.self) private var router
|
||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||
@State private var details: LessonRef?
|
||||
@State private var editor: EditorRequest?
|
||||
@State private var session = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
content
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.navigationTitle("Обзор")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) { GroupButton() }
|
||||
ToolbarSpacer(.fixed, placement: .topBarTrailing)
|
||||
ToolbarItem(placement: .topBarTrailing) { SettingsButton() }
|
||||
}
|
||||
.refreshable { await model.refresh() }
|
||||
.navigationDestination(for: HomeRoute.self) { _ in SubjectsView() }
|
||||
}
|
||||
.sheet(item: $details) { LessonDetails(ref: $0).presentationDetents([.medium, .large]) }
|
||||
.sheet(item: $editor) { NoteEditor(request: $0) }
|
||||
.sheet(isPresented: $session) { SessionSheet(exams: model.upcomingExams).presentationDetents([.medium, .large]) }
|
||||
}
|
||||
|
||||
@ViewBuilder private var content: some View {
|
||||
if let t = model.timetable {
|
||||
if t.semester == nil {
|
||||
VStack(spacing: 16) {
|
||||
Banner(text: t.note ?? "Даты для этого расписания не рассчитаны.", offline: false).padding(.horizontal)
|
||||
EmptyState(
|
||||
title: "Обзор недоступен",
|
||||
text: "Строки Word по дням недели — во вкладке «Расписание».",
|
||||
symbol: "doc.text",
|
||||
action: ("Открыть", { router.tab = .schedule })
|
||||
)
|
||||
}
|
||||
} else if sizeClass == .regular {
|
||||
// iPad: the overview beside this week's grid, as School Planner pairs them.
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
ScrollView { overview(t) }.frame(width: 420)
|
||||
WeekPane(timetable: t) { details = $0 }
|
||||
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 28, style: .continuous))
|
||||
.padding(.trailing, 16)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
} else {
|
||||
ScrollView { overview(t).readableWidth() }
|
||||
}
|
||||
} else if model.status.loading {
|
||||
ProgressView().controlSize(.large).frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
VStack {
|
||||
StatusBanner().padding(.horizontal)
|
||||
EmptyState(
|
||||
title: "Расписание не загружено",
|
||||
text: "Выберите группу — обзор дня и недели появится здесь.",
|
||||
symbol: "person.2",
|
||||
action: model.settings.current != nil
|
||||
? ("Повторить", { Task { await model.refresh() } })
|
||||
: ("Выбрать группу", { router.groups = true })
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func overview(_ t: Timetable) -> some View {
|
||||
let today = model.today
|
||||
let todaySlots = model.slots(t, on: today)
|
||||
let next = (1...7).lazy.map { today.adding(days: $0) }.map { ($0, model.slots(t, on: $0)) }.first { !$0.1.isEmpty }
|
||||
return VStack(alignment: .leading, spacing: 16) {
|
||||
StatusBanner()
|
||||
WeekReport(timetable: t)
|
||||
SessionCard(timetable: t) { session = true }
|
||||
DayTitle(label: "Сегодня", date: today)
|
||||
TodayCard(slots: todaySlots, onOpen: { details = LessonRef(lesson: $0, date: today) }, onDay: { router.openDay(today) })
|
||||
AttendanceCard(slots: todaySlots)
|
||||
NavigationLink(value: HomeRoute.subjects) { SubjectsLink(timetable: t) }
|
||||
.buttonStyle(.plain)
|
||||
TasksCard(onEdit: { editor = EditorRequest(editing: $0) }, onAll: { router.tab = .plans })
|
||||
if let next {
|
||||
let (day, slots) = next
|
||||
DayTitle(label: day == today.adding(days: 1) ? "Завтра" : day.weekdayName, date: day)
|
||||
SectionCard(symbol: "calendar.badge.checkmark", title: pairs(slots.count)) {
|
||||
ForEach(slots) { slot in
|
||||
ForEach(slot.lessons, id: \.key) { lesson in
|
||||
Button { details = LessonRef(lesson: lesson, date: day) } label: { CompactLesson(lesson: lesson, slot: slot) }
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
DayButton { router.openDay(day) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.animation(.smooth, value: model.notes)
|
||||
.animation(.smooth, value: model.marks)
|
||||
}
|
||||
}
|
||||
|
||||
/// Screens the overview opens in its own stack.
|
||||
enum HomeRoute: Hashable {
|
||||
case subjects
|
||||
}
|
||||
|
||||
/// The way to the subjects: how many there are and how busy this week is.
|
||||
private struct SubjectsLink: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var timetable: Timetable
|
||||
|
||||
var body: some View {
|
||||
let subjects = Set((timetable.days.flatMap(\.lessons) + timetable.weekly.flatMap(\.lessons)).map { subjectKey($0.subject) }).count
|
||||
let monday = model.today.monday
|
||||
let week = (0..<7).reduce(0) { $0 + model.slots(timetable, on: monday.adding(days: $1)).count }
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "books.vertical")
|
||||
.font(.body.weight(.semibold))
|
||||
.foregroundStyle(Color.onBrand)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(Color.brand, in: .circle)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Предметы").font(.headline)
|
||||
Text("\(subjects) \(plural(subjects, "предмет", "предмета", "предметов")) · на этой неделе \(pairs(week))")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(.tertiary)
|
||||
}
|
||||
.card(padding: 16)
|
||||
.contentShape(.rect)
|
||||
}
|
||||
}
|
||||
|
||||
private struct DayTitle: View {
|
||||
var label: String
|
||||
var date: CivilDate
|
||||
var body: some View {
|
||||
HStack(alignment: .lastTextBaseline) {
|
||||
Text(label).font(.title2.bold()).accessibilityAddTraits(.isHeader)
|
||||
Spacer()
|
||||
Text(date.longLabel).font(.subheadline).foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private struct DayButton: View {
|
||||
var action: () -> Void
|
||||
var body: some View {
|
||||
Button(action: action) { Label("Открыть день", systemImage: "list.bullet.below.rectangle") }
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The current week at a glance: how many lessons are still ahead, a chip per day with its count
|
||||
* (past days ticked off, today highlighted) and the homework due this week. Once the week is
|
||||
* over it looks ahead to the next one.
|
||||
*/
|
||||
private struct WeekReport: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var timetable: Timetable
|
||||
|
||||
var body: some View {
|
||||
let t = timetable
|
||||
let date = model.today
|
||||
let clock = model.clock
|
||||
let monday = date.monday
|
||||
let days = (0..<7).map { monday.adding(days: $0) }.map { ($0, model.slots(t, on: $0)) }
|
||||
.filter { $0.0.weekday < 7 || !$0.1.isEmpty }
|
||||
let total = days.reduce(0) { $0 + $1.1.count }
|
||||
let left = days.reduce(0) { sum, day in
|
||||
sum + day.1.filter { $0.date > date || ($0.date == date && $0.end > clock) }.count
|
||||
}
|
||||
let week = t.day(monday)?.week
|
||||
let due = model.notes.filter { n in
|
||||
guard let d = CivilDate(n.date) else { return false }
|
||||
return n.task && !n.done && d >= date && d <= monday.adding(days: 6)
|
||||
}.count
|
||||
|
||||
SectionCard(symbol: "calendar", title: week.map { "Неделя \($0.n)" } ?? "Эта неделя") {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(headline(total: total, left: left)).font(.title3.bold())
|
||||
HStack(spacing: 12) {
|
||||
Text(weekSpan(monday, monday.adding(days: 6))).font(.subheadline).foregroundStyle(.secondary)
|
||||
if let week { WeekMark(week: week) }
|
||||
}
|
||||
}
|
||||
if total > 0 {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(days, id: \.0) { day in DayChip(date: day.0, count: day.1.count, today: date) }
|
||||
}
|
||||
}
|
||||
if due > 0 {
|
||||
Meta(symbol: "checklist", text: "Сдать до воскресенья: \(due) \(plural(due, "задание", "задания", "заданий"))")
|
||||
}
|
||||
if left == 0 {
|
||||
let next = monday.adding(days: 7)
|
||||
let nextTotal = (0..<7).reduce(0) { $0 + model.slots(t, on: next.adding(days: $1)).count }
|
||||
if nextTotal > 0 {
|
||||
let w = t.day(next)?.week
|
||||
Meta(symbol: "calendar.badge.checkmark", text: "Дальше " + (w.map { "неделя \($0.n), \($0.white ? "белая" : "зелёная"): " } ?? "") + pairs(nextTotal))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func headline(total: Int, left: Int) -> String {
|
||||
if total == 0 { return "На этой неделе пар нет" }
|
||||
if left == 0 { return "Пары этой недели закончились" }
|
||||
if left == total { return "Впереди " + pairs(total) }
|
||||
return "Осталось \(left) \(plural(left, "пара", "пары", "пар")) из \(total)"
|
||||
}
|
||||
}
|
||||
|
||||
/// A weekday with its number of lessons: ticked off once past, strong today.
|
||||
private struct DayChip: View {
|
||||
var date: CivilDate
|
||||
var count: Int
|
||||
var today: CivilDate
|
||||
|
||||
var body: some View {
|
||||
let past = date < today
|
||||
let isToday = date == today
|
||||
VStack(spacing: 4) {
|
||||
Text(date.shortWeekday).font(.caption.weight(.medium)).foregroundStyle(isToday ? Color.brand : .secondary)
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(count == 0 ? Color.clear : isToday ? Color.brand : past ? Color(.tertiarySystemFill) : Color.brand.opacity(0.15))
|
||||
Group {
|
||||
if count == 0 { Text("—") }
|
||||
else if past { Image(systemName: "checkmark").font(.footnote.weight(.bold)) }
|
||||
else { Text("\(count)").font(.subheadline.weight(.bold)) }
|
||||
}
|
||||
.foregroundStyle(isToday ? Color.onBrand : past || count == 0 ? .secondary : Color.brand)
|
||||
}
|
||||
.frame(height: 36)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("\(date.weekdayName): " + (count == 0 ? "пар нет" : pairs(count) + (past ? ", прошли" : "")))
|
||||
}
|
||||
}
|
||||
|
||||
/// The next lessons of today: the first in full with a countdown, the rest compact.
|
||||
private struct TodayCard: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var slots: [Slot]
|
||||
var onOpen: (Lesson) -> Void
|
||||
var onDay: () -> Void
|
||||
|
||||
var body: some View {
|
||||
let clock = model.clock
|
||||
let left = slots.filter { $0.end > clock }
|
||||
SectionCard(symbol: "clock", title: "Следующие занятия") {
|
||||
if slots.isEmpty {
|
||||
Quiet(title: "Сегодня занятий нет", text: "Отдохните — ближайшие пары ниже.")
|
||||
} else if left.isEmpty {
|
||||
Quiet(title: "Пары на сегодня закончились", text: "Сегодня было \(pairs(slots.count)).")
|
||||
} else {
|
||||
let first = left[0]
|
||||
ForEach(first.lessons, id: \.key) { lesson in
|
||||
Button { onOpen(lesson) } label: { FeaturedLesson(lesson: lesson, slot: first, clock: clock) }
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
if left.count > 1 { Divider() }
|
||||
ForEach(left.dropFirst()) { slot in
|
||||
ForEach(slot.lessons, id: \.key) { lesson in
|
||||
Button { onOpen(lesson) } label: { CompactLesson(lesson: lesson, slot: slot) }.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !slots.isEmpty { DayButton(action: onDay) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct Quiet: View {
|
||||
var title: String
|
||||
var text: String
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title).font(.headline)
|
||||
Text(text).font(.subheadline).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The next lesson in full: subject, time with a countdown, room and teacher.
|
||||
private struct FeaturedLesson: View {
|
||||
@Environment(\.display) private var show
|
||||
var lesson: Lesson
|
||||
var slot: Slot
|
||||
var clock: ClockTime
|
||||
|
||||
var body: some View {
|
||||
let running = clock >= slot.start
|
||||
let minutes = (running ? slot.end : slot.start).minutes - clock.minutes
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
SubjectDot(subject: lesson.subject, size: 14).padding(.top, 6)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(lesson.subject).font(.title3.bold()).lineLimit(3).foregroundStyle(.primary)
|
||||
HStack(spacing: 10) {
|
||||
Text(slot.timeLabel).font(.body.monospacedDigit()).foregroundStyle(.secondary)
|
||||
Text(running ? "Идёт · ещё \(minutesLabel(minutes))" : "Через \(minutesLabel(minutes))")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.padding(.horizontal, 10).padding(.vertical, 4)
|
||||
.foregroundStyle(running ? Color.onBrand : Color.brand)
|
||||
.background(running ? Color.brand : Color.brand.opacity(0.14), in: .capsule)
|
||||
}
|
||||
if lesson.distance { Meta(symbol: "mappin.and.ellipse", text: "Дистанционно") }
|
||||
else if show.rooms && !lesson.rooms.isEmpty { Meta(symbol: "mappin.and.ellipse", text: "Ауд. " + lesson.rooms.joined(separator: ", ")) }
|
||||
if show.teachers && !lesson.teachers.isEmpty { Meta(symbol: "person", text: lesson.teachers.joined(separator: ", ")) }
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.contentShape(.rect)
|
||||
}
|
||||
}
|
||||
|
||||
/// One-tap attendance for today's begun lessons without a mark; leaves once all are marked.
|
||||
private struct AttendanceCard: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var slots: [Slot]
|
||||
|
||||
var body: some View {
|
||||
let date = model.today
|
||||
let open = slots.filter { $0.start <= model.clock }
|
||||
.flatMap { slot in slot.lessons.map { (slot, $0) } }
|
||||
.filter { model.marks.forLesson($0.1.subject, date, $0.1.start) == nil }
|
||||
if model.groupKey != nil && !open.isEmpty {
|
||||
SectionCard(symbol: "person.badge.clock", title: "Отметьте посещаемость") {
|
||||
ForEach(open, id: \.1.key) { item in
|
||||
let (slot, lesson) = item
|
||||
HStack(spacing: 12) {
|
||||
SubjectDot(subject: lesson.subject, size: 10)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(lesson.subject).font(.subheadline.weight(.semibold)).lineLimit(2)
|
||||
Text(slot.timeLabel).font(.subheadline.monospacedDigit()).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
GlassEffectContainer(spacing: 6) {
|
||||
HStack(spacing: 6) {
|
||||
Button { model.mark(lesson, on: date, present: true) } label: { Image(systemName: "checkmark") }
|
||||
.buttonStyle(.glass)
|
||||
.tint(.green)
|
||||
.accessibilityLabel("Был на паре «\(lesson.subject)»")
|
||||
Button { model.mark(lesson, on: date, present: false) } label: { Image(systemName: "xmark") }
|
||||
.buttonStyle(.glass)
|
||||
.tint(.red)
|
||||
.accessibilityLabel("Пропустил пару «\(lesson.subject)»")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Homework and notes ahead: open tasks (overdue ones flagged) and notes for today and later,
|
||||
* soonest first. Ticked tasks stay struck through until their day has passed.
|
||||
*/
|
||||
private struct TasksCard: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var onEdit: (Note) -> Void
|
||||
var onAll: () -> Void
|
||||
@State private var expanded = false
|
||||
|
||||
var body: some View {
|
||||
let date = model.today
|
||||
let pending = model.notes
|
||||
.filter { (CivilDate($0.date) ?? date) >= date || ($0.task && !$0.done) }
|
||||
.sorted { a, b in
|
||||
let ka = (a.task && a.done ? 1 : 0, a.date, a.start)
|
||||
let kb = (b.task && b.done ? 1 : 0, b.date, b.start)
|
||||
return ka < kb
|
||||
}
|
||||
if !pending.isEmpty {
|
||||
SectionCard(symbol: "checklist", title: "Задания и заметки", trailing: "\(pending.open.count)") {
|
||||
ForEach(expanded ? pending : Array(pending.prefix(4))) { note in
|
||||
NoteRow(note: note) { onEdit(note) }
|
||||
}
|
||||
HStack(spacing: 20) {
|
||||
if pending.count > 4 {
|
||||
Button(expanded ? "Свернуть" : "Показать все (\(pending.count))") { withAnimation(.smooth) { expanded.toggle() } }
|
||||
}
|
||||
Button("Все планы", action: onAll)
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session
|
||||
|
||||
/**
|
||||
* The session countdown: days to the next exam or credit, with the subject, date and room.
|
||||
* Before the university publishes the session it counts down to the end of the semester.
|
||||
*/
|
||||
private struct SessionCard: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var timetable: Timetable
|
||||
var onOpen: () -> Void
|
||||
|
||||
var body: some View {
|
||||
let today = model.today
|
||||
let exams = model.upcomingExams
|
||||
let next = exams.first(where: \.counts) ?? exams.first
|
||||
let semesterEnd = timetable.semester.flatMap { CivilDate($0.end) }.flatMap { $0 >= today ? $0 : nil }
|
||||
if let target = next.flatMap({ CivilDate($0.date) }) ?? semesterEnd {
|
||||
let days = today.days(to: target)
|
||||
let card = HStack(spacing: 18) {
|
||||
Countdown(days: days)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(next.map { "Сессия · \($0.type.lowercased()) \(countdownLabel(days))" } ?? "До конца семестра \(countdownLabel(days).replacingOccurrences(of: "через ", with: ""))")
|
||||
.font(.subheadline.weight(.semibold)).foregroundStyle(Color.brand)
|
||||
if let next {
|
||||
Text(next.subject).font(.headline).lineLimit(2)
|
||||
Text(examWhen(next)).font(.subheadline).foregroundStyle(.secondary)
|
||||
if exams.count > 1 { Text("Ещё \(exams.count - 1) в сессии").font(.footnote).foregroundStyle(.secondary).padding(.top, 4) }
|
||||
} else {
|
||||
Text(timetable.semester?.label ?? "Семестр").font(.headline)
|
||||
Text("Учёба до \(semesterEnd!.longLabel). Даты экзаменов появятся здесь, когда университет опубликует расписание сессии.")
|
||||
.font(.subheadline).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
.card()
|
||||
// Only a published session opens a sheet; the semester countdown is just information.
|
||||
if next != nil {
|
||||
Button(action: onOpen) { card }.buttonStyle(.plain)
|
||||
} else {
|
||||
card
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The big number in a glass circle, tinted when the day is close.
|
||||
private struct Countdown: View {
|
||||
var days: Int
|
||||
var body: some View {
|
||||
let urgent = days <= 3
|
||||
VStack(spacing: 0) {
|
||||
Text(days == 0 ? "!" : "\(days)").font(.title.bold()).monospacedDigit()
|
||||
Text(days == 0 ? "сегодня" : days == 1 ? "день" : plural(days, "день", "дня", "дней")).font(.caption.weight(.medium))
|
||||
}
|
||||
.foregroundStyle(urgent ? Color.orange : Color.brand)
|
||||
.frame(width: 84, height: 84)
|
||||
.glassEffect(.regular.tint((urgent ? Color.orange : Color.brand).opacity(0.18)), in: .circle)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
func countdownLabel(_ days: Int) -> String {
|
||||
switch days {
|
||||
case 0: "сегодня"
|
||||
case 1: "завтра"
|
||||
default: "через \(days) \(plural(days, "день", "дня", "дней"))"
|
||||
}
|
||||
}
|
||||
|
||||
/// "Пн, 12 января, 09:00 · ауд. 213А".
|
||||
func examWhen(_ e: Exam) -> String {
|
||||
guard let d = CivilDate(e.date) else { return e.date }
|
||||
return d.shortWeekday + ", " + d.longLabel + ", " + e.start + (e.rooms.isEmpty ? "" : " · ауд. " + e.rooms.joined(separator: ", "))
|
||||
}
|
||||
|
||||
/// The whole session in date order, consultations included.
|
||||
struct SessionSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
var exams: [Exam]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
let counted = exams.filter(\.counts).count
|
||||
Section {
|
||||
ForEach(exams, id: \.self) { e in
|
||||
let d = CivilDate(e.date) ?? .today()
|
||||
HStack(spacing: 16) {
|
||||
VStack {
|
||||
Text("\(d.day)").font(.title2.bold())
|
||||
Text(d.shortWeekday).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(width: 44)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(e.type + " · " + countdownLabel(CivilDate.today().days(to: d)))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(e.counts ? Color.brand : .secondary)
|
||||
Text(e.subject).font(.headline)
|
||||
Text([e.start + (e.end.map { "–\($0)" } ?? ""), e.rooms.isEmpty ? nil : "ауд. " + e.rooms.joined(separator: ", "), e.teachers.isEmpty ? nil : e.teachers.joined(separator: ", ")].compactMap { $0 }.joined(separator: " · "))
|
||||
.font(.subheadline).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("\(counted) \(plural(counted, "экзамен или зачёт", "экзамена или зачёта", "экзаменов и зачётов"))" + (exams.count > counted ? ", консультаций: \(exams.count - counted)" : ""))
|
||||
} footer: {
|
||||
Text("Даты собраны из всех документов каталога по пометкам «экз.», «зач.» и «конс.». Сверяйтесь с документом Word.")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Сессия")
|
||||
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Готово", systemImage: "checkmark") { dismiss() } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iPad week pane
|
||||
|
||||
/// This week's grid beside the overview; on Sunday it already shows the coming week.
|
||||
private struct WeekPane: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var timetable: Timetable
|
||||
var onOpen: (LessonRef) -> Void
|
||||
@State private var monday: CivilDate?
|
||||
|
||||
var body: some View {
|
||||
let first = (model.today.weekday == 7 ? model.today.adding(days: 1) : model.today).monday
|
||||
let shown = monday ?? first
|
||||
let loaded = Set(timetable.dates)
|
||||
let week = timetable.day(shown)?.week
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(week.map { "Неделя \($0.n)" } ?? "Неделя").font(.headline)
|
||||
HStack(spacing: 10) {
|
||||
Text(weekSpan(shown, shown.adding(days: 6))).font(.subheadline).foregroundStyle(.secondary)
|
||||
if let week { WeekMark(week: week, short: true) }
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
GlassEffectContainer(spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
if shown != first {
|
||||
Button("Эта неделя", systemImage: "calendar.badge.clock") { withAnimation(.smooth) { monday = first } }
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
Button("Предыдущая неделя", systemImage: "chevron.left") { withAnimation(.smooth) { monday = shown.adding(days: -7) } }
|
||||
.labelStyle(.iconOnly)
|
||||
.disabled(!loaded.contains(shown.adding(days: -7)))
|
||||
Button("Следующая неделя", systemImage: "chevron.right") { withAnimation(.smooth) { monday = shown.adding(days: 7) } }
|
||||
.labelStyle(.iconOnly)
|
||||
.disabled(!loaded.contains(shown.adding(days: 7)))
|
||||
}
|
||||
.buttonStyle(.glass)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
WeekGrid(timetable: timetable, monday: shown, onOpen: onOpen)
|
||||
.id(shown)
|
||||
.transition(.push(from: .trailing))
|
||||
}
|
||||
}
|
||||
}
|
||||
504
ios/PSUSchedule/Views/LessonViews.swift
Normal file
504
ios/PSUSchedule/Views/LessonViews.swift
Normal file
@@ -0,0 +1,504 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A lesson opened in the details sheet; `date` is nil for Word rows without dates.
|
||||
struct LessonRef: Identifiable, Hashable {
|
||||
var lesson: Lesson
|
||||
var date: CivilDate?
|
||||
var id: String { (date?.iso ?? "-") + "|" + lesson.start + "|" + lesson.key }
|
||||
}
|
||||
|
||||
/// A lesson as a soft card in its subject's colour, like School Planner's day list.
|
||||
struct LessonCard: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.display) private var show
|
||||
var lesson: Lesson
|
||||
var time: String?
|
||||
var date: CivilDate?
|
||||
var status: String?
|
||||
var running = false
|
||||
|
||||
var body: some View {
|
||||
WithSubjectColor(subject: lesson.subject) { c in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text(lesson.subject).font(.headline).frame(maxWidth: .infinity, alignment: .leading)
|
||||
if let status {
|
||||
Text(status)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.padding(.horizontal, 8).padding(.vertical, 3)
|
||||
.foregroundStyle(running ? c.soft : c.onSoft)
|
||||
.background(running ? c.onSoft : c.onSoft.opacity(0.12), in: .rect(cornerRadius: 8, style: .continuous))
|
||||
}
|
||||
}
|
||||
if let time {
|
||||
Text(time + (show.pairs ? lesson.pair.map { " · \($0) пара" } ?? "" : ""))
|
||||
.font(.body.monospacedDigit())
|
||||
}
|
||||
FlowLayout(spacing: 14) {
|
||||
if lesson.distance { Meta(symbol: "mappin.and.ellipse", text: "Дистанционно") }
|
||||
else if show.rooms && !lesson.rooms.isEmpty {
|
||||
Meta(symbol: "mappin.and.ellipse", text: lesson.rooms.joined(separator: ", ") + (lesson.roomMoved ? " (перенос)" : ""))
|
||||
}
|
||||
if show.teachers && !lesson.teachers.isEmpty { Meta(symbol: "person", text: lesson.teachers.joined(separator: ", ")) }
|
||||
if let sub = lesson.subgroup { Meta(symbol: "person.2", text: sub) }
|
||||
}
|
||||
let mark = date.flatMap { model.marks.forLesson(lesson.subject, $0, lesson.start) }
|
||||
let tags = show.tags ? tagsOf(lesson) : []
|
||||
if !tags.isEmpty || lesson.uncertain || mark != nil {
|
||||
FlowLayout(spacing: 6) {
|
||||
if let mark {
|
||||
Tag(text: mark.present ? "был" : "пропуск", symbol: mark.present ? "checkmark" : "xmark", foreground: c.onSoft, background: c.onSoft.opacity(0.12))
|
||||
}
|
||||
ForEach(tags, id: \.self) { Tag(text: $0, foreground: c.onSoft, background: c.onSoft.opacity(0.12)) }
|
||||
if lesson.uncertain { Tag(text: "Сверьте недели", foreground: .red, background: .red.opacity(0.14)) }
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
if show.notes && !lesson.condition.isEmpty {
|
||||
Meta(symbol: "repeat", text: "Пометка в Word: \(lesson.condition)")
|
||||
}
|
||||
if let date { NotesHint(notes: model.notes.forLesson(lesson.subject, date, lesson.start)) }
|
||||
}
|
||||
.foregroundStyle(c.onSoft)
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(c.soft, in: .rect(cornerRadius: 22, style: .continuous))
|
||||
.overlay {
|
||||
if running { RoundedRectangle(cornerRadius: 22, style: .continuous).strokeBorder(c.onSoft, lineWidth: 2) }
|
||||
}
|
||||
.contentShape(.rect(cornerRadius: 22))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func tagsOf(_ lesson: Lesson) -> [String] {
|
||||
var tags: [String] = []
|
||||
if !lesson.type.isEmpty { tags.append(lesson.type) }
|
||||
if lesson.variant != "all" { tags.append(lesson.variant == "upper" ? "белые недели" : "зелёные недели") }
|
||||
if lesson.groups.count > 1 { tags.append("общее занятие") }
|
||||
return tags
|
||||
}
|
||||
|
||||
/// A one-line hint on a lesson card: the first open task or note and how many more.
|
||||
struct NotesHint: View {
|
||||
var notes: [Note]
|
||||
var body: some View {
|
||||
let open = notes.open
|
||||
if let first = open.first {
|
||||
Label {
|
||||
Text(first.text + (open.count > 1 ? " +\(open.count - 1)" : "")).lineLimit(1)
|
||||
} icon: {
|
||||
Image(systemName: first.task ? "checklist" : "note.text")
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Later lessons in a card: a coloured dot, the subject and "13:40 → 15:05 · ауд. 254В".
|
||||
struct CompactLesson: View {
|
||||
@Environment(\.display) private var show
|
||||
var lesson: Lesson
|
||||
var slot: Slot
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
SubjectDot(subject: lesson.subject, size: 10)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(lesson.subject).font(.subheadline.weight(.semibold)).lineLimit(2).foregroundStyle(.primary)
|
||||
Text(details).font(.subheadline.monospacedDigit()).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.contentShape(.rect)
|
||||
}
|
||||
|
||||
private var details: String {
|
||||
var parts = [slot.timeLabel]
|
||||
if lesson.distance { parts.append("дистанционно") }
|
||||
else if show.rooms && !lesson.rooms.isEmpty { parts.append("ауд. " + lesson.rooms.joined(separator: ", ")) }
|
||||
if show.tags && !lesson.type.isEmpty { parts.append(lesson.type.lowercased()) }
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Details
|
||||
|
||||
/// Everything about one lesson: time, teacher, room, attendance, tasks and the Word record.
|
||||
struct LessonDetails: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
var ref: LessonRef
|
||||
@State private var editor: EditorRequest?
|
||||
|
||||
var body: some View {
|
||||
let lesson = ref.lesson
|
||||
let bell = model.timetable?.bells.first { $0.start == lesson.start }
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
TypeTag(type: lesson.type)
|
||||
HStack(alignment: .firstTextBaseline, spacing: 12) {
|
||||
SubjectDot(subject: lesson.subject, size: 14)
|
||||
Text(lesson.subject).font(.title2.bold()).accessibilityAddTraits(.isHeader)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
DetailRow(
|
||||
symbol: "clock",
|
||||
label: bell.map { "\($0.pair) пара" } ?? "Время",
|
||||
value: lesson.start + (lesson.end.map { " — \($0)" } ?? ""),
|
||||
extra: bell.flatMap { $0.halves.count > 1 ? $0.halves.map { $0.joined(separator: "–") }.joined(separator: ", ") : nil }
|
||||
)
|
||||
DetailRow(symbol: "person", label: "Преподаватель", value: lesson.teachers.isEmpty ? "Не указан" : lesson.teachers.joined(separator: ", "))
|
||||
DetailRow(
|
||||
symbol: "mappin.and.ellipse",
|
||||
label: "Аудитория",
|
||||
value: lesson.distance ? "Дистанционно" : (lesson.rooms.isEmpty ? "Не указана" : lesson.rooms.joined(separator: ", ")),
|
||||
extra: lesson.roomMoved ? "В этот день — перенос аудитории" : nil
|
||||
)
|
||||
DetailRow(symbol: "person.3", label: "Группы", value: lesson.groups.joined(separator: ", "), extra: lesson.subgroup)
|
||||
}
|
||||
.card()
|
||||
if lesson.uncertain {
|
||||
Banner(text: "Пометку «\(lesson.condition)» не удалось сопоставить с таблицей недель. Сверьте с Word.", offline: false)
|
||||
}
|
||||
if let date = ref.date, model.groupKey != nil {
|
||||
if model.started(date, lesson.start) { AttendancePicker(lesson: lesson, date: date) }
|
||||
LessonNotes(lesson: lesson, date: date, editor: $editor)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Исходная запись").font(.headline)
|
||||
Text(lesson.raw)
|
||||
.font(.body)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(16)
|
||||
.background(Color(.tertiarySystemFill), in: .rect(cornerRadius: 18, style: .continuous))
|
||||
}
|
||||
if model.groupKey != nil {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Button(role: .destructive) {
|
||||
model.setHidden(lesson.hideKey, true)
|
||||
dismiss()
|
||||
} label: {
|
||||
Label("Скрыть такие занятия", systemImage: "eye.slash")
|
||||
}
|
||||
.buttonStyle(.glass)
|
||||
Text("«\(lesson.subject)»\(lesson.type.isEmpty ? "" : " · " + lesson.type.lowercased()) пропадёт из расписания, обзора, виджетов и напоминаний во все недели. Вернуть можно в настройках.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.navigationTitle(ref.date.map { $0.weekdayName + ", " + $0.longLabel } ?? "Занятие")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Готово", systemImage: "checkmark") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(item: $editor) { NoteEditor(request: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
private struct TypeTag: View {
|
||||
var type: String
|
||||
var body: some View {
|
||||
let color: Color = switch type {
|
||||
case "Лекция": .brand
|
||||
case "Практика": .purple
|
||||
case "Экзамен", "Зачёт": .red
|
||||
default: .gray
|
||||
}
|
||||
Text(type.isEmpty ? "Занятие" : type)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.horizontal, 10).padding(.vertical, 4)
|
||||
.foregroundStyle(color)
|
||||
.background(color.opacity(0.14), in: .capsule)
|
||||
}
|
||||
}
|
||||
|
||||
private struct DetailRow: View {
|
||||
var symbol: String
|
||||
var label: String
|
||||
var value: String
|
||||
var extra: String?
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
Image(systemName: symbol).foregroundStyle(.secondary).frame(width: 22)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label).font(.subheadline).foregroundStyle(.secondary)
|
||||
Text(value).font(.body.weight(.medium))
|
||||
if let extra { Text(extra).font(.subheadline).foregroundStyle(.secondary) }
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
/// "Был" / "Пропустил"; choosing the marked answer again takes the mark back.
|
||||
private struct AttendancePicker: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var lesson: Lesson
|
||||
var date: CivilDate
|
||||
|
||||
var body: some View {
|
||||
let mark = model.marks.forLesson(lesson.subject, date, lesson.start)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Посещаемость").font(.headline)
|
||||
GlassEffectContainer(spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
choice("Был", "checkmark", value: true, current: mark?.present)
|
||||
choice("Пропустил", "xmark", value: false, current: mark?.present)
|
||||
}
|
||||
}
|
||||
if mark != nil {
|
||||
Text("Нажмите на отметку ещё раз, чтобы снять её.").font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func choice(_ label: String, _ symbol: String, value: Bool, current: Bool?) -> some View {
|
||||
let on = current == value
|
||||
return Button {
|
||||
model.mark(lesson, on: date, present: on ? nil : value)
|
||||
} label: {
|
||||
Label(label, systemImage: on ? symbol : "circle").frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.glass)
|
||||
.tint(on ? (value ? .green : .red) : nil)
|
||||
.accessibilityAddTraits(on ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tasks and notes
|
||||
|
||||
/// What the editor needs to file a new note: the lesson it opened from and the next one.
|
||||
struct NoteTarget: Hashable {
|
||||
var subject: String
|
||||
var date: CivilDate
|
||||
var start: String
|
||||
var next: NextLesson?
|
||||
}
|
||||
|
||||
struct NextLesson: Hashable {
|
||||
var date: CivilDate
|
||||
var start: String
|
||||
}
|
||||
|
||||
/// Opens the editor for a new note (target) or an existing one (editing).
|
||||
struct EditorRequest: Identifiable {
|
||||
var target: NoteTarget?
|
||||
var editing: Note?
|
||||
var id: String { editing?.id ?? "new-" + (target.map { $0.date.iso + $0.start + $0.subject } ?? "") }
|
||||
}
|
||||
|
||||
/// The lesson sheet's section: this lesson's tasks and notes and a button to add one.
|
||||
private struct LessonNotes: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var lesson: Lesson
|
||||
var date: CivilDate
|
||||
@Binding var editor: EditorRequest?
|
||||
|
||||
var body: some View {
|
||||
let notes = model.notes.forLesson(lesson.subject, date, lesson.start)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Задания и заметки").font(.headline)
|
||||
if notes.isEmpty {
|
||||
Text("Запишите домашнее задание или напоминание — оно появится в карточке пары и в обзоре.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
ForEach(notes) { note in
|
||||
NoteRow(note: note, showSubject: false) { editor = EditorRequest(editing: note) }
|
||||
}
|
||||
Button {
|
||||
let next = model.nextOccurrence(of: lesson.subject, after: date, start: lesson.start)
|
||||
editor = EditorRequest(target: NoteTarget(
|
||||
subject: lesson.subject, date: date, start: lesson.start,
|
||||
next: next.map { NextLesson(date: $0.0, start: $0.1.start) }
|
||||
))
|
||||
} label: {
|
||||
Label("Добавить", systemImage: "plus")
|
||||
}
|
||||
.buttonStyle(.glass)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A task with its checkbox or a note with its icon; a tap opens the editor.
|
||||
struct NoteRow: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var note: Note
|
||||
var showSubject = true
|
||||
var showDue = true
|
||||
var onEdit: () -> Void
|
||||
|
||||
var body: some View {
|
||||
let overdue = note.overdue(today: model.today)
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
if note.task {
|
||||
Button {
|
||||
withAnimation(.snappy) { model.toggle(note) }
|
||||
} label: {
|
||||
Image(systemName: note.done ? "checkmark.circle.fill" : "circle")
|
||||
.font(.title2)
|
||||
.foregroundStyle(note.done ? Color.brand : Color.secondary)
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(note.done ? "Выполнено" : "Не выполнено")
|
||||
} else {
|
||||
Image(systemName: "note.text").font(.title3).foregroundStyle(.secondary).frame(width: 28)
|
||||
}
|
||||
Button(action: onEdit) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(note.text)
|
||||
.strikethrough(note.task && note.done)
|
||||
.foregroundStyle(note.task && note.done ? .secondary : .primary)
|
||||
.lineLimit(3)
|
||||
HStack(spacing: 6) {
|
||||
if showSubject && !note.general { SubjectDot(subject: note.subject, size: 8) }
|
||||
Text(subtitle(overdue: overdue))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(overdue ? Color.red : Color.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func subtitle(overdue: Bool) -> String {
|
||||
var parts: [String] = []
|
||||
if showSubject { parts.append(note.general ? "Общий план" : note.subject) }
|
||||
if overdue { parts.append("просрочено") }
|
||||
else if showDue { parts.append(note.dueLabel(today: model.today)) }
|
||||
else if !note.start.isEmpty { parts.append("к " + note.start) }
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds or edits a task or a note. A new one goes to this lesson or, for homework set in
|
||||
* class, to the next lesson of the subject; once the lesson is over the next one is the default.
|
||||
*/
|
||||
struct NoteEditor: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
var request: EditorRequest
|
||||
@State private var text = ""
|
||||
@State private var task = true
|
||||
@State private var toNext = false
|
||||
@State private var day = Date()
|
||||
@FocusState private var focused: Bool
|
||||
|
||||
private var general: Bool { request.editing?.general ?? (request.target?.subject.trimmed.isEmpty ?? true) }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
Picker("Вид", selection: $task) {
|
||||
Text("Задание").tag(true)
|
||||
Text("Заметка").tag(false)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets())
|
||||
}
|
||||
Section {
|
||||
TextField(task ? "Например, сдать лабораторную 3" : "Например, принести конспект", text: $text, axis: .vertical)
|
||||
.lineLimit(3...8)
|
||||
.focused($focused)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
}
|
||||
if general {
|
||||
Section("День") {
|
||||
DatePicker("День", selection: $day, displayedComponents: .date)
|
||||
.environment(\.timeZone, minsk)
|
||||
}
|
||||
}
|
||||
if request.editing == nil, let target = request.target, let next = target.next {
|
||||
Section {
|
||||
Picker("К какому занятию", selection: $toNext) {
|
||||
Text("Эта пара").tag(false)
|
||||
Text("Следующая").tag(true)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
} header: {
|
||||
Text("К какому занятию")
|
||||
} footer: {
|
||||
let (d, start) = toNext ? (next.date, next.start) : (target.date, target.start)
|
||||
Text(d.shortWeekday + ", " + d.longLabel + ", " + start)
|
||||
}
|
||||
}
|
||||
if let editing = request.editing {
|
||||
Section {
|
||||
Button("Удалить", role: .destructive) {
|
||||
model.delete(editing)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Отмена", systemImage: "xmark") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Сохранить", systemImage: "checkmark") { save() }
|
||||
.disabled(text.trimmed.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
.onAppear {
|
||||
if let e = request.editing {
|
||||
text = e.text
|
||||
task = e.task
|
||||
day = (CivilDate(e.date) ?? model.today).date
|
||||
} else if let t = request.target {
|
||||
day = t.date.date
|
||||
// Once the lesson has begun, homework most likely belongs to the next one.
|
||||
toNext = t.next != nil && model.started(t.date, t.start) && !t.start.isEmpty
|
||||
}
|
||||
focused = true
|
||||
}
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
if request.editing != nil { return task ? "Задание" : "Заметка" }
|
||||
if general { return task ? "Новый общий план" : "Новая заметка на день" }
|
||||
return task ? "Новое задание" : "Новая заметка"
|
||||
}
|
||||
|
||||
private func save() {
|
||||
guard let group = request.editing?.group ?? model.groupKey else { return }
|
||||
var note: Note
|
||||
if let e = request.editing {
|
||||
note = e
|
||||
} else if let t = request.target {
|
||||
let (d, start) = toNext && t.next != nil ? (t.next!.date, t.next!.start) : (t.date, t.start)
|
||||
note = Note(group: group, subject: t.subject, date: d.iso, start: start, text: "")
|
||||
} else { return }
|
||||
if general { note.date = CivilDate(day).iso }
|
||||
note.text = text.trimmed
|
||||
note.task = task
|
||||
model.save(note)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
306
ios/PSUSchedule/Views/OnboardingView.swift
Normal file
306
ios/PSUSchedule/Views/OnboardingView.swift
Normal file
@@ -0,0 +1,306 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The first run, in the order a student needs it: what this is, their group, subgroup, reminders.
|
||||
private enum Step: Int, Comparable {
|
||||
case welcome, group, subgroup, reminders
|
||||
static func < (a: Step, b: Step) -> Bool { a.rawValue < b.rawValue }
|
||||
}
|
||||
|
||||
/**
|
||||
* First-run onboarding. Only the group is required: the welcome leads straight to it, the
|
||||
* subgroup page appears only for groups split into subgroups, and reminders can wait.
|
||||
*/
|
||||
struct OnboardingView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var onDone: () -> Void
|
||||
@State private var step = Step.welcome
|
||||
@State private var forward = true
|
||||
|
||||
var body: some View {
|
||||
let subgroups = model.timetable?.subgroups ?? []
|
||||
// The steps the progress counts; the welcome is not one of them.
|
||||
let steps: [Step] = subgroups.isEmpty ? [.group, .reminders] : [.group, .subgroup, .reminders]
|
||||
VStack(spacing: 0) {
|
||||
if step != .welcome {
|
||||
StepBar(index: steps.firstIndex(of: step) ?? 0, count: steps.count) { go(previous(step, subgroups)) }
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
ZStack {
|
||||
switch step {
|
||||
case .welcome:
|
||||
Welcome { go(.group) }.transition(slide)
|
||||
case .group:
|
||||
NavigationStack {
|
||||
GroupsView(onboarding: true) {
|
||||
go((model.timetable?.subgroups ?? []).isEmpty ? .reminders : .subgroup)
|
||||
}
|
||||
}
|
||||
.transition(slide)
|
||||
case .subgroup:
|
||||
SubgroupStep(group: model.timetable?.group ?? "", subgroups: subgroups) { choice in
|
||||
model.setSubgroup(choice)
|
||||
go(.reminders)
|
||||
}
|
||||
.transition(slide)
|
||||
case .reminders:
|
||||
RemindersStep { on in
|
||||
model.setReminders(on)
|
||||
onDone()
|
||||
}
|
||||
.transition(slide)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
}
|
||||
|
||||
private var slide: AnyTransition {
|
||||
.asymmetric(
|
||||
insertion: .move(edge: forward ? .trailing : .leading).combined(with: .opacity),
|
||||
removal: .move(edge: forward ? .leading : .trailing).combined(with: .opacity)
|
||||
)
|
||||
}
|
||||
|
||||
private func go(_ next: Step) {
|
||||
forward = next > step
|
||||
withAnimation(.smooth(duration: 0.45)) { step = next }
|
||||
}
|
||||
|
||||
private func previous(_ s: Step, _ subgroups: [String]) -> Step {
|
||||
switch s {
|
||||
case .welcome, .group: .welcome
|
||||
case .subgroup: .group
|
||||
case .reminders: subgroups.isEmpty ? .group : .subgroup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Back to the previous step and how far along the student is.
|
||||
private struct StepBar: View {
|
||||
var index: Int
|
||||
var count: Int
|
||||
var onBack: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
Button("Назад", systemImage: "chevron.left", action: onBack)
|
||||
.labelStyle(.iconOnly)
|
||||
.buttonStyle(.glass)
|
||||
.buttonBorderShape(.circle)
|
||||
.controlSize(.large)
|
||||
ProgressView(value: Double(index + 1), total: Double(count))
|
||||
.tint(.brand)
|
||||
.animation(.smooth, value: index)
|
||||
.accessibilityHidden(true)
|
||||
Text("\(index + 1) из \(count)").font(.subheadline.weight(.semibold)).foregroundStyle(.secondary).monospacedDigit()
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private struct Feature: Identifiable {
|
||||
var symbol: String
|
||||
var color: Color
|
||||
var title: String
|
||||
var text: String
|
||||
var id: String { title }
|
||||
}
|
||||
|
||||
private let features = [
|
||||
Feature(symbol: "calendar", color: .brand, title: "Пары на каждый день", text: "Даты уже рассчитаны по белым и зелёным неделям"),
|
||||
Feature(symbol: "checklist", color: .greenWeek, title: "Задания к занятиям", text: "Планы и заметки рядом с предметами"),
|
||||
Feature(symbol: "arrow.down.circle", color: .purple, title: "Без интернета", text: "Расписание хранится на телефоне"),
|
||||
]
|
||||
|
||||
private struct Welcome: View {
|
||||
var onStart: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 28) {
|
||||
Hero().padding(.top, 32)
|
||||
VStack(spacing: 10) {
|
||||
Text("Расписание ПГУ").font(.largeTitle.bold()).accessibilityAddTraits(.isHeader)
|
||||
Text("Пары вашей группы из документов университета — без таблиц Word на маленьком экране.")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
ForEach(features) { f in
|
||||
HStack(spacing: 16) {
|
||||
Image(systemName: f.symbol)
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(f.color)
|
||||
.frame(width: 48, height: 48)
|
||||
.glassEffect(.regular.tint(f.color.opacity(0.18)), in: .circle)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(f.title).font(.headline)
|
||||
Text(f.text).font(.subheadline).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(.horizontal, 28)
|
||||
.frame(maxWidth: 520)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.scrollBounceBehavior(.basedOnSize)
|
||||
.safeAreaBar(edge: .bottom) {
|
||||
VStack(spacing: 10) {
|
||||
Button(action: onStart) {
|
||||
Text("Найти свою группу").font(.headline).frame(maxWidth: .infinity).padding(.vertical, 6)
|
||||
}
|
||||
.buttonStyle(.glassProminent)
|
||||
.controlSize(.large)
|
||||
Text("Неофициальное приложение. Расписание берётся из публичного каталога ПГУ.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(.horizontal, 28)
|
||||
.padding(.bottom, 8)
|
||||
.frame(maxWidth: 520)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The welcome's picture: the three features as Liquid Glass drops that drift together and
|
||||
* apart inside one container, so their glass flows into one another.
|
||||
*/
|
||||
private struct Hero: View {
|
||||
@Environment(\.accessibilityReduceMotion) private var still
|
||||
@State private var together = false
|
||||
|
||||
var body: some View {
|
||||
GlassEffectContainer(spacing: 30) {
|
||||
ZStack {
|
||||
drop("calendar", .brand, size: 150, icon: 60)
|
||||
drop("checklist", .greenWeek, size: 72, icon: 28)
|
||||
.offset(x: together ? 62 : 92, y: together ? -50 : -70)
|
||||
drop("arrow.down.circle", .purple, size: 62, icon: 24)
|
||||
.offset(x: together ? -60 : -88, y: together ? 52 : 70)
|
||||
}
|
||||
}
|
||||
.frame(width: 280, height: 220)
|
||||
.accessibilityHidden(true)
|
||||
.onAppear {
|
||||
guard !still else { return }
|
||||
withAnimation(.easeInOut(duration: 3.2).repeatForever(autoreverses: true)) { together = true }
|
||||
}
|
||||
}
|
||||
|
||||
private func drop(_ symbol: String, _ color: Color, size: CGFloat, icon: CGFloat) -> some View {
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: icon, weight: .semibold))
|
||||
.foregroundStyle(color)
|
||||
.frame(width: size, height: size)
|
||||
.glassEffect(.regular.tint(color.opacity(0.22)), in: .circle)
|
||||
}
|
||||
}
|
||||
|
||||
/// "Ваша подгруппа" as a page: one tap picks it and moves on.
|
||||
private struct SubgroupStep: View {
|
||||
var group: String
|
||||
var subgroups: [String]
|
||||
var onPick: (String?) -> Void
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
StepHeading(
|
||||
symbol: "person.2",
|
||||
title: "Ваша подгруппа",
|
||||
text: "В группе \(group) часть занятий идёт по подгруппам. Выберите свою — чужие пары скроются из расписания, виджетов и напоминаний. Изменить можно в разделе «Группы»."
|
||||
)
|
||||
Section {
|
||||
Button { onPick(nil) } label: { row("Все подгруппы", "Показывать занятия всех подгрупп") }
|
||||
ForEach(subgroups, id: \.self) { s in
|
||||
Button { onPick(s) } label: { row(s, nil) }
|
||||
}
|
||||
}
|
||||
.tint(.primary)
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.safeAreaBar(edge: .bottom) {
|
||||
Button("Пропустить") { onPick(nil) }.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private func row(_ title: String, _ text: String?) -> some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title).font(.headline).foregroundStyle(.primary)
|
||||
if let text { Text(text).font(.subheadline).foregroundStyle(.secondary) }
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(.tertiary)
|
||||
}
|
||||
.contentShape(.rect)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reminders before each lesson; iOS asks for the notification permission here, when it makes sense.
|
||||
private struct RemindersStep: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var onDone: (Bool) -> Void
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
StepHeading(
|
||||
symbol: "bell.badge",
|
||||
title: "Напоминать о парах?",
|
||||
text: "Уведомление с предметом и аудиторией перед началом пары. Работает без интернета, по сохранённому расписанию. Выключить можно в настройках."
|
||||
)
|
||||
Section("За сколько минут") {
|
||||
Picker("За сколько минут", selection: Binding(get: { model.settings.reminderMinutes }, set: { model.setReminderMinutes($0) })) {
|
||||
ForEach([5, 10, 15, 30], id: \.self) { Text("\($0)").tag($0) }
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets())
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.safeAreaBar(edge: .bottom) {
|
||||
VStack(spacing: 8) {
|
||||
Button {
|
||||
Task { onDone(await Reminders.authorize()) }
|
||||
} label: {
|
||||
Text("Включить напоминания").font(.headline).frame(maxWidth: .infinity).padding(.vertical, 6)
|
||||
}
|
||||
.buttonStyle(.glassProminent)
|
||||
.controlSize(.large)
|
||||
Button("Не сейчас") { onDone(false) }
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 8)
|
||||
.frame(maxWidth: 520)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The heading of a step page: its glass symbol, a title and what the choice changes.
|
||||
private struct StepHeading: View {
|
||||
var symbol: String
|
||||
var title: String
|
||||
var text: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Image(systemName: symbol)
|
||||
.font(.title.weight(.semibold))
|
||||
.foregroundStyle(Color.brand)
|
||||
.frame(width: 72, height: 72)
|
||||
.glassEffect(.regular.tint(Color.brand.opacity(0.18)), in: .circle)
|
||||
Text(title).font(.largeTitle.bold()).accessibilityAddTraits(.isHeader)
|
||||
Text(text).foregroundStyle(.secondary)
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 4, bottom: 8, trailing: 4))
|
||||
}
|
||||
}
|
||||
331
ios/PSUSchedule/Views/PlansView.swift
Normal file
331
ios/PSUSchedule/Views/PlansView.swift
Normal file
@@ -0,0 +1,331 @@
|
||||
import SwiftUI
|
||||
|
||||
/// "Сегодня • Суббота • 17 октября", "Завтра • …" or "Понедельник • 19 октября".
|
||||
func planDayTitle(_ d: CivilDate, today: CivilDate) -> String {
|
||||
if d == today { return "Сегодня • \(d.weekdayName) • \(d.longLabel)" }
|
||||
if d == today.adding(days: 1) { return "Завтра • \(d.weekdayName) • \(d.longLabel)" }
|
||||
return "\(d.weekdayName) • \(d.longLabel)"
|
||||
}
|
||||
|
||||
/// One day of the plans: its lessons (when shown) and its tasks and notes.
|
||||
private struct PlanDay: Identifiable {
|
||||
var date: CivilDate? // nil: overdue
|
||||
var slots: [Slot]
|
||||
var notes: [Note]
|
||||
var id: String { date?.iso ?? "overdue" }
|
||||
var empty: Bool { slots.isEmpty && notes.isEmpty }
|
||||
}
|
||||
|
||||
/**
|
||||
* Plans: what has to be done, day by day — homework and notes pinned to lessons, overdue tasks
|
||||
* first. Three layouts as in School Planner: an agenda with a week strip, a compact list of
|
||||
* busy days, and a board of day columns.
|
||||
*/
|
||||
struct PlansView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@State private var editor: EditorRequest?
|
||||
@State private var adding = false
|
||||
@State private var details: LessonRef?
|
||||
@State private var day: CivilDate?
|
||||
|
||||
var body: some View {
|
||||
let prefs = model.settings.plans
|
||||
let days = planDays(prefs)
|
||||
let open = model.notes.filter { ($0.task && !$0.done) || (!$0.task && (CivilDate($0.date) ?? model.today) >= model.today) }.count
|
||||
NavigationStack {
|
||||
Group {
|
||||
if days.allSatisfy(\.empty) && prefs.layout != .agenda {
|
||||
EmptyState(title: "Планов нет", text: "Задания и заметки к парам появятся здесь. Добавьте первое кнопкой «+».", symbol: "checklist")
|
||||
} else {
|
||||
switch prefs.layout {
|
||||
case .board: Board(days: days, editor: $editor, details: $details)
|
||||
case .agenda, .list: Agenda(days: prefs.layout == .agenda ? days : days.filter { !$0.empty }, strip: prefs.layout == .agenda, editor: $editor, details: $details, day: $day)
|
||||
}
|
||||
}
|
||||
}
|
||||
.animation(.smooth, value: prefs)
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.navigationTitle("Планы")
|
||||
.navigationSubtitle(open == 0 ? "Всё сделано" : "\(open) \(plural(open, "дело", "дела", "дел")) впереди")
|
||||
// A compact title, as in the schedule: a large one collapsing above the floating week
|
||||
// strip jumps when the list comes back to the top.
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) { PlansModeButton() }
|
||||
ToolbarSpacer(.fixed, placement: .topBarTrailing)
|
||||
ToolbarItem(placement: .topBarTrailing) { PlansMenu() }
|
||||
}
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
Button { adding = true } label: {
|
||||
Image(systemName: "plus").font(.title2.weight(.semibold)).frame(width: 36, height: 36)
|
||||
}
|
||||
.buttonStyle(.glassProminent)
|
||||
.buttonBorderShape(.circle)
|
||||
.controlSize(.large)
|
||||
.padding(20)
|
||||
.accessibilityLabel("Новый план")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $adding) {
|
||||
AddPlanSheet(day: nil) { target in
|
||||
adding = false
|
||||
editor = EditorRequest(target: target)
|
||||
}
|
||||
}
|
||||
.sheet(item: $editor) { NoteEditor(request: $0) }
|
||||
.sheet(item: $details) { LessonDetails(ref: $0).presentationDetents([.medium, .large]) }
|
||||
}
|
||||
|
||||
private func planDays(_ prefs: PlansPrefs) -> [PlanDay] {
|
||||
let today = model.today
|
||||
let notes = prefs.done ? model.notes : model.notes.filter { !($0.task && $0.done) }
|
||||
let last = model.timetable?.days.last.flatMap { CivilDate($0.date) }.flatMap { $0 >= today ? $0 : nil } ?? today.adding(days: 13)
|
||||
let overdue = notes.filter { $0.overdue(today: today) }.sorted { ($0.date, $0.start) < ($1.date, $1.start) }
|
||||
let byDay = Dictionary(grouping: notes.filter { (CivilDate($0.date) ?? today) >= today }, by: \.date)
|
||||
var out: [PlanDay] = []
|
||||
if !overdue.isEmpty { out.append(PlanDay(date: nil, slots: [], notes: overdue)) }
|
||||
var d = today
|
||||
while d <= last {
|
||||
let slots = prefs.lessons ? model.timetable.map { model.slots($0, on: d) } ?? [] : []
|
||||
let list = (byDay[d.iso] ?? []).sorted { (($0.task && $0.done) ? 1 : 0, $0.start) < (($1.task && $1.done) ? 1 : 0, $1.start) }
|
||||
out.append(PlanDay(date: d, slots: slots, notes: list))
|
||||
d = d.adding(days: 1)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
/// The layout and what the list shows, as School Planner's plan settings.
|
||||
private struct PlansMenu: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var body: some View {
|
||||
let prefs = model.settings.plans
|
||||
Menu {
|
||||
Picker("Макет", selection: Binding(get: { prefs.layout }, set: { var p = prefs; p.layout = $0; model.setPlans(p) })) {
|
||||
Label("Повестка дня", systemImage: "list.bullet.rectangle").tag(PlansLayout.agenda)
|
||||
Label("Список", systemImage: "list.bullet").tag(PlansLayout.list)
|
||||
Label("Доска", systemImage: "rectangle.split.3x1").tag(PlansLayout.board)
|
||||
}
|
||||
.pickerStyle(.inline)
|
||||
Section {
|
||||
Toggle("Пары дня", systemImage: "calendar.day.timeline.left", isOn: Binding(get: { prefs.lessons }, set: { var p = prefs; p.lessons = $0; model.setPlans(p) }))
|
||||
Toggle("Выполненные", systemImage: "checkmark.circle", isOn: Binding(get: { prefs.done }, set: { var p = prefs; p.done = $0; model.setPlans(p) }))
|
||||
} footer: {
|
||||
Text(footer(prefs.layout))
|
||||
}
|
||||
} label: {
|
||||
Label("Вид планов", systemImage: "slider.horizontal.3")
|
||||
}
|
||||
}
|
||||
|
||||
private func footer(_ layout: PlansLayout) -> String {
|
||||
switch layout {
|
||||
case .agenda: "Все дни подряд с полосой недели — видно и занятые, и свободные дни."
|
||||
case .list: "Без полосы недели и без пустых дней — только дни с парами или планами."
|
||||
case .board: "Колонка на каждый день, листаются в сторону."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The agenda (every day, with a week strip) and the list (busy days only), with sticky days.
|
||||
private struct Agenda: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var days: [PlanDay]
|
||||
var strip: Bool
|
||||
@Binding var editor: EditorRequest?
|
||||
@Binding var details: LessonRef?
|
||||
@Binding var day: CivilDate?
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
List {
|
||||
ForEach(days) { d in
|
||||
Section {
|
||||
if !d.slots.isEmpty, let date = d.date {
|
||||
LessonsSummary(slots: d.slots) { details = LessonRef(lesson: $0, date: date) }
|
||||
}
|
||||
ForEach(d.notes) { note in
|
||||
NoteRow(note: note, showDue: d.date == nil) { editor = EditorRequest(editing: note) }
|
||||
.swipeActions {
|
||||
Button("Удалить", systemImage: "trash", role: .destructive) { model.delete(note) }
|
||||
}
|
||||
}
|
||||
if d.empty {
|
||||
Text("Ничего не запланировано").font(.subheadline).foregroundStyle(.tertiary)
|
||||
}
|
||||
} header: {
|
||||
Text(d.date.map { planDayTitle($0, today: model.today) } ?? "Просрочено")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(d.date == nil ? Color.red : d.date == model.today ? Color.brand : .secondary)
|
||||
.textCase(nil)
|
||||
}
|
||||
.id(d.id)
|
||||
}
|
||||
Color.clear.frame(height: 60).listRowBackground(Color.clear)
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.animation(.smooth, value: days.map(\.notes))
|
||||
.safeAreaBar(edge: .top, spacing: 0) {
|
||||
if strip {
|
||||
PlanStrip(selected: day ?? model.today) { d in
|
||||
day = d
|
||||
withAnimation(.smooth) { proxy.scrollTo(d.iso, anchor: .top) }
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 6)
|
||||
.readableWidth()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The week of the agenda: a dot under days with plans, glass tinted on green weeks.
|
||||
private struct PlanStrip: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var selected: CivilDate
|
||||
var onSelect: (CivilDate) -> Void
|
||||
@State private var monday: CivilDate?
|
||||
|
||||
var body: some View {
|
||||
let shown = monday ?? selected.monday
|
||||
let week = model.timetable?.day(shown)?.week
|
||||
HStack(spacing: 2) {
|
||||
Button("Предыдущая неделя", systemImage: "chevron.left") { monday = max(model.today.monday, shown.adding(days: -7)) }
|
||||
.labelStyle(.iconOnly).frame(width: 30)
|
||||
.disabled(shown <= model.today.monday)
|
||||
ForEach(0..<7, id: \.self) { i in
|
||||
let d = shown.adding(days: i)
|
||||
let isSelected = d == selected
|
||||
let has = model.notes.contains { $0.date == d.iso }
|
||||
Button { if d >= model.today { onSelect(d) } } label: {
|
||||
VStack(spacing: 3) {
|
||||
Text(d.shortWeekday).font(.caption2.weight(.medium)).foregroundStyle(d == model.today ? Color.brand : .secondary)
|
||||
Text("\(d.day)")
|
||||
.font(.callout.weight(isSelected ? .bold : .regular))
|
||||
.foregroundStyle(isSelected ? Color.onBrand : d < model.today ? .secondary : .primary)
|
||||
.frame(width: 32, height: 32)
|
||||
.background { if isSelected { Circle().fill(Color.brand) } }
|
||||
Circle().fill(has ? Color.orange : .clear).frame(width: 5, height: 5)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(d < model.today)
|
||||
}
|
||||
Button("Следующая неделя", systemImage: "chevron.right") { monday = shown.adding(days: 7) }
|
||||
.labelStyle(.iconOnly).frame(width: 30)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 4)
|
||||
.glassEffect(.regular.tint(weekTint(week)).interactive(), in: .rect(cornerRadius: 26, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
/// The day's lessons in one quiet row, above its plans.
|
||||
private struct LessonsSummary: View {
|
||||
var slots: [Slot]
|
||||
var onOpen: (Lesson) -> Void
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(slots) { slot in
|
||||
ForEach(slot.lessons, id: \.key) { lesson in
|
||||
Button { onOpen(lesson) } label: { CompactLesson(lesson: lesson, slot: slot) }.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
/// A column per day, scrolled sideways.
|
||||
private struct Board: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var days: [PlanDay]
|
||||
@Binding var editor: EditorRequest?
|
||||
@Binding var details: LessonRef?
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.horizontal) {
|
||||
LazyHStack(alignment: .top, spacing: 12) {
|
||||
ForEach(days) { d in
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(d.date.map { "\($0.longLabel) • \($0.weekdayName)" + ($0 == model.today ? " • Сегодня" : "") } ?? "Просрочено")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(d.date == nil ? Color.red : d.date == model.today ? Color.brand : .secondary)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if !d.slots.isEmpty, let date = d.date {
|
||||
LessonsSummary(slots: d.slots) { details = LessonRef(lesson: $0, date: date) }
|
||||
Divider()
|
||||
}
|
||||
ForEach(d.notes) { note in
|
||||
NoteRow(note: note, showDue: d.date == nil) { editor = EditorRequest(editing: note) }
|
||||
}
|
||||
if d.empty {
|
||||
Text("Ничего не запланировано").font(.subheadline).foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.frame(width: 290, alignment: .topLeading)
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 26, style: .continuous))
|
||||
}
|
||||
}
|
||||
.scrollTargetLayout()
|
||||
.padding(16)
|
||||
}
|
||||
.scrollTargetBehavior(.viewAligned)
|
||||
}
|
||||
}
|
||||
|
||||
/// A new plan: a general one for any day, or pinned to a lesson of the next two weeks.
|
||||
struct AddPlanSheet: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
var day: CivilDate?
|
||||
var onPick: (NoteTarget) -> Void
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
Button { onPick(NoteTarget(subject: "", date: day ?? model.today, start: "", next: nil)) } label: {
|
||||
Label {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Общий план").font(.headline)
|
||||
Text("Без привязки к паре — на любой день").font(.subheadline).foregroundStyle(.secondary)
|
||||
}
|
||||
} icon: { Image(systemName: "calendar.badge.plus") }
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
if let t = model.timetable {
|
||||
let days = (day.map { [$0] } ?? (0..<14).map { model.today.adding(days: $0) })
|
||||
.map { ($0, model.slots(t, on: $0)) }
|
||||
.filter { !$0.1.isEmpty }
|
||||
ForEach(days, id: \.0) { item in
|
||||
let (d, slots) = item
|
||||
Section(planDayTitle(d, today: model.today)) {
|
||||
ForEach(slots) { slot in
|
||||
ForEach(slot.lessons, id: \.key) { l in
|
||||
Button { onPick(NoteTarget(subject: l.subject, date: d, start: l.start, next: nil)) } label: {
|
||||
CompactLesson(lesson: l, slot: slot)
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Новый план")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("Отмена", systemImage: "xmark") { dismiss() } } }
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
}
|
||||
}
|
||||
297
ios/PSUSchedule/Views/ScheduleView.swift
Normal file
297
ios/PSUSchedule/Views/ScheduleView.swift
Normal file
@@ -0,0 +1,297 @@
|
||||
import SwiftUI
|
||||
|
||||
/**
|
||||
* The dated timetable: days page horizontally under a floating Liquid Glass strip of the week,
|
||||
* tinted green on the university's green weeks; the week view is a time grid.
|
||||
*/
|
||||
struct ScheduleView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(Router.self) private var router
|
||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||
@State private var details: LessonRef?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if let t = model.timetable, t.semester != nil, !t.days.isEmpty {
|
||||
Planner(timetable: t, details: $details)
|
||||
} else if let t = model.timetable {
|
||||
WordRows(timetable: t) { details = LessonRef(lesson: $0, date: nil) }
|
||||
.navigationTitle(t.group)
|
||||
} else {
|
||||
EmptyState(
|
||||
title: "Расписание не загружено",
|
||||
text: "Проверьте подключение или выберите группу заново.",
|
||||
symbol: "icloud.slash",
|
||||
action: model.settings.current != nil
|
||||
? ("Повторить", { Task { await model.refresh() } })
|
||||
: ("Выбрать группу", { router.groups = true })
|
||||
)
|
||||
.navigationTitle("Расписание")
|
||||
}
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
}
|
||||
.sheet(item: $details) { LessonDetails(ref: $0).presentationDetents([.medium, .large]) }
|
||||
}
|
||||
}
|
||||
|
||||
private struct Planner: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(Router.self) private var router
|
||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||
var timetable: Timetable
|
||||
@Binding var details: LessonRef?
|
||||
@State private var selected: CivilDate?
|
||||
@State private var gridMonday: CivilDate?
|
||||
|
||||
var body: some View {
|
||||
let t = timetable
|
||||
let dates = t.dates
|
||||
// On Sunday the next study day is more useful than an empty page.
|
||||
let start = model.today.weekday == 7 ? model.today.adding(days: 1) : model.today
|
||||
let fallback = dates.contains(start) ? start : (dates.first { $0 >= start } ?? dates.last!)
|
||||
let date = selected ?? fallback
|
||||
let weekView = router.weekView ?? (sizeClass == .regular)
|
||||
let monday = weekView ? (gridMonday ?? date.monday) : date.monday
|
||||
let week = t.day(monday)?.week
|
||||
let onToday = weekView ? monday == fallback.monday : date == fallback
|
||||
|
||||
Group {
|
||||
if weekView {
|
||||
WeekPager(timetable: t, monday: Binding(get: { monday }, set: { gridMonday = $0 })) { details = $0 }
|
||||
.transition(.opacity)
|
||||
} else {
|
||||
// A bar, not a plain inset: it blurs the cards scrolling under the title and the strip.
|
||||
DayPager(timetable: t, dates: dates, selection: Binding(get: { date }, set: { selected = $0 })) { details = $0 }
|
||||
.safeAreaBar(edge: .top, spacing: 0) {
|
||||
WeekStrip(timetable: t, selected: date, week: week) { selected = $0 }
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 6)
|
||||
.readableWidth()
|
||||
}
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.animation(.smooth(duration: 0.35), value: weekView)
|
||||
.navigationTitle(weekTitle(monday.adding(days: 3), week?.n))
|
||||
.navigationSubtitle([t.group, model.subgroup?.lowercased().replacingOccurrences(of: "подгруппа", with: "подгр."), week.map { $0.white ? "белая неделя" : "зелёная неделя" }].compactMap { $0 }.joined(separator: " · "))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
if !onToday {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Сегодня", systemImage: "calendar.badge.clock") {
|
||||
withAnimation(.smooth) {
|
||||
if weekView { gridMonday = fallback.monday } else { selected = fallback }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
Button(weekView ? "Показать день" : "Показать неделю", systemImage: weekView ? "list.bullet.below.rectangle" : "calendar") {
|
||||
if !weekView { gridMonday = date.monday }
|
||||
router.weekView = !weekView
|
||||
}
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
Button("Обновить", systemImage: "arrow.clockwise") { Task { await model.refresh() } }
|
||||
.disabled(model.status.loading)
|
||||
}
|
||||
}
|
||||
.onChange(of: router.focus, initial: true) { _, focus in
|
||||
// A day or week opened from elsewhere: the overview, the calendar, a widget.
|
||||
guard let focus else { return }
|
||||
if dates.contains(focus) { selected = focus }
|
||||
gridMonday = focus.monday
|
||||
router.focus = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The days of the semester, one page each, swiped sideways.
|
||||
private struct DayPager: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var timetable: Timetable
|
||||
var dates: [CivilDate]
|
||||
@Binding var selection: CivilDate
|
||||
var onOpen: (LessonRef) -> Void
|
||||
|
||||
var body: some View {
|
||||
// A paging scroll view rather than a page-style TabView: each day's list is an ordinary
|
||||
// scroll view, so it starts below the week strip, ends above the tab bar and scrolls
|
||||
// under their glass.
|
||||
ScrollView(.horizontal) {
|
||||
LazyHStack(spacing: 0) {
|
||||
ForEach(dates) { d in
|
||||
DayPage(timetable: timetable, date: d, onOpen: onOpen)
|
||||
.containerRelativeFrame(.horizontal)
|
||||
.id(d)
|
||||
}
|
||||
}
|
||||
.scrollTargetLayout()
|
||||
}
|
||||
.scrollTargetBehavior(.paging)
|
||||
.scrollIndicators(.hidden)
|
||||
.scrollPosition(id: Binding(get: { selection }, set: { if let d = $0 { selection = d } }))
|
||||
}
|
||||
}
|
||||
|
||||
private struct DayPage: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var timetable: Timetable
|
||||
var date: CivilDate
|
||||
var onOpen: (LessonRef) -> Void
|
||||
|
||||
var body: some View {
|
||||
let slots = model.slots(timetable, on: date)
|
||||
let clock = model.clock
|
||||
let isToday = date == model.today
|
||||
let next = isToday ? slots.first { $0.start > clock } : nil
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
StatusBanner()
|
||||
if slots.isEmpty {
|
||||
EmptyState(title: "Занятий нет", text: "По таблице недель в этот день свободно — можно отдохнуть.", symbol: "sofa")
|
||||
.padding(.top, 60)
|
||||
}
|
||||
ForEach(slots) { slot in
|
||||
let running = isToday && clock >= slot.start && clock < slot.end
|
||||
let status: String? = running
|
||||
? "Идёт · ещё \(minutesLabel(slot.end.minutes - clock.minutes))"
|
||||
: (slot == next && slot.start.minutes - clock.minutes <= 120 ? "Через \(minutesLabel(slot.start.minutes - clock.minutes))" : nil)
|
||||
ForEach(slot.lessons, id: \.key) { lesson in
|
||||
Button { onOpen(LessonRef(lesson: lesson, date: date)) } label: {
|
||||
LessonCard(lesson: lesson, time: slot.timeLabel, date: date, status: status, running: running)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.readableWidth()
|
||||
.animation(.smooth, value: slots)
|
||||
}
|
||||
.refreshable { await model.refresh() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The week as a floating glass strip: weekday, date and a dot on days with lessons. Swipe
|
||||
* it or use the arrows for the neighbouring weeks; green weeks tint the glass.
|
||||
*/
|
||||
private struct WeekStrip: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var timetable: Timetable
|
||||
var selected: CivilDate
|
||||
var week: Week?
|
||||
var onSelect: (CivilDate) -> Void
|
||||
|
||||
var body: some View {
|
||||
let monday = selected.monday
|
||||
let loaded = Set(timetable.dates)
|
||||
HStack(spacing: 2) {
|
||||
Button("Предыдущая неделя", systemImage: "chevron.left") { step(-1, loaded) }
|
||||
.labelStyle(.iconOnly)
|
||||
.frame(width: 30)
|
||||
ForEach(0..<7, id: \.self) { i in
|
||||
let d = monday.adding(days: i)
|
||||
let isSelected = d == selected
|
||||
let has = !model.slots(timetable, on: d).isEmpty
|
||||
Button { if loaded.contains(d) { onSelect(d) } } label: {
|
||||
VStack(spacing: 3) {
|
||||
Text(d.shortWeekday).font(.caption2.weight(.medium))
|
||||
.foregroundStyle(d == model.today ? Color.brand : .secondary)
|
||||
Text("\(d.day)")
|
||||
.font(.callout.weight(isSelected ? .bold : .regular))
|
||||
.foregroundStyle(isSelected ? Color.onBrand : .primary)
|
||||
.frame(width: 32, height: 32)
|
||||
.background { if isSelected { Circle().fill(Color.brand) } }
|
||||
Circle().fill(has ? Color.secondary : .clear).frame(width: 4, height: 4)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!loaded.contains(d))
|
||||
.accessibilityLabel(d.weekdayName + ", " + d.longLabel + (has ? "" : ", пар нет"))
|
||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||
}
|
||||
Button("Следующая неделя", systemImage: "chevron.right") { step(1, loaded) }
|
||||
.labelStyle(.iconOnly)
|
||||
.frame(width: 30)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 4)
|
||||
.glassEffect(.regular.tint(weekTint(week)).interactive(), in: .rect(cornerRadius: 26, style: .continuous))
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 24).onEnded { g in
|
||||
if abs(g.translation.width) > abs(g.translation.height) { step(g.translation.width < 0 ? 1 : -1, loaded) }
|
||||
}
|
||||
)
|
||||
.animation(.smooth, value: selected)
|
||||
}
|
||||
|
||||
/// The same weekday in the neighbouring week, or the nearest loaded day.
|
||||
private func step(_ direction: Int, _ loaded: Set<CivilDate>) {
|
||||
let target = selected.adding(days: 7 * direction)
|
||||
if loaded.contains(target) { onSelect(target); return }
|
||||
let sorted = loaded.sorted()
|
||||
if let near = sorted.min(by: { abs($0.days(to: target)) < abs($1.days(to: target)) }), near != selected { onSelect(near) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The week grid; a sideways swipe moves to the neighbouring loaded week.
|
||||
private struct WeekPager: View {
|
||||
var timetable: Timetable
|
||||
@Binding var monday: CivilDate
|
||||
var onOpen: (LessonRef) -> Void
|
||||
@State private var forward = true
|
||||
|
||||
var body: some View {
|
||||
let mondays = Set(timetable.dates.map(\.monday))
|
||||
WeekGrid(timetable: timetable, monday: monday, onOpen: onOpen)
|
||||
.padding(.top, 8)
|
||||
.id(monday)
|
||||
.transition(.push(from: forward ? .trailing : .leading))
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 30).onEnded { g in
|
||||
guard abs(g.translation.width) > abs(g.translation.height) * 1.5 else { return }
|
||||
let step = g.translation.width < 0 ? 7 : -7
|
||||
let next = monday.adding(days: step)
|
||||
guard mondays.contains(next) else { return }
|
||||
forward = step > 0
|
||||
withAnimation(.smooth) { monday = next }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// When the week table has no semester for this schedule: the Word rows by weekday.
|
||||
private struct WordRows: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var timetable: Timetable
|
||||
var onOpen: (Lesson) -> Void
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 14) {
|
||||
StatusBanner()
|
||||
Banner(text: timetable.note ?? "Даты не рассчитаны: показаны строки Word.", offline: false)
|
||||
ForEach(timetable.weekly, id: \.day) { day in
|
||||
Text(dayName(day.day)).font(.title3.bold()).padding(.top, 8)
|
||||
let lessons = day.lessons
|
||||
.filter { model.subgroup == nil || $0.subgroup == nil || $0.subgroup == model.subgroup }
|
||||
.filter { !model.hidden.contains($0.hideKey) }
|
||||
ForEach(lessons, id: \.key) { lesson in
|
||||
Button { onOpen(lesson) } label: {
|
||||
LessonCard(lesson: lesson, time: lesson.start + (lesson.end.map { " → \($0)" } ?? ""), date: nil)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.readableWidth()
|
||||
}
|
||||
.refreshable { await model.refresh() }
|
||||
}
|
||||
}
|
||||
222
ios/PSUSchedule/Views/SearchView.swift
Normal file
222
ios/PSUSchedule/Views/SearchView.swift
Normal file
@@ -0,0 +1,222 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Whose day to show: a teacher or a room.
|
||||
enum SearchTarget: Hashable {
|
||||
case teacher(String)
|
||||
case room(String)
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .teacher(let name): name
|
||||
case .room(let room): "Аудитория \(room)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// "Кто где": teachers and rooms across every timetable of the university, only online.
|
||||
struct SearchView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(Router.self) private var router
|
||||
@State private var hits: DirectorySearch?
|
||||
@State private var error: String?
|
||||
@State private var retries = 0
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
if let error {
|
||||
Banner(text: error, offline: true, onRetry: { self.error = nil; retries += 1 })
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets())
|
||||
}
|
||||
if let hits, router.searchQuery.trimmed.count >= 2 {
|
||||
if !hits.teachers.isEmpty {
|
||||
Section("Преподаватели") {
|
||||
ForEach(hits.teachers, id: \.name) { t in
|
||||
NavigationLink(value: SearchTarget.teacher(t.name)) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(t.name).font(.body.weight(.medium))
|
||||
Text(([t.faculties.compactMap(facultyShort).joined(separator: ", ")].filter { !$0.isEmpty } + ["\(t.count) \(plural(t.count, "занятие", "занятия", "занятий"))"]).joined(separator: " · "))
|
||||
.font(.subheadline).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hits.rooms.isEmpty {
|
||||
Section("Аудитории") {
|
||||
ForEach(hits.rooms, id: \.room) { r in
|
||||
NavigationLink(value: SearchTarget.room(r.room)) {
|
||||
Label("Аудитория \(r.room)", systemImage: "door.left.hand.open")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if hits.teachers.isEmpty && hits.rooms.isEmpty {
|
||||
EmptyState(title: "Никого не нашли", text: "Ни преподавателя, ни аудитории с таким названием в расписаниях каталога.", symbol: "magnifyingglass")
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
} else if error == nil {
|
||||
EmptyState(
|
||||
title: "Кто где",
|
||||
text: "Найдите преподавателя или аудиторию — покажем занятия на выбранный день по всем расписаниям университета.",
|
||||
symbol: "binoculars"
|
||||
)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Поиск")
|
||||
.navigationDestination(for: SearchTarget.self) { DirectoryDayView(target: $0) }
|
||||
}
|
||||
.task(id: "\(router.searchQuery)|\(retries)") {
|
||||
let q = router.searchQuery.trimmed
|
||||
guard q.count >= 2 else { hits = nil; return }
|
||||
try? await Task.sleep(for: .milliseconds(250))
|
||||
guard !Task.isCancelled else { return }
|
||||
do {
|
||||
hits = try await model.api.searchDirectory(q)
|
||||
error = nil
|
||||
} catch is CancellationError {
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A teacher's or room's lessons on a day, with a glass strip of dates.
|
||||
struct DirectoryDayView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var target: SearchTarget
|
||||
@State private var date = CivilDate.today().weekday == 7 ? CivilDate.today().adding(days: 1) : CivilDate.today()
|
||||
@State private var day: DirectoryDay?
|
||||
@State private var error: String?
|
||||
@State private var loading = false
|
||||
@State private var retries = 0
|
||||
@State private var bells: [Bell] = []
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 12) {
|
||||
if let error {
|
||||
Banner(text: error, offline: true, onRetry: { self.error = nil; retries += 1 })
|
||||
}
|
||||
if loading && day == nil {
|
||||
ProgressView().frame(maxWidth: .infinity).padding(.top, 60)
|
||||
} else if let day {
|
||||
if day.lessons.isEmpty {
|
||||
EmptyState(title: "Занятий нет", text: "По расписаниям каталога в этот день здесь пусто.", symbol: "calendar.badge.checkmark")
|
||||
.padding(.top, 40)
|
||||
}
|
||||
ForEach(Array(day.lessons.enumerated()), id: \.offset) { _, l in
|
||||
DirectoryLessonCard(lesson: l, target: target, bells: bells)
|
||||
}
|
||||
Text(coverageNote(day.coverage))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.readableWidth()
|
||||
}
|
||||
.safeAreaBar(edge: .top, spacing: 0) {
|
||||
DateStrip(selected: $date).padding(.horizontal, 12).padding(.bottom, 6).readableWidth()
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.navigationTitle(target.title)
|
||||
.navigationSubtitle(date.weekdayName + ", " + date.longLabel)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { if bells.isEmpty { bells = (try? await model.api.calendar().bells) ?? model.timetable?.bells ?? [] } }
|
||||
.task(id: "\(date.iso)|\(retries)") {
|
||||
loading = true
|
||||
defer { loading = false }
|
||||
do {
|
||||
day = switch target {
|
||||
case .teacher(let name): try await model.api.teacherDay(name, date: date)
|
||||
case .room(let room): try await model.api.roomDay(room, date: date)
|
||||
}
|
||||
error = nil
|
||||
} catch is CancellationError {
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func coverageNote(_ c: Coverage) -> String {
|
||||
var s = "По расписаниям из Word: учтено \(c.documents) из \(c.total) документов каталога \(c.year)."
|
||||
if !c.failed.isEmpty { s += " Не распознано: \(c.failed.count)." }
|
||||
if !c.withoutSemester.isEmpty { s += " Без дат (нет семестра в таблице недель): \(c.withoutSemester.count)." }
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
private struct DirectoryLessonCard: View {
|
||||
var lesson: DirectoryLesson
|
||||
var target: SearchTarget
|
||||
var bells: [Bell]
|
||||
|
||||
var body: some View {
|
||||
let pair = bells.first { $0.start == lesson.start }?.pair
|
||||
WithSubjectColor(subject: lesson.subject) { c in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(lesson.subject).font(.headline)
|
||||
Text(lesson.start + (lesson.end.map { " → \($0)" } ?? "") + (pair.map { " · \($0) пара" } ?? "")).font(.body.monospacedDigit())
|
||||
FlowLayout(spacing: 14) {
|
||||
if case .teacher = target {
|
||||
Meta(symbol: "mappin.and.ellipse", text: lesson.distance ? "Дистанционно" : lesson.rooms.joined(separator: ", ") + (lesson.moved ? " (перенос)" : ""))
|
||||
} else if !lesson.teachers.isEmpty {
|
||||
Meta(symbol: "person", text: lesson.teachers.joined(separator: ", "))
|
||||
}
|
||||
if !lesson.groups.isEmpty { Meta(symbol: "person.3", text: lesson.groups.joined(separator: ", ")) }
|
||||
}
|
||||
FlowLayout(spacing: 6) {
|
||||
if !lesson.type.isEmpty { Tag(text: lesson.type, foreground: c.onSoft, background: c.onSoft.opacity(0.12)) }
|
||||
if lesson.uncertain { Tag(text: "Сверьте недели", foreground: .red, background: .red.opacity(0.14)) }
|
||||
}
|
||||
}
|
||||
.foregroundStyle(c.onSoft)
|
||||
.padding(.horizontal, 18).padding(.vertical, 14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(c.soft, in: .rect(cornerRadius: 22, style: .continuous))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Seven days around the chosen one in a glass strip; arrows step a week.
|
||||
struct DateStrip: View {
|
||||
@Binding var selected: CivilDate
|
||||
|
||||
var body: some View {
|
||||
let monday = selected.monday
|
||||
let today = CivilDate.today()
|
||||
HStack(spacing: 2) {
|
||||
Button("Предыдущая неделя", systemImage: "chevron.left") { withAnimation(.smooth) { selected = selected.adding(days: -7) } }
|
||||
.labelStyle(.iconOnly).frame(width: 30)
|
||||
ForEach(0..<7, id: \.self) { i in
|
||||
let d = monday.adding(days: i)
|
||||
let isSelected = d == selected
|
||||
Button { withAnimation(.smooth) { selected = d } } label: {
|
||||
VStack(spacing: 3) {
|
||||
Text(d.shortWeekday).font(.caption2.weight(.medium)).foregroundStyle(d == today ? Color.brand : .secondary)
|
||||
Text("\(d.day)")
|
||||
.font(.callout.weight(isSelected ? .bold : .regular))
|
||||
.foregroundStyle(isSelected ? Color.onBrand : .primary)
|
||||
.frame(width: 32, height: 32)
|
||||
.background { if isSelected { Circle().fill(Color.brand) } }
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(d.weekdayName + ", " + d.longLabel)
|
||||
}
|
||||
Button("Следующая неделя", systemImage: "chevron.right") { withAnimation(.smooth) { selected = selected.adding(days: 7) } }
|
||||
.labelStyle(.iconOnly).frame(width: 30)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 4)
|
||||
.glassEffect(.regular.interactive(), in: .rect(cornerRadius: 26, style: .continuous))
|
||||
}
|
||||
}
|
||||
87
ios/PSUSchedule/Views/SettingsView.swift
Normal file
87
ios/PSUSchedule/Views/SettingsView.swift
Normal file
@@ -0,0 +1,87 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
let s = model.settings
|
||||
let d = s.display
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Тема") {
|
||||
Picker("Тема", selection: Binding(get: { s.theme }, set: { model.setTheme($0) })) {
|
||||
Text("Авто").tag(ThemeMode.system)
|
||||
Text("Светлая").tag(ThemeMode.light)
|
||||
Text("Тёмная").tag(ThemeMode.dark)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
Section {
|
||||
toggle("Цвета предметов", "Каждый предмет своим цветом в карточках, сетке и обзоре", d.colors) { var x = d; x.colors = $0; model.setDisplay(x) }
|
||||
toggle("Преподаватели", "Фамилии в карточках занятий", d.teachers) { var x = d; x.teachers = $0; model.setDisplay(x) }
|
||||
toggle("Аудитории", "Номера аудиторий в карточках и в сетке", d.rooms) { var x = d; x.rooms = $0; model.setDisplay(x) }
|
||||
toggle("Номера пар", "«2 пара» рядом со временем", d.pairs) { var x = d; x.pairs = $0; model.setDisplay(x) }
|
||||
toggle("Метки занятий", "Лекция или практика, белые и зелёные недели, общее занятие", d.tags) { var x = d; x.tags = $0; model.setDisplay(x) }
|
||||
toggle("Пометки из Word", "Например, «3-13н.» — даты по ним уже рассчитаны", d.notes) { var x = d; x.notes = $0; model.setDisplay(x) }
|
||||
} header: {
|
||||
Text("Расписание")
|
||||
} footer: {
|
||||
Text("Полные сведения о занятии всегда открываются по нажатию на карточку.")
|
||||
}
|
||||
if !s.hidden.isEmpty {
|
||||
Section("Скрытые занятия") {
|
||||
ForEach(s.hidden.sorted(), id: \.self) { key in
|
||||
let parts = key.split(separator: "|", maxSplits: 1).map(String.init)
|
||||
HStack {
|
||||
Text((parts.first ?? key).prefix(1).uppercased() + (parts.first ?? key).dropFirst() + (parts.count > 1 && !parts[1].isEmpty ? " · " + parts[1] : ""))
|
||||
Spacer()
|
||||
Button("Показать") { model.setHidden(key, false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Section {
|
||||
Toggle(isOn: Binding(get: { s.reminders }, set: { on in
|
||||
if on { Task { model.setReminders(await Reminders.authorize()) } } else { model.setReminders(false) }
|
||||
})) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Перед парой")
|
||||
Text("Уведомление с предметом и аудиторией. Работает без интернета, по сохранённому расписанию.")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if s.reminders {
|
||||
Picker("За сколько минут", selection: Binding(get: { s.reminderMinutes }, set: { model.setReminderMinutes($0) })) {
|
||||
ForEach([5, 10, 15, 30], id: \.self) { Text("\($0)").tag($0) }
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
} header: {
|
||||
Text("Напоминания")
|
||||
}
|
||||
Section {
|
||||
LabeledContent("Документ", value: model.timetable?.document.name ?? "Группа не выбрана")
|
||||
if let sem = model.timetable?.semester { LabeledContent("Семестр", value: sem.label) }
|
||||
LabeledContent("Сервер", value: model.api.base.absoluteString)
|
||||
} header: {
|
||||
Text("О расписании")
|
||||
} footer: {
|
||||
Text("Расписание берётся из публичного каталога ПГУ и пересчитывается по таблице белых и зелёных недель. Приложение неофициальное: при сомнениях сверяйтесь с документом Word.")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Настройки")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Готово", systemImage: "checkmark") { dismiss() } } }
|
||||
}
|
||||
}
|
||||
|
||||
private func toggle(_ title: String, _ text: String, _ on: Bool, _ set: @escaping (Bool) -> Void) -> some View {
|
||||
Toggle(isOn: Binding(get: { on }, set: set)) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
Text(text).font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
229
ios/PSUSchedule/Views/SubjectsView.swift
Normal file
229
ios/PSUSchedule/Views/SubjectsView.swift
Normal file
@@ -0,0 +1,229 @@
|
||||
import Charts
|
||||
import SwiftUI
|
||||
|
||||
/// Everything the loaded weeks say about one subject.
|
||||
private struct SubjectSummary: Identifiable {
|
||||
var name: String
|
||||
var types: [String]
|
||||
var teachers: [String]
|
||||
var rooms: [String]
|
||||
var upcoming: [(Slot, Lesson)]
|
||||
var weekDone: Int
|
||||
var weekTotal: Int
|
||||
var id: String { subjectKey(name) }
|
||||
}
|
||||
|
||||
private func unique(_ items: [String]) -> [String] {
|
||||
var seen = Set<String>()
|
||||
return items.filter { seen.insert($0).inserted }
|
||||
}
|
||||
|
||||
/// Subjects soonest first, with the week's progress, teachers and rooms.
|
||||
struct SubjectsView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(Router.self) private var router
|
||||
@State private var open: String?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let t = model.timetable, t.semester != nil {
|
||||
let subjects = summaries(t)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
WeekLoad(timetable: t, subjects: subjects.count)
|
||||
HStack(alignment: .lastTextBaseline) {
|
||||
Text("Все предметы").font(.title2.bold())
|
||||
Spacer()
|
||||
Text("по ближайшему занятию").font(.subheadline).foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 320), spacing: 12, alignment: .top)], spacing: 12) {
|
||||
ForEach(subjects) { s in
|
||||
Button { open = s.id } label: { SubjectCard(summary: s) }.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: 1200)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.sheet(item: Binding(get: { open.flatMap { id in subjects.first { $0.id == id } } }, set: { open = $0?.id })) { s in
|
||||
SubjectDetails(summary: s).presentationDetents([.medium, .large])
|
||||
}
|
||||
} else if model.timetable != nil {
|
||||
EmptyState(title: "Предметы недоступны", text: "Для этого расписания даты не рассчитаны: откройте вкладку «Расписание».", symbol: "calendar.badge.exclamationmark")
|
||||
} else {
|
||||
EmptyState(title: "Предметов пока нет", text: "Выберите группу — предметы появятся из её расписания.", symbol: "books.vertical", action: ("Выбрать группу", { router.groups = true }))
|
||||
}
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.navigationTitle("Предметы")
|
||||
}
|
||||
|
||||
private func summaries(_ t: Timetable) -> [SubjectSummary] {
|
||||
let date = model.today
|
||||
let clock = model.clock
|
||||
let monday = date.monday
|
||||
let entries = t.dates.flatMap { model.slots(t, on: $0) }.flatMap { slot in slot.lessons.map { (slot, $0) } }
|
||||
let grouped = Dictionary(grouping: entries) { subjectKey($0.1.subject) }
|
||||
return grouped.values.map { items in
|
||||
let lessons = items.map(\.1)
|
||||
let week = items.filter { $0.0.date.monday == monday }
|
||||
return SubjectSummary(
|
||||
name: lessons[0].subject,
|
||||
types: unique(lessons.map { $0.type.isEmpty ? "Занятие" : $0.type }),
|
||||
teachers: unique(lessons.flatMap(\.teachers)),
|
||||
rooms: unique(lessons.flatMap(\.rooms)),
|
||||
upcoming: items.filter { $0.0.date > date || ($0.0.date == date && $0.0.end > clock) },
|
||||
weekDone: week.filter { $0.0.date < date || ($0.0.date == date && $0.0.end <= clock) }.count,
|
||||
weekTotal: week.count
|
||||
)
|
||||
}
|
||||
// Soonest first; subjects with nothing ahead go to the end.
|
||||
.sorted { a, b in
|
||||
switch (a.upcoming.first?.0, b.upcoming.first?.0) {
|
||||
case let (x?, y?): (x.date, x.start, a.name) < (y.date, y.start, b.name)
|
||||
case (.some, nil): true
|
||||
case (nil, .some): false
|
||||
default: a.name < b.name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pairs per weekday of the current week as a bar chart, today's bar in the strong tone.
|
||||
private struct WeekLoad: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var timetable: Timetable
|
||||
var subjects: Int
|
||||
|
||||
var body: some View {
|
||||
let date = model.today
|
||||
let monday = date.monday
|
||||
let days = (0..<7).map { monday.adding(days: $0) }.map { ($0, model.slots(timetable, on: $0).count) }
|
||||
.filter { $0.0.weekday < 7 || $0.1 > 0 }
|
||||
let total = days.reduce(0) { $0 + $1.1 }
|
||||
let most = days.map(\.1).max() ?? 0
|
||||
let busiest = days.filter { $0.1 == most && most > 0 }.map { $0.0.weekdayName.lowercased() }
|
||||
SectionCard(symbol: "chart.bar", title: "Эта неделя") {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("\(pairs(total)) · \(subjects) \(plural(subjects, "предмет", "предмета", "предметов"))").font(.title3.bold())
|
||||
if !busiest.isEmpty {
|
||||
Text("Больше всего — \(busiest.joined(separator: " и ")), \(pairs(most))").font(.subheadline).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Chart(days, id: \.0) { day in
|
||||
BarMark(x: .value("День", day.0.shortWeekday), y: .value("Пары", day.1), width: .ratio(0.55))
|
||||
.foregroundStyle(day.0 == date ? Color.brand : Color.brand.opacity(day.0 < date ? 0.3 : 0.55))
|
||||
.clipShape(.rect(cornerRadius: 6))
|
||||
.annotation(position: .top) {
|
||||
Text(day.1 > 0 ? "\(day.1)" : "—").font(.caption.weight(.semibold)).foregroundStyle(day.0 == date ? Color.brand : .secondary)
|
||||
}
|
||||
}
|
||||
.chartYAxis(.hidden)
|
||||
.chartYScale(domain: 0...max(1, most + 1))
|
||||
.frame(height: 120)
|
||||
.accessibilityLabel(days.map { "\($0.0.weekdayName): \($0.1)" }.joined(separator: ", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SubjectCard: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var summary: SubjectSummary
|
||||
|
||||
var body: some View {
|
||||
WithSubjectColor(subject: summary.name) { c in
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Text(abbreviate(summary.name))
|
||||
.font(.footnote.weight(.bold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.6)
|
||||
.padding(.horizontal, 4)
|
||||
.foregroundStyle(c.onStrong)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(c.strong, in: .rect(cornerRadius: 12, style: .continuous))
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(summary.name).font(.headline).lineLimit(3)
|
||||
Text(summary.types.joined(separator: ", ")).font(.subheadline).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if let first = summary.upcoming.first {
|
||||
let (slot, lesson) = first
|
||||
Meta(symbol: "clock", text: nextLabel(slot) + (lesson.rooms.isEmpty ? "" : " · ауд. " + lesson.rooms.joined(separator: ", ")))
|
||||
} else {
|
||||
Meta(symbol: "checkmark.circle", text: "Занятий в этом семестре больше нет")
|
||||
}
|
||||
if !summary.teachers.isEmpty { Meta(symbol: "person", text: summary.teachers.joined(separator: ", ")) }
|
||||
if summary.weekTotal > 0 {
|
||||
ProgressView(value: Double(summary.weekDone), total: Double(summary.weekTotal)) {
|
||||
Text("На этой неделе: \(summary.weekDone) из \(summary.weekTotal)").font(.subheadline)
|
||||
}
|
||||
.tint(c.strong)
|
||||
}
|
||||
let stats = model.marks.stats(for: summary.name)
|
||||
if stats.total > 0 {
|
||||
Meta(symbol: "person.badge.clock", text: "Посещено \(stats.present) из \(stats.total)")
|
||||
}
|
||||
let tasks = model.notes.filter { subjectKey($0.subject) == summary.id && $0.task && !$0.done }.count
|
||||
if tasks > 0 { Meta(symbol: "checklist", text: "\(tasks) \(plural(tasks, "задание", "задания", "заданий"))") }
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
.card(padding: 18)
|
||||
}
|
||||
}
|
||||
|
||||
private func nextLabel(_ slot: Slot) -> String {
|
||||
let today = model.today
|
||||
let day = slot.date == today ? "сегодня" : slot.date == today.adding(days: 1) ? "завтра" : slot.date.shortWeekday.lowercased() + ", " + slot.date.longLabel
|
||||
return "Далее \(day), \(slot.start)"
|
||||
}
|
||||
}
|
||||
|
||||
/// A subject in full: teachers, rooms, upcoming lessons, attendance and tasks.
|
||||
private struct SubjectDetails: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
var summary: SubjectSummary
|
||||
@State private var details: LessonRef?
|
||||
@State private var editor: EditorRequest?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
LabeledContent("Виды занятий", value: summary.types.joined(separator: ", "))
|
||||
if !summary.teachers.isEmpty { LabeledContent("Преподаватели", value: summary.teachers.joined(separator: ", ")) }
|
||||
if !summary.rooms.isEmpty { LabeledContent("Аудитории", value: summary.rooms.joined(separator: ", ")) }
|
||||
let stats = model.marks.stats(for: summary.name)
|
||||
if stats.total > 0 { LabeledContent("Посещаемость", value: "\(stats.present) из \(stats.total)") }
|
||||
}
|
||||
let notes = model.notes.filter { subjectKey($0.subject) == summary.id }
|
||||
if !notes.isEmpty {
|
||||
Section("Задания и заметки") {
|
||||
ForEach(notes) { note in NoteRow(note: note, showSubject: false) { editor = EditorRequest(editing: note) } }
|
||||
}
|
||||
}
|
||||
Section("Ближайшие занятия") {
|
||||
if summary.upcoming.isEmpty { Text("Занятий в этом семестре больше нет").foregroundStyle(.secondary) }
|
||||
ForEach(Array(summary.upcoming.prefix(12).enumerated()), id: \.offset) { _, item in
|
||||
Button { details = LessonRef(lesson: item.1, date: item.0.date) } label: {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.0.date.weekdayName + ", " + item.0.date.longLabel).font(.subheadline.weight(.semibold))
|
||||
Text([item.0.timeLabel, item.1.type.isEmpty ? nil : item.1.type.lowercased(), item.1.rooms.isEmpty ? nil : "ауд. " + item.1.rooms.joined(separator: ", ")].compactMap { $0 }.joined(separator: " · "))
|
||||
.font(.subheadline).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(summary.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Готово", systemImage: "checkmark") { dismiss() } } }
|
||||
}
|
||||
.sheet(item: $details) { LessonDetails(ref: $0).presentationDetents([.medium, .large]) }
|
||||
.sheet(item: $editor) { NoteEditor(request: $0) }
|
||||
}
|
||||
}
|
||||
136
ios/PSUSchedule/Views/WeekGrid.swift
Normal file
136
ios/PSUSchedule/Views/WeekGrid.swift
Normal file
@@ -0,0 +1,136 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The week as a time grid: one column per day, a coloured block per lesson.
|
||||
struct WeekGrid: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var timetable: Timetable
|
||||
var monday: CivilDate
|
||||
var onOpen: (LessonRef) -> Void
|
||||
|
||||
private let hour: CGFloat = 60
|
||||
private let gutter: CGFloat = 32
|
||||
|
||||
var body: some View {
|
||||
let all = (0..<7).map { monday.adding(days: $0) }.map { ($0, model.slots(timetable, on: $0)) }
|
||||
// Sunday only takes a column when something happens on it.
|
||||
let days = all.filter { $0.0.weekday < 7 || !$0.1.isEmpty }
|
||||
let slots = days.flatMap(\.1)
|
||||
let first = min(8, slots.map(\.start.hour).min() ?? 8)
|
||||
let last = max(18, slots.map { $0.end.hour + ($0.end.minute > 0 ? 1 : 0) }.max() ?? 18)
|
||||
let hours = last - first
|
||||
let today = model.today
|
||||
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 0) {
|
||||
Color.clear.frame(width: gutter, height: 1)
|
||||
ForEach(days, id: \.0) { day in
|
||||
let d = day.0
|
||||
let isToday = d == today
|
||||
VStack(spacing: 2) {
|
||||
Text(d.shortWeekday).font(.caption.weight(.medium)).foregroundStyle(isToday ? Color.brand : .secondary)
|
||||
Text("\(d.day)")
|
||||
.font(.headline)
|
||||
.foregroundStyle(isToday ? Color.onBrand : .primary)
|
||||
.frame(width: 34, height: 34)
|
||||
.background(isToday ? Color.brand : .clear, in: .circle)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(d.weekdayName + ", " + d.longLabel)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
|
||||
ScrollView {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
ForEach(first..<last, id: \.self) { h in
|
||||
Text("\(h)")
|
||||
.font(.caption2.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.offset(x: 10, y: hour * CGFloat(h - first) - 7)
|
||||
}
|
||||
}
|
||||
.frame(width: gutter, height: hour * CGFloat(hours), alignment: .topLeading)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
ForEach(days, id: \.0) { day in
|
||||
column(day.0, day.1, first: first, hours: hours, today: today)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func y(_ t: ClockTime, _ first: Int) -> CGFloat { hour * CGFloat(t.minutes - first * 60) / 60 }
|
||||
|
||||
private func column(_ d: CivilDate, _ daySlots: [Slot], first: Int, hours: Int, today: CivilDate) -> some View {
|
||||
GeometryReader { geo in
|
||||
let width = geo.size.width
|
||||
ZStack(alignment: .topLeading) {
|
||||
Path { p in
|
||||
for h in 0...hours {
|
||||
p.move(to: CGPoint(x: 0, y: hour * CGFloat(h)))
|
||||
p.addLine(to: CGPoint(x: width, y: hour * CGFloat(h)))
|
||||
}
|
||||
p.move(to: .zero)
|
||||
p.addLine(to: CGPoint(x: 0, y: hour * CGFloat(hours)))
|
||||
}
|
||||
.stroke(Color(.separator), lineWidth: 0.5)
|
||||
|
||||
ForEach(daySlots) { slot in
|
||||
let top = y(slot.start, first)
|
||||
let height = y(slot.end, first) - top
|
||||
let w = width / CGFloat(slot.lessons.count)
|
||||
ForEach(Array(slot.lessons.enumerated()), id: \.element.key) { i, lesson in
|
||||
Button { onOpen(LessonRef(lesson: lesson, date: d)) } label: {
|
||||
LessonBlock(lesson: lesson, wide: w >= 96, notes: model.notes.forLesson(lesson.subject, d, lesson.start).open.count)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: max(0, w - 4), height: max(0, height - 2))
|
||||
.offset(x: w * CGFloat(i) + 2, y: top + 1)
|
||||
}
|
||||
}
|
||||
|
||||
if d == today, (first..<(first + hours)).contains(model.clock.hour) {
|
||||
Capsule().fill(Color.brand).frame(height: 2).offset(y: y(model.clock, first) - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: hour * CGFloat(hours))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
/// A lesson in the grid: its abbreviation (the full name on wide columns) and room.
|
||||
private struct LessonBlock: View {
|
||||
@Environment(\.display) private var show
|
||||
var lesson: Lesson
|
||||
var wide: Bool
|
||||
var notes: Int
|
||||
|
||||
var body: some View {
|
||||
WithSubjectColor(subject: lesson.subject) { c in
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(wide ? lesson.subject : abbreviate(lesson.subject))
|
||||
.font(.caption.weight(.semibold))
|
||||
.lineLimit(wide ? 3 : 2)
|
||||
if show.rooms, let room = lesson.distance ? "дист." : lesson.rooms.first {
|
||||
Text(room).font(.caption2).lineLimit(1).opacity(0.9)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if notes > 0 {
|
||||
Image(systemName: "checklist").font(.caption2.weight(.bold))
|
||||
}
|
||||
}
|
||||
.foregroundStyle(c.onStrong)
|
||||
.padding(4)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(c.strong, in: .rect(cornerRadius: 8, style: .continuous))
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("\(lesson.subject), \(lesson.start)" + (lesson.rooms.isEmpty ? "" : ", ауд. " + lesson.rooms.joined(separator: ", ")))
|
||||
}
|
||||
}
|
||||
175
ios/Shared/Dates.swift
Normal file
175
ios/Shared/Dates.swift
Normal file
@@ -0,0 +1,175 @@
|
||||
import Foundation
|
||||
|
||||
/// The university's time zone: dates and "now" never follow the phone's zone.
|
||||
let minsk = TimeZone(identifier: "Europe/Minsk")!
|
||||
|
||||
/// A Gregorian calendar in Minsk time, weeks from Monday.
|
||||
let minskCalendar: Calendar = {
|
||||
var c = Calendar(identifier: .gregorian)
|
||||
c.timeZone = minsk
|
||||
c.firstWeekday = 2
|
||||
c.locale = Locale(identifier: "ru_RU")
|
||||
return c
|
||||
}()
|
||||
|
||||
/// A calendar date without a time, like java.time.LocalDate: "2026-10-18".
|
||||
struct CivilDate: Hashable, Comparable, Codable, Identifiable, CustomStringConvertible {
|
||||
var year: Int
|
||||
var month: Int
|
||||
var day: Int
|
||||
|
||||
init(year: Int, month: Int, day: Int) {
|
||||
self.year = year
|
||||
self.month = month
|
||||
self.day = day
|
||||
}
|
||||
|
||||
init?(_ iso: String) {
|
||||
let parts = iso.prefix(10).split(separator: "-").compactMap { Int($0) }
|
||||
guard parts.count == 3 else { return nil }
|
||||
self.init(year: parts[0], month: parts[1], day: parts[2])
|
||||
}
|
||||
|
||||
init(_ date: Date) {
|
||||
let c = minskCalendar.dateComponents([.year, .month, .day], from: date)
|
||||
self.init(year: c.year!, month: c.month!, day: c.day!)
|
||||
}
|
||||
|
||||
static func today(_ now: Date = Date()) -> CivilDate { CivilDate(now) }
|
||||
|
||||
/// Noon in Minsk, so adding days never trips over a daylight saving change.
|
||||
var date: Date {
|
||||
minskCalendar.date(from: DateComponents(year: year, month: month, day: day, hour: 12))!
|
||||
}
|
||||
|
||||
var iso: String { String(format: "%04d-%02d-%02d", year, month, day) }
|
||||
var description: String { iso }
|
||||
var id: String { iso }
|
||||
|
||||
func adding(days: Int) -> CivilDate {
|
||||
CivilDate(minskCalendar.date(byAdding: .day, value: days, to: date)!)
|
||||
}
|
||||
|
||||
func adding(months: Int) -> CivilDate {
|
||||
CivilDate(minskCalendar.date(byAdding: .month, value: months, to: date)!)
|
||||
}
|
||||
|
||||
/// 1 = Monday … 7 = Sunday, as in java.time.
|
||||
var weekday: Int {
|
||||
let w = minskCalendar.component(.weekday, from: date) // 1 = Sunday
|
||||
return w == 1 ? 7 : w - 1
|
||||
}
|
||||
|
||||
var monday: CivilDate { adding(days: 1 - weekday) }
|
||||
var firstOfMonth: CivilDate { CivilDate(year: year, month: month, day: 1) }
|
||||
|
||||
func days(to other: CivilDate) -> Int {
|
||||
minskCalendar.dateComponents([.day], from: date, to: other.date).day ?? 0
|
||||
}
|
||||
|
||||
func at(_ time: ClockTime) -> Date {
|
||||
minskCalendar.date(from: DateComponents(year: year, month: month, day: day, hour: time.hour, minute: time.minute))!
|
||||
}
|
||||
|
||||
static func < (a: CivilDate, b: CivilDate) -> Bool {
|
||||
(a.year, a.month, a.day) < (b.year, b.month, b.day)
|
||||
}
|
||||
|
||||
// Codable as the ISO string, the way the server and the Android cache write dates.
|
||||
init(from decoder: Decoder) throws {
|
||||
let s = try decoder.singleValueContainer().decode(String.self)
|
||||
guard let d = CivilDate(s) else {
|
||||
throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Не дата: \(s)"))
|
||||
}
|
||||
self = d
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var c = encoder.singleValueContainer()
|
||||
try c.encode(iso)
|
||||
}
|
||||
}
|
||||
|
||||
/// A time of day in minutes since midnight: "08:30".
|
||||
struct ClockTime: Hashable, Comparable, CustomStringConvertible {
|
||||
var minutes: Int
|
||||
|
||||
init(minutes: Int) { self.minutes = minutes }
|
||||
|
||||
init(hour: Int, minute: Int) { minutes = hour * 60 + minute }
|
||||
|
||||
init?(_ hhmm: String) {
|
||||
let parts = hhmm.split(separator: ":").compactMap { Int($0) }
|
||||
guard parts.count >= 2 else { return nil }
|
||||
minutes = parts[0] * 60 + parts[1]
|
||||
}
|
||||
|
||||
static func now(_ date: Date = Date()) -> ClockTime {
|
||||
let c = minskCalendar.dateComponents([.hour, .minute], from: date)
|
||||
return ClockTime(hour: c.hour!, minute: c.minute!)
|
||||
}
|
||||
|
||||
var hour: Int { minutes / 60 }
|
||||
var minute: Int { minutes % 60 }
|
||||
var description: String { String(format: "%02d:%02d", hour, minute) }
|
||||
|
||||
func adding(minutes m: Int) -> ClockTime { ClockTime(minutes: minutes + m) }
|
||||
|
||||
static func < (a: ClockTime, b: ClockTime) -> Bool { a.minutes < b.minutes }
|
||||
}
|
||||
|
||||
// MARK: - Russian labels
|
||||
|
||||
private let monthsGenitive = ["января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря"]
|
||||
private let monthsNominative = ["Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", "Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь"]
|
||||
private let monthsShort = ["Янв.", "Февр.", "Март", "Апр.", "Май", "Июнь", "Июль", "Авг.", "Сент.", "Окт.", "Нояб.", "Дек."]
|
||||
private let weekdaysFull = ["Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота", "Воскресенье"]
|
||||
private let weekdaysShort = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]
|
||||
|
||||
extension CivilDate {
|
||||
/// "18 октября"
|
||||
var longLabel: String { "\(day) \(monthsGenitive[month - 1])" }
|
||||
/// "Пн"
|
||||
var shortWeekday: String { weekdaysShort[weekday - 1] }
|
||||
/// "Понедельник"
|
||||
var weekdayName: String { weekdaysFull[weekday - 1] }
|
||||
/// "Октябрь 2026"
|
||||
var monthTitle: String { "\(monthsNominative[month - 1]) \(year)" }
|
||||
/// "Окт."
|
||||
var shortMonth: String { monthsShort[month - 1] }
|
||||
/// "18.10"
|
||||
var numeric: String { String(format: "%02d.%02d", day, month) }
|
||||
}
|
||||
|
||||
/// "Понедельник" for 1…7.
|
||||
func dayName(_ day: Int) -> String { weekdaysFull[max(0, min(6, day - 1))] }
|
||||
|
||||
/// "12–18 октября" or "28 сентября – 4 октября".
|
||||
func weekSpan(_ start: CivilDate, _ end: CivilDate) -> String {
|
||||
start.month == end.month ? "\(start.day)–\(end.longLabel)" : "\(start.longLabel) – \(end.longLabel)"
|
||||
}
|
||||
|
||||
/// Russian plural: 1 пара, 2 пары, 5 пар.
|
||||
func plural(_ n: Int, _ one: String, _ few: String, _ many: String) -> String {
|
||||
let tail = n % 100
|
||||
if (11...14).contains(tail) { return many }
|
||||
switch n % 10 {
|
||||
case 1: return one
|
||||
case 2...4: return few
|
||||
default: return many
|
||||
}
|
||||
}
|
||||
|
||||
/// "1 пара", "3 пары".
|
||||
func pairs(_ n: Int) -> String { "\(n) \(plural(n, "пара", "пары", "пар"))" }
|
||||
|
||||
/// "45 мин", "1 ч 20 мин".
|
||||
func minutesLabel(_ m: Int) -> String {
|
||||
if m < 60 { return "\(m) мин" }
|
||||
return "\(m / 60) ч" + (m % 60 > 0 ? " \(m % 60) мин" : "")
|
||||
}
|
||||
|
||||
/// "Окт. · Неделя 7": the title of a shown week.
|
||||
func weekTitle(_ date: CivilDate, _ n: Int?) -> String {
|
||||
n.map { "\(date.shortMonth) · Неделя \($0)" } ?? date.shortMonth
|
||||
}
|
||||
432
ios/Shared/Models.swift
Normal file
432
ios/Shared/Models.swift
Normal file
@@ -0,0 +1,432 @@
|
||||
import Foundation
|
||||
|
||||
// Shapes of the server JSON (see server/timetable.js and server/directory.js), as in the
|
||||
// Android client's Models.kt. Missing keys fall back to defaults, so an older or newer
|
||||
// server never breaks decoding.
|
||||
|
||||
struct Timetable: Codable, Hashable {
|
||||
var group: String
|
||||
var document: DocumentRef
|
||||
var period: String?
|
||||
var semester: Semester?
|
||||
var note: String?
|
||||
var subgroups: [String] = []
|
||||
var bells: [Bell] = []
|
||||
var from: String
|
||||
var days: [Day] = []
|
||||
var weekly: [WeeklyDay] = []
|
||||
var notes: [String] = []
|
||||
}
|
||||
|
||||
struct DocumentRef: Codable, Hashable {
|
||||
var id: String
|
||||
var name: String
|
||||
var faculty: String = ""
|
||||
var course: String = ""
|
||||
var modified: String?
|
||||
}
|
||||
|
||||
struct Semester: Codable, Hashable {
|
||||
var id: String
|
||||
var label: String
|
||||
var start: String
|
||||
var end: String
|
||||
}
|
||||
|
||||
struct Bell: Codable, Hashable {
|
||||
var pair: Int
|
||||
var start: String
|
||||
var end: String
|
||||
var halves: [[String]] = []
|
||||
}
|
||||
|
||||
struct Week: Codable, Hashable {
|
||||
var n: Int
|
||||
var color: String
|
||||
var white: Bool { color == "white" }
|
||||
}
|
||||
|
||||
struct Day: Codable, Hashable {
|
||||
var date: String
|
||||
var weekday: Int
|
||||
var week: Week?
|
||||
var lessons: [Lesson] = []
|
||||
}
|
||||
|
||||
struct WeeklyDay: Codable, Hashable {
|
||||
var day: Int
|
||||
var lessons: [Lesson] = []
|
||||
}
|
||||
|
||||
struct Lesson: Codable, Hashable {
|
||||
var key: String
|
||||
var start: String
|
||||
var end: String?
|
||||
var pair: Int?
|
||||
var subject: String
|
||||
var type: String = ""
|
||||
var teachers: [String] = []
|
||||
var rooms: [String] = []
|
||||
var roomMoved: Bool = false
|
||||
var distance: Bool = false
|
||||
var condition: String = ""
|
||||
var variant: String = "all"
|
||||
var subgroup: String?
|
||||
var groups: [String] = []
|
||||
var uncertain: Bool = false
|
||||
var raw: String = ""
|
||||
|
||||
/** A recurring lesson the student may hide: the subject with its kind, in every week. */
|
||||
var hideKey: String { subject.trimmed.lowercased() + "|" + type.trimmed.lowercased() }
|
||||
}
|
||||
|
||||
struct GroupsResponse: Codable {
|
||||
var results: [GroupResult] = []
|
||||
var facets: Facets = Facets()
|
||||
var total: Int = 0
|
||||
var year: String = ""
|
||||
}
|
||||
|
||||
struct GroupResult: Codable, Hashable, Identifiable {
|
||||
var group: String
|
||||
var fileId: String
|
||||
var file: String = ""
|
||||
var faculty: String = ""
|
||||
var course: String = ""
|
||||
var form: String = ""
|
||||
var period: String = ""
|
||||
var id: String { fileId + "|" + group }
|
||||
}
|
||||
|
||||
struct Facets: Codable {
|
||||
var faculties: [Facet] = []
|
||||
var courses: [Facet] = []
|
||||
var forms: [Facet] = []
|
||||
}
|
||||
|
||||
struct Facet: Codable, Hashable {
|
||||
var name: String
|
||||
var count: Int
|
||||
}
|
||||
|
||||
struct DirectorySearch: Codable {
|
||||
var teachers: [TeacherHit] = []
|
||||
var rooms: [RoomHit] = []
|
||||
}
|
||||
|
||||
struct TeacherHit: Codable, Hashable {
|
||||
var name: String
|
||||
var count: Int = 0
|
||||
var faculties: [String] = []
|
||||
}
|
||||
|
||||
struct RoomHit: Codable, Hashable {
|
||||
var room: String
|
||||
var count: Int = 0
|
||||
}
|
||||
|
||||
struct DirectoryDay: Codable {
|
||||
var date: String
|
||||
var lessons: [DirectoryLesson] = []
|
||||
var coverage: Coverage
|
||||
}
|
||||
|
||||
struct DirectoryLesson: Codable, Hashable {
|
||||
var start: String
|
||||
var end: String?
|
||||
var subject: String
|
||||
var type: String = ""
|
||||
var teachers: [String] = []
|
||||
var rooms: [String] = []
|
||||
var moved: Bool = false
|
||||
var distance: Bool = false
|
||||
var groups: [String] = []
|
||||
var condition: String = ""
|
||||
var uncertain: Bool = false
|
||||
}
|
||||
|
||||
struct Coverage: Codable {
|
||||
var documents: Int
|
||||
var total: Int
|
||||
var failed: [String] = []
|
||||
var withoutSemester: [String] = []
|
||||
var year: String = ""
|
||||
}
|
||||
|
||||
struct CalendarInfo: Codable {
|
||||
var bells: [Bell] = []
|
||||
}
|
||||
|
||||
/** An exam, credit or consultation of the group, from any document of the catalog (/api/exams). */
|
||||
struct Exam: Codable, Hashable {
|
||||
var date: String
|
||||
var start: String
|
||||
var end: String?
|
||||
var type: String
|
||||
var subject: String
|
||||
var teachers: [String] = []
|
||||
var rooms: [String] = []
|
||||
/** Consultations go into the list but not into the countdown. */
|
||||
var counts: Bool { type != "Консультация" }
|
||||
}
|
||||
|
||||
struct ExamsResponse: Codable {
|
||||
var group: String = ""
|
||||
var exams: [Exam] = []
|
||||
}
|
||||
|
||||
/** A group the student opened; kept on the device for quick switching. */
|
||||
struct SavedGroup: Codable, Hashable {
|
||||
var docId: String
|
||||
var group: String
|
||||
var faculty: String = ""
|
||||
var course: String = ""
|
||||
/** "docId|group": notes, marks and choices of one group never show up in another. */
|
||||
var key: String { docId + "|" + group }
|
||||
}
|
||||
|
||||
/**
|
||||
* A homework task or a plain note pinned to one lesson (subject, date, start time),
|
||||
* or a general plan with an empty subject. Tasks can be ticked off; notes just remind.
|
||||
*/
|
||||
struct Note: Codable, Hashable, Identifiable {
|
||||
var id: String = UUID().uuidString
|
||||
var group: String
|
||||
var subject: String
|
||||
var date: String
|
||||
var start: String
|
||||
var text: String
|
||||
var task: Bool = true
|
||||
var done: Bool = false
|
||||
var created: Double = Date().timeIntervalSince1970 * 1000
|
||||
|
||||
/** A plan not pinned to any lesson: just a day. */
|
||||
var general: Bool { subject.trimmed.isEmpty }
|
||||
}
|
||||
|
||||
/** Whether the student was at one lesson. */
|
||||
struct Mark: Codable, Hashable {
|
||||
var group: String
|
||||
var subject: String
|
||||
var date: String
|
||||
var start: String
|
||||
var present: Bool
|
||||
var id: String { [group, date, start, subject.trimmed.lowercased()].joined(separator: "|") }
|
||||
}
|
||||
|
||||
extension String {
|
||||
var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
}
|
||||
|
||||
// MARK: - Lenient decoding
|
||||
|
||||
/// Keys may be missing in older answers or caches: every optional field falls back to its default.
|
||||
extension Timetable {
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
group = try c.decode(String.self, forKey: .group)
|
||||
document = try c.decode(DocumentRef.self, forKey: .document)
|
||||
period = try c.decodeIfPresent(String.self, forKey: .period)
|
||||
semester = try c.decodeIfPresent(Semester.self, forKey: .semester)
|
||||
note = try c.decodeIfPresent(String.self, forKey: .note)
|
||||
subgroups = try c.decodeIfPresent([String].self, forKey: .subgroups) ?? []
|
||||
bells = try c.decodeIfPresent([Bell].self, forKey: .bells) ?? []
|
||||
from = try c.decodeIfPresent(String.self, forKey: .from) ?? ""
|
||||
days = try c.decodeIfPresent([Day].self, forKey: .days) ?? []
|
||||
weekly = try c.decodeIfPresent([WeeklyDay].self, forKey: .weekly) ?? []
|
||||
notes = try c.decodeIfPresent([String].self, forKey: .notes) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
extension DocumentRef {
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try c.decode(String.self, forKey: .id)
|
||||
name = try c.decodeIfPresent(String.self, forKey: .name) ?? ""
|
||||
faculty = try c.decodeIfPresent(String.self, forKey: .faculty) ?? ""
|
||||
course = try c.decodeIfPresent(String.self, forKey: .course) ?? ""
|
||||
modified = try c.decodeIfPresent(String.self, forKey: .modified)
|
||||
}
|
||||
}
|
||||
|
||||
extension Bell {
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
pair = try c.decode(Int.self, forKey: .pair)
|
||||
start = try c.decode(String.self, forKey: .start)
|
||||
end = try c.decode(String.self, forKey: .end)
|
||||
halves = try c.decodeIfPresent([[String]].self, forKey: .halves) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
extension Day {
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
date = try c.decode(String.self, forKey: .date)
|
||||
weekday = try c.decodeIfPresent(Int.self, forKey: .weekday) ?? 0
|
||||
week = try c.decodeIfPresent(Week.self, forKey: .week)
|
||||
lessons = try c.decodeIfPresent([Lesson].self, forKey: .lessons) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
extension WeeklyDay {
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
day = try c.decode(Int.self, forKey: .day)
|
||||
lessons = try c.decodeIfPresent([Lesson].self, forKey: .lessons) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
extension Lesson {
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
key = try c.decodeIfPresent(String.self, forKey: .key) ?? UUID().uuidString
|
||||
start = try c.decode(String.self, forKey: .start)
|
||||
end = try c.decodeIfPresent(String.self, forKey: .end)
|
||||
pair = try c.decodeIfPresent(Int.self, forKey: .pair)
|
||||
subject = try c.decode(String.self, forKey: .subject)
|
||||
type = try c.decodeIfPresent(String.self, forKey: .type) ?? ""
|
||||
teachers = try c.decodeIfPresent([String].self, forKey: .teachers) ?? []
|
||||
rooms = try c.decodeIfPresent([String].self, forKey: .rooms) ?? []
|
||||
roomMoved = try c.decodeIfPresent(Bool.self, forKey: .roomMoved) ?? false
|
||||
distance = try c.decodeIfPresent(Bool.self, forKey: .distance) ?? false
|
||||
condition = try c.decodeIfPresent(String.self, forKey: .condition) ?? ""
|
||||
variant = try c.decodeIfPresent(String.self, forKey: .variant) ?? "all"
|
||||
subgroup = try c.decodeIfPresent(String.self, forKey: .subgroup)
|
||||
groups = try c.decodeIfPresent([String].self, forKey: .groups) ?? []
|
||||
uncertain = try c.decodeIfPresent(Bool.self, forKey: .uncertain) ?? false
|
||||
raw = try c.decodeIfPresent(String.self, forKey: .raw) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
extension GroupsResponse {
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
results = try c.decodeIfPresent([GroupResult].self, forKey: .results) ?? []
|
||||
facets = try c.decodeIfPresent(Facets.self, forKey: .facets) ?? Facets()
|
||||
total = try c.decodeIfPresent(Int.self, forKey: .total) ?? 0
|
||||
year = try c.decodeIfPresent(String.self, forKey: .year) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
extension GroupResult {
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
group = try c.decode(String.self, forKey: .group)
|
||||
fileId = try c.decode(String.self, forKey: .fileId)
|
||||
file = try c.decodeIfPresent(String.self, forKey: .file) ?? ""
|
||||
faculty = try c.decodeIfPresent(String.self, forKey: .faculty) ?? ""
|
||||
course = try c.decodeIfPresent(String.self, forKey: .course) ?? ""
|
||||
form = try c.decodeIfPresent(String.self, forKey: .form) ?? ""
|
||||
period = try c.decodeIfPresent(String.self, forKey: .period) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
extension Facets {
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
faculties = try c.decodeIfPresent([Facet].self, forKey: .faculties) ?? []
|
||||
courses = try c.decodeIfPresent([Facet].self, forKey: .courses) ?? []
|
||||
forms = try c.decodeIfPresent([Facet].self, forKey: .forms) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
extension DirectorySearch {
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
teachers = try c.decodeIfPresent([TeacherHit].self, forKey: .teachers) ?? []
|
||||
rooms = try c.decodeIfPresent([RoomHit].self, forKey: .rooms) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
extension TeacherHit {
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
name = try c.decode(String.self, forKey: .name)
|
||||
count = try c.decodeIfPresent(Int.self, forKey: .count) ?? 0
|
||||
faculties = try c.decodeIfPresent([String].self, forKey: .faculties) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
extension RoomHit {
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
room = try c.decode(String.self, forKey: .room)
|
||||
count = try c.decodeIfPresent(Int.self, forKey: .count) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
extension DirectoryDay {
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
date = try c.decode(String.self, forKey: .date)
|
||||
lessons = try c.decodeIfPresent([DirectoryLesson].self, forKey: .lessons) ?? []
|
||||
coverage = try c.decode(Coverage.self, forKey: .coverage)
|
||||
}
|
||||
}
|
||||
|
||||
extension DirectoryLesson {
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
start = try c.decode(String.self, forKey: .start)
|
||||
end = try c.decodeIfPresent(String.self, forKey: .end)
|
||||
subject = try c.decode(String.self, forKey: .subject)
|
||||
type = try c.decodeIfPresent(String.self, forKey: .type) ?? ""
|
||||
teachers = try c.decodeIfPresent([String].self, forKey: .teachers) ?? []
|
||||
rooms = try c.decodeIfPresent([String].self, forKey: .rooms) ?? []
|
||||
moved = try c.decodeIfPresent(Bool.self, forKey: .moved) ?? false
|
||||
distance = try c.decodeIfPresent(Bool.self, forKey: .distance) ?? false
|
||||
groups = try c.decodeIfPresent([String].self, forKey: .groups) ?? []
|
||||
condition = try c.decodeIfPresent(String.self, forKey: .condition) ?? ""
|
||||
uncertain = try c.decodeIfPresent(Bool.self, forKey: .uncertain) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
extension Coverage {
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
documents = try c.decodeIfPresent(Int.self, forKey: .documents) ?? 0
|
||||
total = try c.decodeIfPresent(Int.self, forKey: .total) ?? 0
|
||||
failed = try c.decodeIfPresent([String].self, forKey: .failed) ?? []
|
||||
withoutSemester = try c.decodeIfPresent([String].self, forKey: .withoutSemester) ?? []
|
||||
year = try c.decodeIfPresent(String.self, forKey: .year) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
extension CalendarInfo {
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
bells = try c.decodeIfPresent([Bell].self, forKey: .bells) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
extension Exam {
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
date = try c.decode(String.self, forKey: .date)
|
||||
start = try c.decodeIfPresent(String.self, forKey: .start) ?? ""
|
||||
end = try c.decodeIfPresent(String.self, forKey: .end)
|
||||
type = try c.decodeIfPresent(String.self, forKey: .type) ?? ""
|
||||
subject = try c.decode(String.self, forKey: .subject)
|
||||
teachers = try c.decodeIfPresent([String].self, forKey: .teachers) ?? []
|
||||
rooms = try c.decodeIfPresent([String].self, forKey: .rooms) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
extension ExamsResponse {
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
group = try c.decodeIfPresent(String.self, forKey: .group) ?? ""
|
||||
exams = try c.decodeIfPresent([Exam].self, forKey: .exams) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
extension SavedGroup {
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
docId = try c.decode(String.self, forKey: .docId)
|
||||
group = try c.decode(String.self, forKey: .group)
|
||||
faculty = try c.decodeIfPresent(String.self, forKey: .faculty) ?? ""
|
||||
course = try c.decodeIfPresent(String.self, forKey: .course) ?? ""
|
||||
}
|
||||
}
|
||||
102
ios/Shared/Schedule.swift
Normal file
102
ios/Shared/Schedule.swift
Normal file
@@ -0,0 +1,102 @@
|
||||
import Foundation
|
||||
|
||||
/// One time slot of a day: parallel lessons of subgroups share it.
|
||||
struct Slot: Hashable, Identifiable {
|
||||
var date: CivilDate
|
||||
var start: ClockTime
|
||||
var end: ClockTime
|
||||
var lessons: [Lesson]
|
||||
var id: String { date.iso + "|" + start.description }
|
||||
/// "13:40 → 15:05"
|
||||
var timeLabel: String { "\(start) → \(end)" }
|
||||
}
|
||||
|
||||
func subjectKey(_ subject: String) -> String { subject.trimmed.lowercased() }
|
||||
|
||||
/// End of a lesson: its own end, else the bell table's, else 80 minutes after the start.
|
||||
func lessonEnd(_ lesson: Lesson, _ t: Timetable) -> ClockTime {
|
||||
if let end = lesson.end.flatMap(ClockTime.init) { return end }
|
||||
if let bell = t.bells.first(where: { $0.start == lesson.start }), let end = ClockTime(bell.end) { return end }
|
||||
return (ClockTime(lesson.start) ?? ClockTime(minutes: 0)).adding(minutes: 80)
|
||||
}
|
||||
|
||||
extension Timetable {
|
||||
func day(_ date: CivilDate) -> Day? { days.first { $0.date == date.iso } }
|
||||
|
||||
func week(of date: CivilDate) -> Week? { day(date.monday)?.week ?? day(date)?.week }
|
||||
|
||||
var dates: [CivilDate] { days.compactMap { CivilDate($0.date) } }
|
||||
|
||||
/// Lessons of a date after the student's subgroup filter and the lessons they hid.
|
||||
func lessons(on date: CivilDate, subgroup: String?, hidden: Set<String>) -> [Lesson] {
|
||||
(day(date)?.lessons ?? [])
|
||||
.filter { subgroup == nil || $0.subgroup == nil || $0.subgroup == subgroup }
|
||||
.filter { !hidden.contains($0.hideKey) }
|
||||
}
|
||||
|
||||
func slots(on date: CivilDate, subgroup: String?, hidden: Set<String>) -> [Slot] {
|
||||
let lessons = lessons(on: date, subgroup: subgroup, hidden: hidden)
|
||||
var order: [String] = []
|
||||
var groups: [String: [Lesson]] = [:]
|
||||
for l in lessons {
|
||||
if groups[l.start] == nil { order.append(l.start) }
|
||||
groups[l.start, default: []].append(l)
|
||||
}
|
||||
return order.compactMap { start in
|
||||
guard let begin = ClockTime(start), let items = groups[start] else { return nil }
|
||||
return Slot(date: date, start: begin, end: lessonEnd(items[0], self), lessons: items)
|
||||
}
|
||||
.sorted { $0.start < $1.start }
|
||||
}
|
||||
|
||||
/// Palette slot of every subject: alphabetical, so a subject keeps its colour across refreshes.
|
||||
var subjectSlots: [String: Int] {
|
||||
let subjects = Set((days.flatMap(\.lessons) + weekly.flatMap(\.lessons)).map { subjectKey($0.subject) }).sorted()
|
||||
return Dictionary(uniqueKeysWithValues: subjects.enumerated().map { ($1, $0) })
|
||||
}
|
||||
}
|
||||
|
||||
private let shortWords: Set<String> = ["и", "в", "во", "на", "по", "с", "со", "к", "о", "об", "для", "из", "от"]
|
||||
|
||||
/// "Экономика предприятия в машиностроении" → "ЭПМ"; short names stay as they are.
|
||||
func abbreviate(_ subject: String) -> String {
|
||||
let name = (subject.split(separator: ":").first.map(String.init) ?? subject).trimmed
|
||||
if name.count <= 10 { return name }
|
||||
let letters = name.split(whereSeparator: { " -(),".contains($0) })
|
||||
.filter { !$0.isEmpty && !shortWords.contains($0.lowercased()) && ($0.first?.isLetter ?? false) }
|
||||
.map { String($0.first!).uppercased() }
|
||||
.joined()
|
||||
return letters.count >= 2 ? String(letters.prefix(4)) : String(name.prefix(8))
|
||||
}
|
||||
|
||||
/// Faculty names as the student knows them.
|
||||
private let faculties = [
|
||||
"Финансово-экономический факультет": "ФЭФ",
|
||||
"Факультет информационных технологий": "ФИТ",
|
||||
"Факультет компьютерных наук и электроники": "ФКНЭ",
|
||||
"Гуманитарный факультет": "ГФ",
|
||||
"Инженерно-строительный факультет": "ИСФ",
|
||||
"Механико-технологический факультет": "МТФ",
|
||||
"Юридический факультет": "ЮФ",
|
||||
]
|
||||
|
||||
func facultyShort(_ name: String) -> String? { name.isEmpty ? nil : (faculties[name] ?? name) }
|
||||
|
||||
/// What a widget or reminder shows: today's slots, or the next study day's once today is over.
|
||||
struct Upcoming {
|
||||
var date: CivilDate
|
||||
var slots: [Slot]
|
||||
}
|
||||
|
||||
func upcoming(_ t: Timetable, settings: UserSettings, now: Date = Date()) -> Upcoming? {
|
||||
let today = CivilDate.today(now)
|
||||
let clock = ClockTime.now(now)
|
||||
let todays = t.slots(on: today, subgroup: settings.subgroup, hidden: settings.hidden).filter { $0.end > clock }
|
||||
if !todays.isEmpty { return Upcoming(date: today, slots: todays) }
|
||||
for step in 1...7 {
|
||||
let d = today.adding(days: step)
|
||||
let slots = t.slots(on: d, subgroup: settings.subgroup, hidden: settings.hidden)
|
||||
if !slots.isEmpty { return Upcoming(date: d, slots: slots) }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
109
ios/Shared/Storage.swift
Normal file
109
ios/Shared/Storage.swift
Normal file
@@ -0,0 +1,109 @@
|
||||
import Foundation
|
||||
|
||||
enum ThemeMode: String, Codable, CaseIterable {
|
||||
case system, light, dark
|
||||
}
|
||||
|
||||
enum PlansLayout: String, Codable, CaseIterable {
|
||||
case agenda, list, board
|
||||
}
|
||||
|
||||
/** How the Plans screen lays out tasks, as in School Planner's plan settings. */
|
||||
struct PlansPrefs: Codable, Hashable {
|
||||
var layout: PlansLayout = .agenda
|
||||
/** The day's lessons above its plans; off by default, so plans come first. */
|
||||
var lessons: Bool = false
|
||||
/** Ticked-off tasks stay in the list. */
|
||||
var done: Bool = true
|
||||
}
|
||||
|
||||
/** What lesson cards, the week grid and the overview show; everything is on by default. */
|
||||
struct Display: Codable, Hashable {
|
||||
var colors = true
|
||||
var teachers = true
|
||||
var rooms = true
|
||||
var pairs = true
|
||||
var tags = true
|
||||
var notes = true
|
||||
}
|
||||
|
||||
/** Everything the student chose; stays on the device, shared with the widgets. */
|
||||
struct UserSettings: Codable, Hashable {
|
||||
var current: SavedGroup?
|
||||
/** The chosen subgroup per group ("docId|group"). */
|
||||
var subgroups: [String: String] = [:]
|
||||
var theme: ThemeMode = .system
|
||||
var reminders = false
|
||||
var reminderMinutes = 10
|
||||
var recent: [SavedGroup] = []
|
||||
var display = Display()
|
||||
/** Hidden lessons per group, as Lesson.hideKey values. */
|
||||
var hiddenByGroup: [String: [String]] = [:]
|
||||
var plans = PlansPrefs()
|
||||
|
||||
var subgroup: String? { current.flatMap { subgroups[$0.key] } }
|
||||
var hidden: Set<String> { Set(current.flatMap { hiddenByGroup[$0.key] } ?? []) }
|
||||
}
|
||||
|
||||
// Every field may be missing in an older save.
|
||||
extension UserSettings {
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
current = try c.decodeIfPresent(SavedGroup.self, forKey: .current)
|
||||
subgroups = try c.decodeIfPresent([String: String].self, forKey: .subgroups) ?? [:]
|
||||
theme = try c.decodeIfPresent(ThemeMode.self, forKey: .theme) ?? .system
|
||||
reminders = try c.decodeIfPresent(Bool.self, forKey: .reminders) ?? false
|
||||
reminderMinutes = try c.decodeIfPresent(Int.self, forKey: .reminderMinutes) ?? 10
|
||||
recent = try c.decodeIfPresent([SavedGroup].self, forKey: .recent) ?? []
|
||||
display = try c.decodeIfPresent(Display.self, forKey: .display) ?? Display()
|
||||
hiddenByGroup = try c.decodeIfPresent([String: [String]].self, forKey: .hiddenByGroup) ?? [:]
|
||||
plans = try c.decodeIfPresent(PlansPrefs.self, forKey: .plans) ?? PlansPrefs()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Files and settings in the app group container, so the widgets read the same timetable,
|
||||
* notes and choices as the app. Everything stays on the device.
|
||||
*/
|
||||
enum Storage {
|
||||
static let group = "group.org.xtimms.psu.schedule"
|
||||
|
||||
static var directory: URL {
|
||||
if let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group) { return url }
|
||||
// Without the app group (an unsigned build) the app still works on its own.
|
||||
let url = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||
return url
|
||||
}
|
||||
|
||||
static var defaults: UserDefaults { UserDefaults(suiteName: group) ?? .standard }
|
||||
|
||||
static let encoder = JSONEncoder()
|
||||
static let decoder = JSONDecoder()
|
||||
|
||||
static func read<T: Decodable>(_ type: T.Type, _ name: String) -> T? {
|
||||
guard let data = try? Data(contentsOf: directory.appendingPathComponent(name)) else { return nil }
|
||||
return try? decoder.decode(type, from: data)
|
||||
}
|
||||
|
||||
static func write<T: Encodable>(_ value: T, _ name: String) {
|
||||
guard let data = try? encoder.encode(value) else { return }
|
||||
try? data.write(to: directory.appendingPathComponent(name), options: .atomic)
|
||||
}
|
||||
|
||||
static func remove(_ name: String) {
|
||||
try? FileManager.default.removeItem(at: directory.appendingPathComponent(name))
|
||||
}
|
||||
|
||||
static func loadSettings() -> UserSettings {
|
||||
guard let data = defaults.data(forKey: "settings"),
|
||||
let s = try? decoder.decode(UserSettings.self, from: data) else { return UserSettings() }
|
||||
return s
|
||||
}
|
||||
|
||||
static func saveSettings(_ s: UserSettings) {
|
||||
if let data = try? encoder.encode(s) { defaults.set(data, forKey: "settings") }
|
||||
}
|
||||
|
||||
static func loadTimetable() -> Timetable? { read(Timetable.self, "timetable.json") }
|
||||
}
|
||||
189
ios/Widgets/Widgets.swift
Normal file
189
ios/Widgets/Widgets.swift
Normal file
@@ -0,0 +1,189 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
/// What a widget shows at one moment: the group and its lessons ahead.
|
||||
struct LessonsEntry: TimelineEntry {
|
||||
var date: Date
|
||||
var group: String?
|
||||
var day: CivilDate?
|
||||
var slots: [Slot]
|
||||
var message: String?
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the timetable the app saved in the shared container, so widgets work offline.
|
||||
* A new entry at every lesson's start and end keeps "Идёт" and "Далее" right without the app.
|
||||
*/
|
||||
struct LessonsProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> LessonsEntry {
|
||||
LessonsEntry(date: Date(), group: "23-БА", day: CivilDate.today(), slots: [], message: nil)
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (LessonsEntry) -> Void) {
|
||||
completion(entry(at: Date()))
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<LessonsEntry>) -> Void) {
|
||||
let now = Date()
|
||||
var moments = [now]
|
||||
if let t = Storage.loadTimetable() {
|
||||
let settings = Storage.loadSettings()
|
||||
let today = CivilDate.today(now)
|
||||
for d in [today, today.adding(days: 1)] {
|
||||
for slot in t.slots(on: d, subgroup: settings.subgroup, hidden: settings.hidden) {
|
||||
moments.append(d.at(slot.start))
|
||||
moments.append(d.at(slot.end))
|
||||
}
|
||||
}
|
||||
}
|
||||
// The next midnight in Minsk turns "Завтра" into "Сегодня".
|
||||
moments.append(CivilDate.today(now).adding(days: 1).at(ClockTime(minutes: 0)))
|
||||
let entries = Set(moments.filter { $0 >= now }).sorted().prefix(40).map { entry(at: $0) }
|
||||
completion(Timeline(entries: entries, policy: .after(now.addingTimeInterval(6 * 3600))))
|
||||
}
|
||||
|
||||
private func entry(at date: Date) -> LessonsEntry {
|
||||
let settings = Storage.loadSettings()
|
||||
guard let t = Storage.loadTimetable(), settings.current != nil else {
|
||||
return LessonsEntry(date: date, group: nil, day: nil, slots: [], message: "Откройте приложение и выберите группу")
|
||||
}
|
||||
guard let next = upcoming(t, settings: settings, now: date) else {
|
||||
return LessonsEntry(date: date, group: t.group, day: nil, slots: [], message: "Занятий на неделе нет")
|
||||
}
|
||||
return LessonsEntry(date: date, group: t.group, day: next.date, slots: next.slots, message: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func dayLabel(_ day: CivilDate, at date: Date) -> String {
|
||||
let today = CivilDate.today(date)
|
||||
if day == today { return "Сегодня, \(day.longLabel)" }
|
||||
if day == today.adding(days: 1) { return "Завтра, \(day.weekdayName.lowercased())" }
|
||||
return "\(day.weekdayName), \(day.longLabel)"
|
||||
}
|
||||
|
||||
private func status(_ slot: Slot, day: CivilDate, at date: Date) -> String? {
|
||||
guard day == CivilDate.today(date) else { return nil }
|
||||
let clock = ClockTime.now(date)
|
||||
if clock >= slot.start && clock < slot.end { return "Идёт · ещё \(minutesLabel(slot.end.minutes - clock.minutes))" }
|
||||
return nil
|
||||
}
|
||||
|
||||
private func place(_ lesson: Lesson) -> String? {
|
||||
if lesson.distance { return "дист." }
|
||||
return lesson.rooms.isEmpty ? nil : "ауд. " + lesson.rooms.joined(separator: ", ")
|
||||
}
|
||||
|
||||
/// "Сегодня": the day's lessons ahead, with the current one marked.
|
||||
struct TodayWidgetView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
var entry: LessonsEntry
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(entry.day.map { dayLabel($0, at: entry.date) } ?? "Расписание").font(.caption.weight(.semibold)).foregroundStyle(.tint)
|
||||
Spacer()
|
||||
if let group = entry.group { Text(group).font(.caption).foregroundStyle(.secondary) }
|
||||
}
|
||||
if let message = entry.message {
|
||||
Spacer()
|
||||
Text(message).font(.subheadline).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
} else {
|
||||
let limit = family == .systemLarge ? 7 : family == .systemMedium ? 3 : 2
|
||||
ForEach(entry.slots.prefix(limit)) { slot in
|
||||
let lesson = slot.lessons[0]
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Capsule().fill(.tint).frame(width: 3)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(slot.lessons.map(\.subject).joined(separator: " / "))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(family == .systemSmall ? 2 : 1)
|
||||
Text(([status(slot, day: entry.day!, at: entry.date) ?? slot.timeLabel] + [place(lesson)].compactMap { $0 }).joined(separator: " · "))
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
if entry.slots.count > limit {
|
||||
Text("Ещё \(pairs(entry.slots.count - limit))").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
.containerBackground(.fill.tertiary, for: .widget)
|
||||
.widgetURL(URL(string: "psuschedule://day/\(entry.day?.iso ?? "")"))
|
||||
}
|
||||
}
|
||||
|
||||
/// "Следующая пара": one lesson, large, also on the Lock Screen.
|
||||
struct NextLessonView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
var entry: LessonsEntry
|
||||
|
||||
var body: some View {
|
||||
let slot = entry.slots.first
|
||||
let lesson = slot?.lessons.first
|
||||
Group {
|
||||
switch family {
|
||||
case .accessoryInline:
|
||||
if let slot, let lesson { Text("\(slot.start) \(lesson.subject)") } else { Text("Пар нет") }
|
||||
case .accessoryRectangular:
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
if let slot, let lesson, let day = entry.day {
|
||||
Text(status(slot, day: day, at: entry.date) ?? (day == CivilDate.today(entry.date) ? slot.timeLabel : day.shortWeekday + " " + slot.timeLabel))
|
||||
.font(.caption.weight(.semibold))
|
||||
Text(lesson.subject).font(.headline).lineLimit(1)
|
||||
if let p = place(lesson) { Text(p).font(.caption) }
|
||||
} else {
|
||||
Text(entry.message ?? "Пар нет").font(.caption)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
default:
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if let slot, let lesson, let day = entry.day {
|
||||
Text(status(slot, day: day, at: entry.date) ?? (day == CivilDate.today(entry.date) ? "Далее · " + slot.start.description : dayLabel(day, at: entry.date)))
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.tint)
|
||||
Text(lesson.subject).font(.headline).lineLimit(3)
|
||||
Spacer(minLength: 0)
|
||||
Text(slot.timeLabel).font(.subheadline.monospacedDigit())
|
||||
if let p = place(lesson) { Text(p).font(.caption).foregroundStyle(.secondary) }
|
||||
} else {
|
||||
Text(entry.message ?? "Пар нет").font(.subheadline).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
.containerBackground(.fill.tertiary, for: .widget)
|
||||
}
|
||||
}
|
||||
|
||||
struct TodayWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: "today", provider: LessonsProvider()) { TodayWidgetView(entry: $0) }
|
||||
.configurationDisplayName("Сегодня")
|
||||
.description("Пары на сегодня или ближайший учебный день.")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
||||
}
|
||||
}
|
||||
|
||||
struct NextLessonWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: "next", provider: LessonsProvider()) { NextLessonView(entry: $0) }
|
||||
.configurationDisplayName("Следующая пара")
|
||||
.description("Текущая или следующая пара с аудиторией.")
|
||||
.supportedFamilies([.systemSmall, .accessoryRectangular, .accessoryInline])
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct ScheduleWidgets: WidgetBundle {
|
||||
var body: some Widget {
|
||||
TodayWidget()
|
||||
NextLessonWidget()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user