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:
2026-10-18 20:26:12 +03:00
parent 6b68cec34a
commit 170a44c377
40 changed files with 58862 additions and 51029 deletions

3
.gitignore vendored
View File

@@ -3,3 +3,6 @@ dist/
.data/
*.tsbuildinfo
.DS_Store
xcuserdata/
DerivedData/
*.xcuserstate

View File

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

View 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>

View 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
View 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

View 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>

View 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>

View 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 */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -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
View 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 {}

View 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)
}
}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

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

View File

@@ -0,0 +1 @@
{ "info" : { "author" : "xcode", "version" : 1 } }

View File

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

View 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()
}
}

View 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
}
}
}
}
}

View 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 }
}
}

View 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)
}
}
}
}
}
}

View 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)
}
}

View 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 : [])
}
}

View 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))
}
}
}

View 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()
}
}

View 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))
}
}

View 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])
}
}

View 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() }
}
}

View 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))
}
}

View 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)
}
}
}
}

View 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) }
}
}

View 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
View 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
View 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
View 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
View 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
View 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()
}
}