131 Commits
Author SHA1 Message Date
MineTecandClaude Opus 5.5 c5592f117b added automated App Store screenshots via iOS simulators and fastlane deliver
tool/screenshots_ios.sh runs the existing demo-login integration test on
iPhone 6.5" (iPhone 14 Plus) and iPad 13" simulators, stores the canonical PNGs
under materials/screenshots and mirrors alpha-free JPEGs to
ios/fastlane/screenshots for `fastlane upload_screenshots`.

Fixed the demo router answering timetable/custom-events with a week, which
crashed the inline iOS widget refresh after a demo login. The screenshot test
now skips the one-time Talk notification dialog and waits longer for the app
shell on cold simulators.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 16:58:29 +02:00
MineTec ada4d6a77c Merge remote-tracking branch 'origin/develop' into develop 2026-09-29 16:39:55 +02:00
MineTecandClaude Fable 5.1 cd88cc75fc required firebase_messaging 16.6 so AuthorizationStatus.deniedPermanently resolves
The push code references the value since the platform upgrade in September,
but the old constraint still allowed firebase_messaging_platform_interface
4.9.0, which lacks it; a stale local lockfile then failed analyze and eleven
test files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HrepDTL4qDe35TuS29sLW3
2026-09-29 16:35:41 +02:00
MineTecandClaude Fable 5.1 279b973e89 hardened API decoding against server fields the models did not expect
The Talk room list crashed for a new account because the server omits
lastMessage for rooms without a visible message and the model required it.
Beyond that single field, a review of the Talk models against the OpenAPI
spec of the installed version (23.0.8) found the same crash class in several
places: closed enums over open server values (inCall bitmask, rich-object
types, actor and message types), a deleted parent sent as {id, deleted},
and the autocomplete status arriving as an object.

- json_serializable `checked: true`: decode errors now name class and field
- lists (rooms, messages, participants, autocomplete) decode per entry;
  a malformed entry is skipped and reported via ClientErrorReporter
- unknownEnumValue fallbacks for all response enums of Talk and breakers
- lastMessage nullable; inCall as bitmask; deleted parent placeholder;
  autocomplete status object/string; fixed shareWithDisplayNameUnique key
- GetRoomParams no longer sends null query parameters

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HrepDTL4qDe35TuS29sLW3
2026-09-29 16:35:41 +02:00
MineTec b90cda62da iOS build 2026-09-29 15:53:18 +02:00
MineTec a79b376bb3 bumped version to 1.6.0+66 2026-09-27 21:41:49 +02:00
MineTec eddd294a34 blocked all nextcloud requests in demo mode to stop fake-credential logins tripping brute-force protection 2026-09-27 21:40:55 +02:00
MineTec c455bb1cae consolidated downloads dir, nextcloud origin, upload route and chat timestamps; added webdav path tests 2026-09-27 21:19:50 +02:00
MineTec aca86c506a fixed countdown wrap, css rgba colors, stale create-link gate and wired up reply drafts in talk 2026-09-27 21:13:44 +02:00
MineTec 2664378b17 fixed overwrite on internal copy, DST drift in widget anchors, inline errors without controller and removed dead room lookup 2026-09-27 17:37:31 +02:00
MineTec ee1240f71a simplified code across the app and consolidated duplicated helpers 2026-09-27 17:29:54 +02:00
MineTec 3e856d0b1c updated project notes to the current backends 2026-09-27 14:57:49 +02:00
MineTec 8eddc8f771 fixed widget background refresh loading custom events from the legacy backend 2026-09-27 14:54:10 +02:00
MineTec 27563a6dc1 added support for multiple accounts with an account switcher in settings 2026-09-27 14:54:10 +02:00
MineTec 5e65c4671a replaced account data singleton with a session model 2026-09-27 14:49:38 +02:00
MineTec 79b33ab248 reworked login screen texts and extracted shared login form parts 2026-09-27 14:40:04 +02:00
MineTec 1fd6264d10 fixed notification taps being ignored when they cold-started the app 2026-09-27 14:38:48 +02:00
MineTec 2ca9feb49a reworked home screen widgets to mirror the in-app timetable, hidden below android 12 2026-09-27 14:07:52 +02:00
MineTec 2aed3d104c push registration no longer depends on notification permission, reporting effective opt-in and os permission 2026-09-26 23:38:24 +02:00
MineTec e6f74ca6fe reworked developer tools with diagnostics, storage viewers and release-capable debug overlays 2026-09-26 14:34:47 +02:00
MineTec 0f6c388940 fixed overflowing account name and dropdowns in settings 2026-09-26 14:34:47 +02:00
MineTec d6130584f2 fixed several runtime errors from client error reports 2026-09-26 00:06:18 +02:00
MineTec 56a497985b improved performance and battery usage on older devices 2026-09-25 23:58:08 +02:00
MineTec ed8e52f475 timetable performance optimization 2026-09-25 22:27:10 +02:00
MineTec ef7d17033d added talk notification levels for chats 2026-09-25 17:04:10 +02:00
MineTec e21560ed3d added teacher-focused lesson sheet with direct class and teacher timetable links 2026-09-24 23:33:13 +02:00
MineTec de866b12b4 fixed stalled requests after app resume 2026-09-24 22:33:00 +02:00
MineTec 4f31e68456 replaced generic "Event" label with substitution or lesson text 2026-09-24 22:22:43 +02:00
MineTec 498d195138 fixed stale state after logout and replayed or lost share intents 2026-09-24 21:43:21 +02:00
MineTec e4e2b1a4fb dependency upgrade 2026-09-18 20:26:59 +02:00
MineTec 3734d7ff2c fixed possible lockups on app start 2026-09-11 12:04:32 +02:00
MineTec 43dfc52bc7 added clear message for denied camera or gallery access 2026-09-02 20:31:44 +02:00
MineTec f19f212fb4 platform upgrades 2026-09-02 20:17:17 +02:00
MineTec 71839d0848 multiple runtime bugfixes for 1.5.5+65 2026-08-28 20:42:30 +02:00
MineTec 65300614b1 bumped version to 1.5.4+64 2026-08-16 21:36:33 +02:00
MineTec 02a7b87b5b markdown support for talk messages 2026-08-16 21:35:41 +02:00
MineTec bd83a88e70 bumped version to 1.5.3+63 2026-08-16 14:00:37 +02:00
MineTec 09d4ecda78 fixed bug that prevents sending messages if reply was triggered in a different chat 2026-08-14 09:47:56 +02:00
MineTec d27312afdf renamed absence module 2026-08-14 09:37:41 +02:00
MineTec ccb22a497d added support for 2fa login with browser flow 2026-08-10 20:00:38 +02:00
MineTec 889d8f67c5 show classname instead of teacher name in teacher timetable view 2026-08-09 12:19:57 +02:00
MineTec 39c16bd4ea enhanced route transition gate because of a syncfusion viewer bug 2026-08-08 22:13:19 +02:00
MineTec b9cb1df473 added cache folder sweep check before file actions, preventing errors 2026-08-07 21:36:09 +02:00
MineTec 62fa337188 added integration_test to deps 2026-08-07 21:25:08 +02:00
MineTec c0dadf8b6e bumped minor version to 1.5.1+61 2026-08-06 20:30:20 +02:00
MineTec ab23422a86 widget refresh enhancements 2026-08-06 20:22:32 +02:00
MineTec 646e2c0451 removed timetable limits, fully dependent on marianumConnect now 2026-08-05 20:03:02 +02:00
MineTec 246cb0f527 simplified loadable state logic by removing connectivity debouncing and inlining indicator helpers; updated error bar visibility and chat list polling behavior 2026-08-02 08:21:25 +02:00
MineTec 4c2e9b47e7 Merge remote-tracking branch 'origin/develop' into develop 2026-08-01 18:19:56 +02:00
MineTec 2d690736e3 implemented connectivity debouncing and refetch throttling for loadable state UI components 2026-08-01 18:16:22 +02:00
MineTec 778c473631 added telemetry call on app resume with 15 minute cooldown 2026-07-28 12:52:07 +02:00
MineTec 7e9cbcf1e9 fixed widget mounting errors 2026-07-28 12:51:39 +02:00
MineTec 32be67426c Merge remote-tracking branch 'origin/develop' into develop 2026-07-26 13:49:05 +02:00
MineTec b957189fd3 implemented absence report module with form validation, prefill support, and API integration 2026-07-26 11:45:48 +02:00
MineTec 624c5512a6 fixed failing test 2026-07-23 14:22:32 +02:00
MineTec 3493cf8617 fixed error on pdf viewer transition 2026-07-23 14:16:33 +02:00
MineTec 75080a2c49 implemented shared items view for Talk chats with tabbed categories and on-scroll pagination 2026-07-19 10:58:02 +02:00
MineTec f3cd7896d9 implemented backward pagination for chat history with on-scroll prefetching 2026-07-17 20:14:47 +02:00
Marianum 101e7c909c bump version to 1.4.0+59 2026-07-17 15:19:26 +02:00
Marianum 429f1e6f96 fix build against updated dependencies: re-add DioExceptionType.transformTimeout case (restored in dio 5.10.0) and rename SwiftReceiveSharingIntentPlugin to ReceiveSharingIntentPlugin (renamed in receive_sharing_intent 1.9.0); refresh Podfile.lock and SPM Package.resolved 2026-07-17 15:19:10 +02:00
Marianum a0c55a811c completed iOS push notification setup: created the NotificationServiceExtension Xcode target programmatically (via xcodeproj), embedded it in Runner, and added its xcconfig base configs; fixed Automatic Signing by switching the NSE↔Runner shared keychain from an app-group to a team-prefixed access group across entitlements, Dart and Swift; fixed the analyzer break from dio 5.9.2 dropping DioExceptionType.transformTimeout 2026-07-17 15:05:34 +02:00
MineTec e625216a90 improved push notification settings with health indicators and optimized notification dismissal 2026-07-16 22:40:33 +02:00
MineTec 8128cede21 refactored teacher display in lesson details to better handle substitutions and duplicates 2026-07-16 21:44:08 +02:00
MineTec e5f7cf0176 updated the ProseMirror table view to conditionally hide the mode toggle toolbar when content fits within the available width, ensuring the toggle is only visible for overflowing tables. 2026-07-16 21:43:52 +02:00
MineTec e349d667d4 implemented a 1-minute in-memory cache for the EmergencyNoticeClient to prevent redundant network requests on app resume 2026-07-16 10:47:51 +02:00
MineTec 7c1f5c06df implemented a backend-independent emergency notice system 2026-07-15 20:30:20 +02:00
MineTec e8c6ac1c65 further improved accessibility across the application and add tooltips to action buttons 2026-07-15 19:29:40 +02:00
MineTec 478f0ff20b implemented comprehensive accessibility (A11y) support across the app 2026-07-13 23:41:47 +02:00
MineTec 8274dd46cd refactored HTTP error handling and streamlined UI components by centralizing shared logic and removing redundant parameters 2026-07-13 22:58:02 +02:00
MineTec d7536ea5d0 remove dead endpoint staging mode (obsoleted by demo mode) 2026-07-13 22:32:38 +02:00
MineTec 398b147c76 simplify: dedupe boilerplate and remove dead code across all layers 2026-07-13 22:22:39 +02:00
MineTec 15791423ea added fileFetchedAt to TickerPageResponse and updated the Ticker UI to use it as the data currency timestamp for proxied files 2026-07-13 00:32:27 +02:00
MineTec 2f5a6b4ce0 split file_viewer into focused sub-widgets 2026-07-13 00:01:07 +02:00
MineTec 53bc6d5360 share weekKey extension and log splash precache failures 2026-07-12 23:56:47 +02:00
MineTec f50359b4eb use AppProgressIndicator for plain loading spinners 2026-07-12 23:45:55 +02:00
MineTec 9994a1f3fa extract SettingsDropdownTile for settings dropdowns 2026-07-12 23:42:56 +02:00
MineTec 4aa31a2e44 extract SettingsCheckboxTile for settings toggles 2026-07-12 23:41:12 +02:00
MineTec dfce3e7b5c add PromptDialog helper for text input dialogs 2026-07-12 23:36:46 +02:00
MineTec 564a334cdc route avatar crop and profile view through AppRoutes 2026-07-12 23:32:48 +02:00
MineTec db329c7299 adopt MarianumConnectQuery base in remaining queries 2026-07-12 23:28:51 +02:00
MineTec 0a2ff5c3fb add MarianumConnectQuery base and adopt it in list queries 2026-07-12 23:24:21 +02:00
MineTec 9b5198c6db add ticker page bloc and avatar disk cache 2026-07-12 23:18:53 +02:00
MineTec 94794ff092 remove unused dead code files 2026-07-12 23:17:47 +02:00
MineTec fe2b3c43b2 implemented custom subject colors for the timetable and unified the color palette system 2026-07-12 21:30:08 +02:00
MineTec a7111844b1 bugfixes, better background operation robustness and platform-specific error handling 2026-07-12 19:17:49 +02:00
MineTec babc347b18 implemented global user search integration for Talk chats with role-coded badges and direct chat creation logic 2026-07-12 19:05:27 +02:00
MineTec 44e45c9b78 implemented full interactive poll support in Talk, including creation, voting, and closing functionality 2026-07-12 17:54:30 +02:00
MineTec 3f44e9302f implemented dynamic quick reactions by surfacing emojis from message content in the chat options dialog 2026-07-12 16:42:30 +02:00
MineTec 59501d3b45 implemented enlarged emoji rendering for chat messages containing up to three emojis 2026-07-12 16:26:22 +02:00
MineTec 91a6216f66 migrated custom timetable events to Marianum-Connect and refactored push notification handling 2026-07-12 14:31:48 +02:00
MineTec c444ed54a5 migrated Marianum Message module to the Marianum-Connect API and implemented push notification deep linking 2026-07-11 17:37:50 +02:00
MineTec ff23199345 downgrade AGP and Gradle versions, update Ticker icon, and upgrade dependencies 2026-07-11 15:32:09 +02:00
MineTec 9545d2a946 upgraded Android Gradle Plugin to 9.2.1 and Gradle wrapper to 9.4.1 2026-07-10 19:16:07 +02:00
MineTec 37608e59b3 implemented Ticker page selection persistence and enhanced ProseMirror table display modes 2026-07-10 19:15:52 +02:00
MineTec 0d01f6b631 fixed push-registration not activated on app-login 2026-07-10 15:35:56 +02:00
MineTec b0d2e7a34b fixed inconsistent placement of appbar title on iOS 2026-07-09 12:04:20 +02:00
MineTec cedeb06569 implemented the Ticker module with a native ProseMirror document renderer and integrated API support for structured content, navigation trees, and proxied files 2026-07-09 00:51:08 +02:00
MineTecandClaude Fable 5 1114291313 Add Fastlane Play Store integration with automated screenshots
Fastlane supply config (android/fastlane) with de-DE metadata structure,
integration_test-driven screenshot automation (screenshot_test.dart,
test_driver, tool/screenshots.sh) using stable keys on the login form,
refreshed materials/screenshots (phone + 7-inch) and app banner/symbol.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 23:43:10 +02:00
MineTec 9b74c9fd81 implemented a comprehensive client-side demo mode triggered by a "demo@" username prefix, serving curated fixture data for all app modules 2026-07-07 20:24:16 +02:00
MineTec 2668b111f1 updated login branding and the about dialog with updated slogan. 2026-07-07 19:21:11 +02:00
MineTec a19b67eb84 refactored and condensed technical documentation and comments across the codebase to improve readability 2026-07-06 23:17:06 +02:00
MineTec c48f5ef215 migrated feedback submission from the legacy mhsl.eu endpoint to the MarianumConnect API 2026-07-06 22:36:11 +02:00
MineTec ddbb3fbc19 implemented a new telemetry heartbeat and client-side error reporting system for MarianumConnect; replaced legacy mhsl.eu user index updates with a POST me/telemetry heartbeat using stable, securely-stored device identifiers; integrated global Flutter and platform error handlers to report uncaught exceptions with session-based deduplication and rate limiting. 2026-07-06 22:11:23 +02:00
MineTec 2be5051d12 migrated the breaker system to MarianumConnect and refactored the API response to a rule-based model with support for version-specific blocks using maxBuild. 2026-07-06 21:15:39 +02:00
MineTec 38a271929c implemented a guided notification permission flow triggered on the first Talk visit 2026-07-06 20:01:45 +02:00
MineTec 3be0113f93 fixed bug on leaving talk chat, spamming console 2026-07-06 15:55:27 +02:00
MineTec 2b59f11ea5 fixed z-index issue with background-loading indicator being behind talk-messages in chats 2026-07-06 15:51:08 +02:00
MineTec 124b1a5177 added support for simultan downloads in files and talk, support for background downloads, enhanced loading in files with retry 2026-07-06 15:43:17 +02:00
MineTec 614ab159af updated notification subject parsing to support newline separators for sender and message, while maintaining a fallback for legacy colon-delimited formats. 2026-07-05 23:57:48 +02:00
MineTec 5952c6619f fixed a race condition in long-poll initialization by adding a token validation guard to prevent stale chat sessions from hijacking the current state during navigation or app resumes. 2026-07-05 23:55:01 +02:00
MineTec dab208877f improved reliability and error handling for background notification actions by migrating to a top-level entry point for stable AOT callback resolution; implemented failure notifications to preserve undelivered reply text and surface technical errors; resolved a race condition that could resurrect dismissed notifications during silent re-renders; updated the Android manifest with the required broadcast receiver for notification actions; and refactored internal OCS methods to return detailed success/failure records for improved logging and field debugging. 2026-07-05 23:43:58 +02:00
MineTec 483fea62ba implemented dual Nextcloud push registration with separate general and talk apptypes to ensure reliable Talk notification delivery; introduced stacked MessagingStyle notifications for chat threads with support for circular conversation avatars and disk caching 2026-07-05 22:48:04 +02:00
MineTec 35e144799e Bumped Gradle to 8.14 and fixed build warnings 2026-07-05 00:17:20 +02:00
MineTec b14e8c8ea2 improved timetable date range calculation to ensure the current week remains accessible during holiday gaps or when persisted bounds are stale, and updated holiday labels to prioritize long names over short names in the calendar view. 2026-07-04 23:54:50 +02:00
MineTec 74a2ddd17f implemented an E2E-encrypted Nextcloud push-v2 notification system with support for RSA decryption and signature verification; introduced an iOS Notification Service Extension and native AppDelegate handlers for Talk actions (inline reply and mark-as-read); replaced the legacy notification registration with a new lifecycle managing app passwords and secure keypair storage; added background message handling with tray synchronization and a test notification utility in the settings. 2026-07-04 22:50:18 +02:00
MineTec 32f7c311bc fixed pending share race error on warm app start 2026-07-02 15:28:05 +02:00
MineTec 4bc7ffd37a implemented post-login splash screen with Lottie animations and integrated background data prefetching during the login transition 2026-06-29 12:00:26 +02:00
MineTec 13f4f79829 removed broad media access permissions from the manifest to comply with Google Play policies, and bumped the app version to 1.2.2+57. 2026-06-23 18:41:18 +02:00
MineTecandClaude Opus 4.8 a76b09af26 updated pods and SPM dependencies (Firebase 12.15, PhoneNumberKit)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 12:13:00 +02:00
Marianum 4fd09204ec iOS build 2026-06-23 12:06:22 +02:00
MineTec f554c57d8d updated version to 1.2.1+56 2026-06-22 22:44:54 +02:00
MineTec baa26a6e79 implemented a comprehensive Nextcloud file sharing system with support for user, group, and public link shares with gating based on server-side permissions; added sharing management interfaces including a share sheet; updated the file list with visual badges for incoming shares and improved OCS API response handling. 2026-06-02 21:42:08 +02:00
MineTec b6d06dd3b4 implemented foreign timetable support for students, teachers, rooms, and classes, including a searchable element picker with favorites support, introduced a capabilities system for feature gating, refactored the timetable UI into a reusable TimetableCalendarView component, and redesigned the chat input field with a unified emoji picker and integrated attachment actions. 2026-05-31 21:29:16 +02:00
MineTec 6e12da08c0 implemented a customizable chat background system with support for patterns, solid colors, and gallery images; added a dedicated settings page with live preview and adjustable blur/dim effects, updated the image cropper to support flexible aspect ratios for wallpapers, and integrated file cleanup logic during account logout. 2026-05-31 19:20:18 +02:00
MineTec 5ebf5bccdb implemented avatar management for user profiles and chat rooms, including 1:1 cropping, integrated OCS and Spreed avatar APIs, added cache invalidation logic, and updated the account settings view to display user info and profile pictures. 2026-05-31 18:42:30 +02:00
MineTec f966cf302b implemented favorite and leave actions for chat/rooms info view 2026-05-30 14:05:00 +02:00
MineTec 582432dbb9 implemented support for viewing large group profile pictures 2026-05-30 13:57:26 +02:00
MineTec ece0669f7d implemented a central haptic feedback system with configurable levels (off, reduced, full), added a Haptics facade providing semantic feedback methods, integrated haptic cues across navigation, settings toggles, and async action results, and updated version to 1.1.0+54 2026-05-30 13:54:19 +02:00
MineTec 01b4b44010 migrated holidays module to MarianumConnect API, replaced local Holiday model. 2026-05-24 17:49:25 +02:00
MineTec 93b9929f8f migrated timetable integration from WebUntis to the MarianumConnect API, implementing a Dio-based client with bearer token authentication, background session validation, and auto-refresh logic. 2026-05-23 17:32:42 +02:00
MineTec 2858f910c9 implemented DST-safe date arithmetic with new addDays and subtractDays extensions, updated timetable state to reset view and scroll boundaries on initialization to prevent stale views, added hard caps to calendar navigation, and updated version to 1.0.3+52 2026-05-22 15:08:30 +02:00
862 changed files with 49552 additions and 12119 deletions
+18
View File
@@ -350,3 +350,21 @@ hs_err_pid*
*.idea* *.idea*
**/.DS_store **/.DS_store
# Fastlane (Play-Store-Upload)
android/fastlane/report.xml
android/fastlane/Preview.html
android/fastlane/play-service-account.json
android/fastlane/README.md.bak
# lokale Screenshots außerhalb der Fastlane-Metadaten
/screenshots/
# Fastlane-Bild-PNGs sind generierte Kopien; kanonisch/versioniert liegen die
# Screenshots unter materials/screenshots/. Nur die leeren Ordner (.gitkeep)
# bleiben eingecheckt, damit supply die Struktur vorfindet.
android/fastlane/metadata/android/**/images/**/*.png
# Fastlane (App-Store-Upload)
ios/fastlane/report.xml
ios/fastlane/Preview.html
ios/fastlane/app-store-connect-api-key.json
ios/fastlane/screenshots/**/*.jpg
+10 -6
View File
@@ -1,6 +1,6 @@
# MarianumMobile Client # MarianumMobile Client
Flutter-App für die Schul-Community: Webuntis-Stundenplan, Nextcloud Talk + Files, Custom MHSL-Backend (Breaker, Custom Events, Push). Flutter-App für die Schul-Community: Stundenplan, Ticker, Newsletter & Co. über das MarianumConnect-Backend, Nextcloud Talk + Files.
## Stack ## Stack
@@ -16,7 +16,8 @@ Flutter-App für die Schul-Community: Webuntis-Stundenplan, Nextcloud Talk + Fil
``` ```
lib/ lib/
├── api/ HTTP-Layer pro Backend (mhsl/, marianumcloud/, webuntis/, holidays/) ├── api/ HTTP-Layer pro Backend (marianumconnect/, marianumcloud/, mhsl/ Legacy, demo/)
├── session/ Session-Modell, SessionManager, SessionLifecycle, Konten-Index
├── state/app/modules/ BLoC pro Feature-Modul (timetable, chat, chat_list, files, ...) ├── state/app/modules/ BLoC pro Feature-Modul (timetable, chat, chat_list, files, ...)
├── state/app/infrastructure LoadableState<T>, DataLoader, geteilte BLoC-Bausteine ├── state/app/infrastructure LoadableState<T>, DataLoader, geteilte BLoC-Bausteine
├── view/ Screens ├── view/ Screens
@@ -51,6 +52,10 @@ lib/
**Settings:** Pro Feature ein Freezed-Modell unter `lib/storage/`, persistiert via HydratedBloc. **Settings:** Pro Feature ein Freezed-Modell unter `lib/storage/`, persistiert via HydratedBloc.
**Session:** Die aktive Sitzung liegt in `SessionManager().current` (`lib/session/`). Nextcloud-Zugriffe nur über `SessionManager().requireNextcloud()`. Abmelden ausschließlich über `SessionLifecycle.signOut()`. Die Keychain-Keys in `SessionKeys` sind eingefroren (Bestandsinstallationen, iOS-NSE).
**Mehrere Konten:** Die `SessionKeys`-Slots, der MC-Token und die Group-Keychain spiegeln immer das *aktive* Konto; inaktive Konten liegen im Tresor (`accounts_index` + `account_vault_<id>`, `lib/session/account_codec.dart`). Wechseln/Hinzufügen/Entfernen nur über `SessionLifecycle` (`switchTo`, `beginAddAccount`/`finishLogin`/`cancelAddAccount`, `removeInactive`), danach `AccountBloc.activated(id)`. Lokale Daten sind pro Konto getrennt (`AccountStorage`: eigene HydratedBloc-Box; der Request-Cache `CacheStore` folgt dem aktiven Konto mit eigenem Verzeichnis); nur die Settings liegen in der globalen Box. `SessionManager().sessionEpoch` steigt bei jedem Kontowechsel – asynchrone Arbeit prüft damit, ob ihr Ergebnis noch zum aktiven Konto gehört. Was ein Konto außerhalb seines Speichers hinterlässt (Tray, Push-Schlüssel, Downloads), räumt `SessionWipe` auf. Alle Pro-Konto-Blocs gehören in `AccountScope` – er wird per Konto-ID neu aufgebaut (samt `MaterialApp`/Navigator). Push ist nur für das aktive Konto registriert.
## Build / Run ## Build / Run
```bash ```bash
@@ -65,13 +70,12 @@ flutter test # Tests (siehe test
| Backend | Pfad | Zweck | | Backend | Pfad | Zweck |
|---------------------------|-----------------------|----------------------------------------| |---------------------------|-----------------------|----------------------------------------|
| Webuntis | `lib/api/webuntis/` | Stundenplan, Klassen, Räume, Lehrer | | MarianumConnect (Bearer) | `lib/api/marianumconnect/` | Auth, Stundenplan (Webuntis-Proxy), Ticker, Newsletter, Ferien, Abwesenheit, Capabilities, Push |
| Nextcloud (Talk + WebDAV) | `lib/api/marianumcloud/` | Chats, Datei-Verwaltung | | Nextcloud (Talk + WebDAV) | `lib/api/marianumcloud/` | Chats, Datei-Verwaltung |
| Custom MHSL-Server | `lib/api/mhsl/` | Breaker, Custom Events, Notify, Noten | | MHSL (Legacy) | `lib/api/mhsl/` | nur noch Einmal-Migration der Custom Events |
| Holiday-Calendar | `lib/api/holidays/` | Ferien |
`nextcloud`-Paket ist auf einen Custom-Fork gepinnt (siehe `pubspec.yaml` `dependency_overrides`). `nextcloud`-Paket ist auf einen Custom-Fork gepinnt (siehe `pubspec.yaml` `dependency_overrides`).
## Tests ## Tests
`test/` deckt aktuell nur Kern-Funktionen ab (DateTime-Extensions, AsyncActionController, LessonResolver). Beim Hinzufügen neuer pure-function-Helper bitte Test mit dazu. `test/` deckt vor allem pure Funktionen ab (DateTime-Extensions, Stundenplan-Logik, Session-Codec). Beim Hinzufügen neuer pure-function-Helper bitte Test mit dazu.
+3
View File
@@ -18,6 +18,9 @@ analyzer:
- "**/*.freezed.dart" - "**/*.freezed.dart"
- "lib/firebase_options.dart" - "lib/firebase_options.dart"
- "build/**" - "build/**"
- android/**
- ios/**
- web/**
linter: linter:
rules: rules:
+1
View File
@@ -11,3 +11,4 @@ GeneratedPluginRegistrant.java
key.properties key.properties
**/*.keystore **/*.keystore
**/*.jks **/*.jks
.kotlin/
+10 -6
View File
@@ -33,10 +33,6 @@ android {
coreLibraryDesugaringEnabled true coreLibraryDesugaringEnabled true
} }
kotlinOptions {
jvmTarget = '17'
}
sourceSets { sourceSets {
main.java.srcDirs += 'src/main/kotlin' main.java.srcDirs += 'src/main/kotlin'
} }
@@ -47,7 +43,6 @@ android {
targetSdkVersion flutter.targetSdkVersion targetSdkVersion flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName
multiDexEnabled true
} }
buildTypes { buildTypes {
@@ -59,11 +54,20 @@ android {
} }
} }
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter { flutter {
source '../..' source '../..'
} }
dependencies { dependencies {
implementation 'com.android.support:multidex:2.0.1' // Same version as workmanager_android pins — needed to enqueue its
// BackgroundWorker from native widget code (the plugin uses
// `implementation`, so androidx.work is not exposed transitively).
implementation 'androidx.work:work-runtime:2.11.2'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
} }
@@ -4,4 +4,10 @@
to allow setting breakpoints, to provide hot reload, etc. to allow setting breakpoints, to provide hot reload, etc.
--> -->
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<!-- Allow cleartext HTTP in debug builds so developers can point the
Marianum-Connect custom endpoint at a local backend (e.g.
http://10.0.2.2:8080 from the Android emulator). Release builds
keep the default cleartext block. -->
<application android:usesCleartextTraffic="true" />
</manifest> </manifest>
+49 -2
View File
@@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application <application
android:label="Marianum Fulda" android:label="Marianum Fulda"
android:name="${applicationName}" android:name="${applicationName}"
@@ -8,7 +9,7 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTop" android:launchMode="singleTask"
android:theme="@style/LaunchTheme" android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
@@ -47,10 +48,40 @@
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<!-- Fallback small icon + accent for anything FCM ever renders itself
(e.g. plain notification messages). Launcher mipmaps are unusable
as small icons: Android only draws their alpha silhouette, which
shows up as a solid circle in the status bar. -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_stat_notification" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_color"
android:resource="@color/notification_accent" />
<!-- flutter_local_notifications liefert Notification-Action-Taps (Talk:
Antworten / Gelesen) als expliziten Broadcast an diesen Receiver.
Das Plugin deklariert ihn NICHT selbst — ohne diesen Eintrag kommt
der Tap nirgends an und der Background-Handler startet nie. -->
<receiver
android:name="com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver"
android:exported="false" />
<!-- background_downloader runs large/long downloads in a foreground
service (WorkManager) so they survive the app being backgrounded and
beat the default 9-minute WorkManager timeout. On API 34+ the
dataSync foreground service type must be declared explicitly here;
see the FOREGROUND_SERVICE_DATA_SYNC permission below. -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
<!-- Receiver classes live at the package root (NOT under .widgets) because <!-- Receiver classes live at the package root (NOT under .widgets) because
the home_widget Flutter plugin resolves them as <app-package>.<name>. --> the home_widget Flutter plugin resolves them as <app-package>.<name>. -->
<receiver <receiver
android:name="eu.mhsl.marianum.mobile.client.TimetableDayWidget" android:name="eu.mhsl.marianum.mobile.client.TimetableDayWidget"
android:enabled="@bool/widget_supported"
android:label="@string/widget_day_label" android:label="@string/widget_day_label"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
@@ -63,6 +94,7 @@
<receiver <receiver
android:name="eu.mhsl.marianum.mobile.client.TimetableWeekWidget" android:name="eu.mhsl.marianum.mobile.client.TimetableWeekWidget"
android:enabled="@bool/widget_supported"
android:label="@string/widget_week_label" android:label="@string/widget_week_label"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
@@ -103,7 +135,22 @@
</intent> </intent>
</queries> </queries>
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<!-- Android 13+ runtime notification permission. Requested at login via
FirebaseMessaging.requestPermission(); without this declaration the
locally rendered push notifications are silently dropped. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- background_downloader foreground service for large/long downloads that
must keep running while the app is backgrounded. DATA_SYNC is the
required type declaration on Android 14+ (API 34+). -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<!-- Workmanager periodic widget refresh needs to reschedule after device <!-- Workmanager periodic widget refresh needs to reschedule after device
reboot, otherwise the widget freezes until the user opens the app. --> reboot, otherwise the widget freezes until the user opens the app. -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<!-- Aus open_filex gemergt: die App öffnet nur selbst heruntergeladene Dateien
über dessen FileProvider (content://-URIs) und braucht keinen Zugriff auf
geteilte Medien. Entfernt wegen Google-Play "Photo and Video Permissions". -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" tools:node="remove"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" tools:node="remove"/>
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" tools:node="remove"/>
</manifest> </manifest>
@@ -1,25 +0,0 @@
// Generated file.
//
// If you wish to remove Flutter's multidex support, delete this entire file.
//
// Modifications to this file should be done in a copy under a different name
// as this file may be regenerated.
package io.flutter.app;
import android.app.Application;
import android.content.Context;
import androidx.annotation.CallSuper;
import androidx.multidex.MultiDex;
/**
* Extension of {@link android.app.Application}, adding multidex support.
*/
public class FlutterMultiDexApplication extends Application {
@Override
@CallSuper
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
MultiDex.install(this);
}
}
@@ -1,15 +1,38 @@
package eu.mhsl.marianum.mobile.client package eu.mhsl.marianum.mobile.client
import android.content.Intent import android.content.Intent
import android.os.Bundle
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
private val widgetChannel = "eu.mhsl.marianum.widget" private val widgetChannel = "eu.mhsl.marianum.widget"
private val shareChannel = "eu.mhsl.marianum.share"
/// Last seen widget tap target. Cleared by Dart via `consumePendingNavigation` /// Last seen widget tap target. Cleared by Dart via `consumePendingNavigation`
/// so the same intent isn't replayed on every resume. /// so the same intent isn't replayed on every resume.
private var pendingTimetableTap: Boolean = false private var pendingTimetableTap: Boolean = false
private val shareActions = setOf(Intent.ACTION_SEND, Intent.ACTION_SEND_MULTIPLE)
/// receive_sharing_intent only emits warm shares to a live Dart listener and
/// does not buffer them. After a relaunch into an existing task the share
/// arrives via onNewIntent before Dart subscribed, so it is held here until
/// Dart reports `listenerReady`.
private var shareListenerReady = false
private var bufferedShare: Intent? = null
override fun onCreate(savedInstanceState: Bundle?) {
// Android replays the original launch intent when the activity is
// recreated from recents or after process death. A share or widget tap
// that was already handled would otherwise run again — for shares with
// temp files that are long gone.
val replayed = savedInstanceState != null ||
(intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) != 0
if (replayed && (intent.action in shareActions || isWidgetTap(intent))) {
intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
}
super.onCreate(savedInstanceState)
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
@@ -26,17 +49,35 @@ class MainActivity : FlutterActivity() {
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
shareChannel
).setMethodCallHandler { call, result ->
when (call.method) {
"listenerReady" -> {
shareListenerReady = true
bufferedShare?.let { flutterEngine.activityControlSurface.onNewIntent(it) }
bufferedShare = null
result.success(null)
}
else -> result.notImplemented()
}
}
consumeIntentData(intent) consumeIntentData(intent)
} }
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
if (!shareListenerReady && intent.action in shareActions) {
bufferedShare = intent
}
consumeIntentData(intent) consumeIntentData(intent)
} }
private fun isWidgetTap(intent: Intent?): Boolean =
intent?.getBooleanExtra("widget_open_timetable", false) == true
private fun consumeIntentData(intent: Intent?) { private fun consumeIntentData(intent: Intent?) {
if (intent?.getBooleanExtra("widget_open_timetable", false) == true) { if (isWidgetTap(intent)) pendingTimetableTap = true
pendingTimetableTap = true
}
} }
} }
@@ -4,6 +4,7 @@ import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider import android.appwidget.AppWidgetProvider
import android.content.Context import android.content.Context
import android.os.Bundle import android.os.Bundle
import eu.mhsl.marianum.mobile.client.widgets.WidgetRefreshRequester
import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer
/** /**
@@ -11,6 +12,11 @@ import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer
* Flutter plugin resolves the receiver class as `<app-package>.<androidName>`. * Flutter plugin resolves the receiver class as `<app-package>.<androidName>`.
*/ */
class TimetableDayWidget : AppWidgetProvider() { class TimetableDayWidget : AppWidgetProvider() {
override fun onEnabled(context: Context) {
// First widget of this kind placed → fetch fresh data right away.
WidgetRefreshRequester.enqueueOneOffRefresh(context)
}
override fun onUpdate( override fun onUpdate(
context: Context, context: Context,
appWidgetManager: AppWidgetManager, appWidgetManager: AppWidgetManager,
@@ -4,9 +4,15 @@ import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider import android.appwidget.AppWidgetProvider
import android.content.Context import android.content.Context
import android.os.Bundle import android.os.Bundle
import eu.mhsl.marianum.mobile.client.widgets.WidgetRefreshRequester
import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer
class TimetableWeekWidget : AppWidgetProvider() { class TimetableWeekWidget : AppWidgetProvider() {
override fun onEnabled(context: Context) {
// First widget of this kind placed → fetch fresh data right away.
WidgetRefreshRequester.enqueueOneOffRefresh(context)
}
override fun onUpdate( override fun onUpdate(
context: Context, context: Context,
appWidgetManager: AppWidgetManager, appWidgetManager: AppWidgetManager,
@@ -11,7 +11,7 @@ import java.util.TimeZone
// Mirror of lib/widget_data/widget_data.dart — JSON keys + enum names // Mirror of lib/widget_data/widget_data.dart — JSON keys + enum names
// must stay in sync. // must stay in sync.
enum class WidgetLessonStatus { enum class WidgetLessonStatus {
REGULAR, ONGOING, PAST, CANCELLED, IRREGULAR, TEACHER_CHANGED, EVENT; REGULAR, ONGOING, PAST, CANCELLED, IRREGULAR, TEACHER_CHANGED, EVENT, DUTY;
companion object { companion object {
fun fromWire(raw: String?): WidgetLessonStatus = when (raw) { fun fromWire(raw: String?): WidgetLessonStatus = when (raw) {
@@ -22,6 +22,7 @@ enum class WidgetLessonStatus {
"irregular" -> IRREGULAR "irregular" -> IRREGULAR
"teacherChanged" -> TEACHER_CHANGED "teacherChanged" -> TEACHER_CHANGED
"event" -> EVENT "event" -> EVENT
"duty" -> DUTY
else -> REGULAR else -> REGULAR
} }
} }
@@ -33,10 +34,15 @@ data class WidgetLesson(
val subjectShort: String, val subjectShort: String,
val subjectLong: String?, val subjectLong: String?,
val room: String?, val room: String?,
// On teacher accounts this carries the class label ("7a") instead of the
// teacher short name (originalTeacher is null then) — mapped in Dart.
val teacher: String?, val teacher: String?,
val originalTeacher: String?, val originalTeacher: String?,
val status: WidgetLessonStatus, val status: WidgetLessonStatus,
val customColor: String?, val customColor: String?,
// Tile colour resolved in Dart (subject colours, palettes); null only for
// payloads written before the field existed.
val color: Int?,
val siblingCount: Int, val siblingCount: Int,
) )
@@ -48,6 +54,12 @@ data class WidgetPeriod(
val virtualEndMinutes: Int, val virtualEndMinutes: Int,
) )
data class WidgetDayInfo(
val date: Date,
val isHoliday: Boolean,
val holidayName: String?,
)
data class WidgetTimetableData( data class WidgetTimetableData(
val fetchedAt: Date, val fetchedAt: Date,
val anchorDate: Date, val anchorDate: Date,
@@ -55,6 +67,8 @@ data class WidgetTimetableData(
val periods: List<WidgetPeriod>, val periods: List<WidgetPeriod>,
val isHoliday: Boolean, val isHoliday: Boolean,
val holidayName: String?, val holidayName: String?,
// Week payload only; empty for the day payload.
val days: List<WidgetDayInfo>,
) )
object WidgetDataParser { object WidgetDataParser {
@@ -79,6 +93,11 @@ object WidgetDataParser {
} }
} }
private fun parseColor(raw: String?): Int? {
if (raw == null || !Regex("#[0-9A-Fa-f]{6}").matches(raw)) return null
return 0xFF000000.toInt() or raw.substring(1).toInt(16)
}
fun parse(json: String?): WidgetTimetableData? { fun parse(json: String?): WidgetTimetableData? {
if (json.isNullOrEmpty()) return null if (json.isNullOrEmpty()) return null
return try { return try {
@@ -100,6 +119,7 @@ object WidgetDataParser {
originalTeacher = obj.stringOrNull("originalTeacher"), originalTeacher = obj.stringOrNull("originalTeacher"),
status = WidgetLessonStatus.fromWire(obj.stringOrNull("status")), status = WidgetLessonStatus.fromWire(obj.stringOrNull("status")),
customColor = obj.stringOrNull("customColor"), customColor = obj.stringOrNull("customColor"),
color = parseColor(obj.stringOrNull("color")),
siblingCount = obj.optInt("siblingCount", 0), siblingCount = obj.optInt("siblingCount", 0),
) )
} }
@@ -118,6 +138,18 @@ object WidgetDataParser {
) )
} }
} }
val daysArray = root.optJSONArray("days")
val days = mutableListOf<WidgetDayInfo>()
if (daysArray != null) {
for (i in 0 until daysArray.length()) {
val obj = daysArray.optJSONObject(i) ?: continue
days += WidgetDayInfo(
date = parseDate(obj.stringOrNull("date")) ?: continue,
isHoliday = obj.optBoolean("isHoliday", false),
holidayName = obj.stringOrNull("holidayName"),
)
}
}
WidgetTimetableData( WidgetTimetableData(
fetchedAt = parseDate(root.stringOrNull("fetchedAt")) ?: Date(), fetchedAt = parseDate(root.stringOrNull("fetchedAt")) ?: Date(),
anchorDate = parseDate(root.stringOrNull("anchorDate")) ?: Date(), anchorDate = parseDate(root.stringOrNull("anchorDate")) ?: Date(),
@@ -125,6 +157,7 @@ object WidgetDataParser {
periods = periods, periods = periods,
isHoliday = root.optBoolean("isHoliday", false), isHoliday = root.optBoolean("isHoliday", false),
holidayName = root.stringOrNull("holidayName"), holidayName = root.stringOrNull("holidayName"),
days = days,
) )
} catch (_: JSONException) { } catch (_: JSONException) {
null null
@@ -0,0 +1,40 @@
package eu.mhsl.marianum.mobile.client.widgets
import android.content.Context
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import dev.fluttercommunity.workmanager.BackgroundWorker
/**
* Enqueues the app's Dart one-off widget refresh from native code, so a
* freshly placed widget populates within seconds instead of waiting for the
* next periodic slot.
*
* DART_TASK_KEY is public plugin API but no semver guarantee — re-verify on
* workmanager upgrades. The Dart callback handle comes from SharedPreferences
* persisted by Workmanager().initialize(); if the app never ran, the worker
* fails gracefully and the widget keeps rendering its cached/empty state.
*/
object WidgetRefreshRequester {
// Mirrors WidgetBackgroundTask.oneOffTaskName on the Dart side.
private const val DART_ONE_OFF_TASK = "eu.mhsl.marianum.widget.refresh.once"
fun enqueueOneOffRefresh(context: Context) {
val request = OneTimeWorkRequest.Builder(BackgroundWorker::class.java)
.setInputData(
Data.Builder()
.putString(BackgroundWorker.DART_TASK_KEY, DART_ONE_OFF_TASK)
.build(),
)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build(),
)
.build()
WorkManager.getInstance(context).enqueue(request)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Status-bar small icon: monochrome Marianum M, ported from
marianum_m_watermark.xml (same path + nested transforms). Android renders
only the alpha channel of small icons, so the fill is hard white and never
tinted at runtime. 24dp with the source's non-square viewport — the system
fits it into the icon bounds.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="70.000168"
android:viewportHeight="82.227348">
<group
android:scaleX="0.26458333"
android:scaleY="0.26458334"
android:translateX="107.44411"
android:translateY="-80.482198">
<group
android:translateX="-749.41293"
android:translateY="290.52252">
<group
android:scaleX="0.13333333"
android:scaleY="-0.13333333"
android:translateX="0"
android:translateY="632.14667">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M 3499.67,4594.33 c -16.43,-106.19 -29.71,-199.97 -43.79,-293.49 86.83,-19 138.5,-27.61 223.38,-43.82 63.81,-12.18 175.24,-20.4 179.64,-83.23 6.46,-92.69 -124.69,-55.41 -188.38,-43.81 -84.33,15.36 -159.13,28.84 -232.2,43.81 -13.68,-60.72 -26.83,-118.68 -39.43,-179.61 -36.76,-178.32 -73.67,-368.16 -105.11,-551.97 18.09,25.66 30.84,42.72 43.8,65.7 66.7,118.26 140.39,245.04 227.76,354.83 33.49,42.05 76.86,94.81 118.31,113.91 98.42,45.36 166.68,-22.2 170.87,-118.28 3.68,-85.28 -23.09,-181.17 -35.08,-275.99 -12.4,-98.19 -22.89,-194.93 -35.03,-275.98 72.44,102.69 147.93,269.64 240.95,381.12 27.51,33 73.55,80.61 118.27,87.62 218.76,34.33 126.58,-312.17 127.05,-473.13 0.4,-144.9 44.01,-255.37 175.21,-271.59 43.02,-5.31 105.84,11.16 112.7,-26.34 8.67,-47.38 -78.15,-60.52 -125.84,-61.28 -291.34,-4.51 -322.06,262.33 -311.01,573.88 -19.85,-18.57 -35.71,-47.53 -52.57,-74.47 -97.59,-155.88 -203.95,-327.22 -297.92,-503.79 -25.93,-48.79 -53.68,-114.7 -135.8,-83.23 -17.27,6.63 -48.25,44.39 -52.56,56.96 -19.58,57.19 1.55,137.42 8.76,205.89 21.54,203.72 57.81,389.09 78.87,587.01 -26.3,0.51 -43.93,-30.07 -56.96,-48.2 -46.9,-65.27 -86.02,-140.76 -127.04,-214.64 -52.84,-95.15 -108.23,-192.84 -157.71,-293.52 -75.25,-153.09 -188.6,-501.89 -242.12,-678.81 -8.67,-28.67 -17.7,-58.08 -26.3,-87.64 -7.48,-25.72 -10.39,-57.68 -35.05,-74.46 -100.02,18.93 -89.71,104.89 -70.09,205.9 47.35,243.43 170.89,706.45 211.48,946.04 -72.97,-70.97 -153.99,-207.41 -289.14,-236.55 -136.47,-29.44 -217.95,47.68 -271.6,122.66 -17.14,23.96 -41.43,49.54 -26.29,78.84 83.96,35.51 113.37,-65.2 197.15,-74.47 22.65,-2.5 54.56,2.4 74.46,8.78 132.4,42.34 237.57,218.76 297.87,346.07 74.16,156.45 125.32,330.5 148.95,490.64 -65.71,11.4 -142.96,22.15 -219.25,36.52 -109.8,20.72 -158.81,10.75 -201.29,59.86 9.15,41.95 41.41,60.8 70.1,83.24 126.26,-16.84 252.45,-33.77 372.36,-56.97 20.43,89.25 51.98,218.51 74.45,311.05 40.53,26.02 88.88,-8.43 105.17,-35.06 z" />
</group>
</group>
</group>
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material Icons "coffee_outlined", matches the in-app period ruler. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M18.52 3H6C4.92 3 3.98 3.89 3.98 5.02V10.69C3.98 14.53 6.94 17.91 10.78 18C14.72 18.14 18 14.95 18 11.02V9.98H18.52C20.44 9.98 21.98 8.44 21.98 6.52C21.98 4.55 20.44 3 18.52 3ZM15.98 5.02V8.02H6V5.02H15.98ZM15.98 9.98V11.02C15.98 13.78 13.78 15.98 11.02 15.98C8.25 15.98 6 13.78 6 11.02V9.98ZM18.52 8.02H18V5.02H18.52C19.31 5.02 20.02 5.67 20.02 6.52C20.02 7.31 19.31 8.02 18.52 8.02ZM3.98 18.98H20.02V21H3.98V18.98Z" />
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material Icons "event_outlined", matches the in-app holiday region. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M18.98 3.98H18V2.02H15.98V3.98H8.02V2.02H6V3.98H5.02C3.89 3.98 3 4.92 3 6V20.02C3 21.09 3.89 21.98 5.02 21.98H18.98C20.11 21.98 21 21.09 21 20.02V6C21 4.92 20.11 3.98 18.98 3.98ZM18.98 20.02H5.02V9.98H18.98V20.02ZM18.98 8.02H5.02V6H18.98V8.02ZM12 12.98H17.02V18H12V12.98Z" />
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material Icons "restaurant", matches the in-app break region. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M11.02 9H9V2.02H6.98V9H5.02V2.02H3V9C3 11.11 4.64 12.84 6.75 12.98V21.98H9.23V12.98C11.34 12.84 12.98 11.11 12.98 9V2.02H11.02V9ZM15.98 6V14.02H18.52V21.98H21V2.02C18.23 2.02 15.98 4.22 15.98 6Z" />
</vector>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Tinted at runtime with the tile colour resolved in Dart. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="7dp" />
<solid android:color="@android:color/white" />
</shape>
@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_cancelled" />
<stroke android:width="1.5dp" android:color="#C8FF0000" />
</shape>
</item>
<item
android:left="3dp"
android:top="3dp"
android:right="3dp"
android:bottom="3dp"
android:drawable="@drawable/widget_lesson_cancelled_x" />
</layer-list>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="#FF2196F3" />
</shape>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="#FF4CAF50" />
</shape>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="#FF993333" />
</shape>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_irregular" />
</shape>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_ongoing" />
</shape>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_past" />
</shape>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_regular" />
</shape>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_teacher_changed" />
</shape>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<corners android:radius="7dp" />
<solid android:color="@android:color/transparent" />
<stroke android:width="2dp" android:color="#C8F44336" />
</shape>
</item>
<item
android:left="2dp"
android:top="2dp"
android:right="2dp"
android:bottom="2dp"
android:drawable="@drawable/widget_lesson_cancelled_x" />
</layer-list>
@@ -5,7 +5,7 @@
android:viewportWidth="100" android:viewportWidth="100"
android:viewportHeight="100"> android:viewportHeight="100">
<path <path
android:strokeColor="#C8FF0000" android:strokeColor="#C8F44336"
android:strokeWidth="5" android:strokeWidth="2.5"
android:pathData="M0,0 L100,100 M100,0 L0,100" /> android:pathData="M0,0 L100,100 M100,0 L0,100" />
</vector> </vector>
@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" <shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"> android:shape="oval">
<corners android:radius="6dp" /> <solid android:color="@android:color/white" />
<solid android:color="#FFEF6C00" />
</shape> </shape>
@@ -3,6 +3,14 @@
android:id="@+id/widget_break_block_root" android:id="@+id/widget_break_block_root"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="14dp" android:layout_height="14dp"
android:layout_marginTop="0dp" android:layout_marginTop="0dp">
android:layout_marginStart="2dp"
android:layout_marginEnd="2dp" /> <ImageView
android:id="@+id/widget_break_block_icon"
android:layout_width="12dp"
android:layout_height="12dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:contentDescription="@null"
android:src="@drawable/widget_icon_restaurant" />
</FrameLayout>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_break_label_root"
android:layout_width="match_parent"
android:layout_height="14dp"
android:layout_marginTop="0dp">
<ImageView
android:id="@+id/widget_break_label_icon"
android:layout_width="10dp"
android:layout_height="10dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:contentDescription="@null"
android:src="@drawable/widget_icon_coffee" />
</FrameLayout>
+40 -13
View File
@@ -1,16 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- FrameLayout root so the Marianum watermark can sit at bottom|end behind <!-- FrameLayout root so the Marianum watermark can sit at bottom|end behind
the actual widget content without disturbing the LinearLayout flow. --> the actual widget content without disturbing the LinearLayout flow.
Text sizes and chrome heights are dp: WidgetRenderer derives the grid
height from them, so they must not follow the system font scale. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_root" android:id="@+id/widget_root"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="@drawable/app_widget_background"> android:background="@drawable/app_widget_background"
tools:ignore="SpUsage,SmallSp">
<!-- Bottom-leading so the mark looks like it's peeking out of the lower <!-- Width/height/end+bottom margins are overridden in the renderer so
left corner. Width/height/start+bottom margins are overridden in the the mark scales with the widget size. -->
renderer so the mark scales with the widget size. -->
<ImageView <ImageView
android:id="@+id/widget_watermark" android:id="@+id/widget_watermark"
android:layout_width="160dp" android:layout_width="160dp"
@@ -26,43 +28,68 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:padding="10dp"> android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="10dp"
android:paddingBottom="8dp">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="20dp"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center_vertical"> android:gravity="center_vertical">
<TextView <TextView
android:id="@+id/widget_day_title" android:id="@+id/widget_day_title"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:layout_weight="1" android:layout_weight="1"
android:textSize="14sp"
android:textStyle="bold" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:textColor="@color/widget_text_primary"
android:singleLine="true" android:maxLines="1"
android:ellipsize="end" android:ellipsize="end"
android:gravity="start|center_vertical"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="9dp"
android:autoSizeMaxTextSize="14dp"
android:autoSizeStepGranularity="0.5dp"
tools:text="Heute · 08.05." /> tools:text="Heute · 08.05." />
<TextView <TextView
android:id="@+id/widget_day_subtitle" android:id="@+id/widget_day_subtitle"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textSize="10sp" android:layout_marginStart="6dp"
android:textSize="10dp"
android:textColor="@color/widget_text_secondary" android:textColor="@color/widget_text_secondary"
android:maxLines="1"
android:includeFontPadding="false"
tools:text="Stand: 14:32" /> tools:text="Stand: 14:32" />
</LinearLayout> </LinearLayout>
<!-- Narrow day widgets can't fit title and freshness side by side —
the renderer moves the freshness label down here instead. -->
<TextView
android:id="@+id/widget_day_subtitle_below"
android:layout_width="match_parent"
android:layout_height="12dp"
android:visibility="gone"
android:gravity="start|center_vertical"
android:textSize="9dp"
android:textColor="@color/widget_text_secondary"
android:maxLines="1"
android:includeFontPadding="false"
tools:text="Stand: 14:32" />
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1" android:layout_weight="1"
android:layout_marginTop="6dp" android:layout_marginTop="4dp"
android:orientation="horizontal"> android:orientation="horizontal">
<FrameLayout <FrameLayout
android:id="@+id/widget_day_time_labels" android:id="@+id/widget_day_time_labels"
android:layout_width="32dp" android:layout_width="30dp"
android:layout_height="match_parent" /> android:layout_height="match_parent" />
<FrameLayout <FrameLayout
@@ -1,8 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Static picker preview of the day widget, laid out like WidgetRenderer's output. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="@color/widget_background"> android:background="@color/widget_background"
tools:ignore="SpUsage,SmallSp,HardcodedText,UseAppTint">
<ImageView <ImageView
android:layout_width="160dp" android:layout_width="160dp"
@@ -20,164 +23,126 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:padding="10dp"> android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="10dp"
android:paddingBottom="8dp">
<LinearLayout <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="20dp"
android:orientation="horizontal" android:gravity="start|center_vertical"
android:gravity="center_vertical"> android:includeFontPadding="false"
<TextView android:textSize="14dp"
android:layout_width="0dp" android:textStyle="bold"
android:layout_height="wrap_content" android:textColor="@color/widget_text_primary"
android:layout_weight="1" android:maxLines="1"
android:textSize="14sp" android:text="Heute · 06.05." />
android:textStyle="bold"
android:textColor="@color/widget_text_primary"
android:singleLine="true"
android:text="Heute · 06.05." />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="10sp"
android:textColor="@color/widget_text_secondary"
android:text="Stand: 07:50" />
</LinearLayout>
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1" android:layout_weight="1"
android:orientation="horizontal" android:layout_marginTop="4dp"
android:layout_marginTop="6dp"> android:orientation="horizontal">
<FrameLayout <FrameLayout android:layout_width="30dp" android:layout_height="match_parent">
android:layout_width="32dp" <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
android:layout_height="match_parent"> <FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="0dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="07:55" />
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="1" />
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp" <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="08:40" />
android:layout_marginTop="22dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:text="1." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="07:55" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
android:layout_marginTop="50dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:text="2." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="08:40" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
android:layout_marginTop="79dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:text="3." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="09:30" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
android:layout_marginTop="115dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:text="4." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="10:35" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
android:layout_marginTop="142dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:text="5." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="11:25" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
android:layout_marginTop="169dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:text="6." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="12:15" />
</LinearLayout>
</FrameLayout>
<FrameLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="29dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="56dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="83dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="115dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="143dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="170dp" android:background="@color/widget_divider" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="22dp"
android:layout_marginTop="29dp"
android:layout_marginStart="2dp"
android:layout_marginEnd="2dp"
android:padding="3dp"
android:background="@drawable/widget_lesson_block_regular">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@android:color/white" android:text="MA-LK" />
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="36dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="08:40" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="2" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="09:25" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="72dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="09:30" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="3" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="10:15" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp">
<ImageView android:layout_width="10dp" android:layout_height="10dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_text_secondary" android:src="@drawable/widget_icon_coffee" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="122dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="10:35" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="4" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="11:20" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="158dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="11:25" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="5" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="12:10" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="194dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="12:15" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="6" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="13:00" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="230dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="13:05" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="7" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="13:50" />
</FrameLayout>
</FrameLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<FrameLayout <FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
android:layout_width="match_parent" <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
android:layout_height="55dp" <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
android:layout_marginTop="83dp" <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
android:layout_marginStart="2dp" <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
android:layout_marginEnd="2dp" <FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
android:padding="3dp" <ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
android:background="@drawable/widget_lesson_block_regular"> </FrameLayout>
<LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content" <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
android:orientation="vertical"> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
android:textSize="11sp" android:textStyle="bold" <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
android:textColor="@android:color/white" android:text="DE-GK" /> <FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#5C6BC0">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
android:textSize="9sp" android:textColor="#CCFFFFFF" android:text="B11" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="MA-LK" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="B11" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="MUE" />
</LinearLayout> </LinearLayout>
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="72.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#993333">
<FrameLayout <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
android:layout_width="match_parent" <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="DE" />
android:layout_height="22dp" <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A204" />
android:layout_marginTop="143dp" <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="SCHN" />
android:layout_marginStart="2dp" </LinearLayout>
android:layout_marginEnd="2dp"
android:padding="3dp"
android:background="@drawable/widget_lesson_block_cancelled">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@android:color/white" android:text="BIO" />
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#0097A7">
<FrameLayout <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
android:layout_width="match_parent" <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="EN" />
android:layout_height="22dp" <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="F2.04" />
android:layout_marginTop="170dp" <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="KRA" />
android:layout_marginStart="2dp" </LinearLayout>
android:layout_marginEnd="2dp" </FrameLayout>
android:padding="3dp" <FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="158.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#000000">
android:background="@drawable/widget_lesson_block_teacher_changed"> <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="BIO" />
android:textSize="11sp" android:textStyle="bold" <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N103" />
android:textColor="@android:color/white" android:text="GE" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="WEB" />
</LinearLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="match_parent" android:background="@drawable/widget_lesson_cancelled_overlay" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="230.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#29639B">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="GE" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A110" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="BER" />
</LinearLayout>
</FrameLayout> </FrameLayout>
</FrameLayout> </FrameLayout>
</LinearLayout> </LinearLayout>
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Mirrors the in-app holiday region: icon and "Schulfrei" on top, the
holiday name rotated along the column. The name's width is set by the
renderer — after the rotation it is the length along the column. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_holiday_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:ignore="SpUsage,SmallSp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="vertical"
android:gravity="center_horizontal">
<ImageView
android:id="@+id/widget_holiday_icon"
android:layout_width="14dp"
android:layout_height="14dp"
android:importantForAccessibility="no"
android:contentDescription="@null"
android:src="@drawable/widget_icon_event" />
<TextView
android:id="@+id/widget_holiday_label"
android:layout_width="match_parent"
android:layout_height="12dp"
android:layout_marginTop="2dp"
android:paddingStart="2dp"
android:paddingEnd="2dp"
android:gravity="center"
android:text="@string/widget_school_free"
android:textColor="@color/widget_text_primary"
android:maxLines="1"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="6dp"
android:autoSizeMaxTextSize="9dp"
android:autoSizeStepGranularity="0.5dp" />
</LinearLayout>
<TextView
android:id="@+id/widget_holiday_name"
android:layout_width="120dp"
android:layout_height="14dp"
android:layout_gravity="center"
android:rotation="90"
android:gravity="center"
android:textStyle="bold"
android:letterSpacing="0.1"
android:textColor="@color/widget_text_primary"
android:maxLines="1"
android:ellipsize="end"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="7dp"
android:autoSizeMaxTextSize="11dp"
android:autoSizeStepGranularity="0.5dp"
tools:text="Herbstferien" />
</FrameLayout>
@@ -1,90 +1,113 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Mirrors AppointmentTile: title on top, room and teacher stacked below,
each line shrinking on its own to fit the tile width. Text sizes are dp
(not sp) because the line boxes are sized in dp by the renderer. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_lesson_block_root" android:id="@+id/widget_lesson_block_root"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="40dp" android:layout_height="40dp"
android:layout_marginStart="2dp" android:layout_marginStart="1dp"
android:layout_marginEnd="2dp" android:layout_marginEnd="1dp"
android:layout_marginTop="0dp" android:layout_marginTop="0dp"
android:paddingStart="7dp" android:background="@drawable/widget_lesson_block"
android:paddingEnd="7dp" tools:ignore="SpUsage,SmallSp">
android:paddingTop="3dp"
android:paddingBottom="3dp"
android:background="@drawable/widget_lesson_block_regular">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="horizontal" android:orientation="vertical"
android:gravity="top" android:paddingStart="4dp"
android:baselineAligned="false"> android:paddingEnd="4dp"
android:paddingTop="2dp"
android:paddingBottom="2dp">
<LinearLayout
android:id="@+id/widget_lesson_title_row"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:baselineAligned="false">
<TextView
android:id="@+id/widget_lesson_subject"
android:layout_width="0dp"
android:layout_height="14dp"
android:layout_weight="1"
android:fontFamily="sans-serif-medium"
android:textColor="@android:color/white"
android:maxLines="1"
android:ellipsize="end"
android:gravity="start|center_vertical"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="6.5dp"
android:autoSizeMaxTextSize="15dp"
android:autoSizeStepGranularity="0.5dp"
tools:text="MA-LK" />
<!-- Only for tiles too short for a body line below the title. -->
<TextView
android:id="@+id/widget_lesson_inline"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginStart="3dp"
android:textColor="@android:color/white"
android:textSize="7dp"
android:maxLines="1"
android:includeFontPadding="false"
android:visibility="gone"
tools:text="B11" />
</LinearLayout>
<TextView <TextView
android:id="@+id/widget_lesson_subject" android:id="@+id/widget_lesson_room"
android:layout_width="0dp" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="10dp"
android:layout_weight="1"
android:textStyle="bold"
android:textColor="@android:color/white" android:textColor="@android:color/white"
android:maxLines="1" android:maxLines="1"
android:ellipsize="end" android:ellipsize="end"
android:gravity="top|start" android:gravity="start|center_vertical"
android:includeFontPadding="false" android:includeFontPadding="false"
android:autoSizeTextType="uniform" android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="5sp" android:autoSizeMinTextSize="5.5dp"
android:autoSizeMaxTextSize="11sp" android:autoSizeMaxTextSize="10dp"
android:autoSizeStepGranularity="1sp" android:autoSizeStepGranularity="0.5dp"
tools:text="MA-LK" /> tools:text="A12" />
<LinearLayout <TextView
android:id="@+id/widget_lesson_secondary_stack" android:id="@+id/widget_lesson_teacher"
android:layout_width="0dp" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="10dp"
android:layout_weight="1" android:textColor="@android:color/white"
android:orientation="vertical" android:maxLines="1"
android:gravity="top|end" android:ellipsize="end"
android:layout_marginStart="4dp"> android:gravity="start|center_vertical"
android:includeFontPadding="false"
<TextView android:autoSizeTextType="uniform"
android:id="@+id/widget_lesson_room" android:autoSizeMinTextSize="5.5dp"
android:layout_width="wrap_content" android:autoSizeMaxTextSize="10dp"
android:layout_height="12dp" android:autoSizeStepGranularity="0.5dp"
android:textColor="#CCFFFFFF" tools:text="Müller" />
android:maxLines="1"
android:ellipsize="end"
android:gravity="end|top"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="5sp"
android:autoSizeMaxTextSize="11sp"
android:autoSizeStepGranularity="1sp"
tools:text="A12" />
<TextView
android:id="@+id/widget_lesson_teacher"
android:layout_width="wrap_content"
android:layout_height="12dp"
android:textColor="#B3FFFFFF"
android:maxLines="1"
android:ellipsize="end"
android:gravity="end|top"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="5sp"
android:autoSizeMaxTextSize="11sp"
android:autoSizeStepGranularity="1sp"
tools:text="Müller" />
</LinearLayout>
</LinearLayout> </LinearLayout>
<FrameLayout
android:id="@+id/widget_lesson_cancelled"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_lesson_cancelled_overlay"
android:visibility="gone" />
<TextView <TextView
android:id="@+id/widget_lesson_sibling_badge" android:id="@+id/widget_lesson_sibling_badge"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="bottom|start" android:layout_gravity="bottom|end"
android:layout_marginEnd="4dp"
android:layout_marginBottom="2dp"
android:textStyle="bold" android:textStyle="bold"
android:textSize="12sp" android:textSize="9dp"
android:textColor="@android:color/white" android:textColor="@android:color/white"
android:maxLines="1" android:maxLines="1"
android:includeFontPadding="false" android:includeFontPadding="false"
@@ -1,31 +1,50 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <!-- One period cell of the ruler, mirroring the in-app _PeriodLabel: number
centred, start time above, end time below. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_time_label_root" android:id="@+id/widget_time_label_root"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="36dp"
android:layout_marginTop="0dp" android:layout_marginTop="0dp"
android:orientation="vertical" tools:ignore="SpUsage,SmallSp">
android:gravity="end"
android:paddingEnd="3dp">
<TextView <TextView
android:id="@+id/widget_time_label_time" android:id="@+id/widget_time_label_start"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textSize="9sp" android:layout_gravity="top"
android:textColor="@color/widget_text_primary" android:layout_marginTop="2dp"
android:singleLine="true" android:gravity="center_horizontal"
android:lineSpacingExtra="-2dp" android:textSize="7dp"
android:textColor="@color/widget_text_secondary"
android:maxLines="1"
android:includeFontPadding="false"
tools:text="07:55" /> tools:text="07:55" />
<TextView <TextView
android:id="@+id/widget_time_label_number" android:id="@+id/widget_time_label_number"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textSize="7sp" android:layout_gravity="center_vertical"
android:gravity="center_horizontal"
android:fontFamily="sans-serif-medium"
android:textSize="11dp"
android:textColor="@color/widget_text_primary"
android:maxLines="1"
android:includeFontPadding="false"
tools:text="1" />
<TextView
android:id="@+id/widget_time_label_end"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:layout_marginBottom="2dp"
android:gravity="center_horizontal"
android:textSize="7dp"
android:textColor="@color/widget_text_secondary" android:textColor="@color/widget_text_secondary"
android:singleLine="true" android:maxLines="1"
android:lineSpacingExtra="-2dp" android:includeFontPadding="false"
tools:text="1." /> tools:text="08:40" />
</LinearLayout> </FrameLayout>
+36 -18
View File
@@ -1,11 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- FrameLayout root so the Marianum watermark can sit at bottom|end behind
the actual widget content without disturbing the LinearLayout flow.
Text sizes and chrome heights are dp: WidgetRenderer derives the grid
height from them, so they must not follow the system font scale. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_root" android:id="@+id/widget_root"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="@drawable/app_widget_background"> android:background="@drawable/app_widget_background"
tools:ignore="SpUsage,SmallSp">
<!-- Width/height/end+bottom margins are overridden in the renderer so
the mark scales with the widget size. -->
<ImageView <ImageView
android:id="@+id/widget_watermark" android:id="@+id/widget_watermark"
android:layout_width="160dp" android:layout_width="160dp"
@@ -21,42 +28,53 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:padding="10dp"> android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="10dp"
android:paddingBottom="8dp">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="20dp"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center_vertical"> android:gravity="center_vertical">
<TextView <TextView
android:id="@+id/widget_week_title" android:id="@+id/widget_week_title"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:layout_weight="1" android:layout_weight="1"
android:textSize="14sp"
android:textStyle="bold" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:textColor="@color/widget_text_primary"
android:singleLine="true" android:maxLines="1"
android:ellipsize="end" android:ellipsize="end"
android:gravity="start|center_vertical"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="9dp"
android:autoSizeMaxTextSize="14dp"
android:autoSizeStepGranularity="0.5dp"
tools:text="KW 19 · 06.05.–10.05." /> tools:text="KW 19 · 06.05.–10.05." />
<TextView <TextView
android:id="@+id/widget_week_subtitle" android:id="@+id/widget_week_subtitle"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textSize="10sp" android:layout_marginStart="6dp"
android:textSize="10dp"
android:textColor="@color/widget_text_secondary" android:textColor="@color/widget_text_secondary"
android:maxLines="1"
android:includeFontPadding="false"
tools:text="Stand: 14:32" /> tools:text="Stand: 14:32" />
</LinearLayout> </LinearLayout>
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="32dp"
android:orientation="horizontal" android:layout_marginTop="4dp"
android:layout_marginTop="6dp"> android:orientation="horizontal">
<FrameLayout <FrameLayout
android:layout_width="28dp" android:layout_width="30dp"
android:layout_height="wrap_content" /> android:layout_height="match_parent" />
<FrameLayout <FrameLayout
android:layout_width="1dp" android:layout_width="1dp"
@@ -66,7 +84,7 @@
<FrameLayout <FrameLayout
android:id="@+id/widget_week_header_mon" android:id="@+id/widget_week_header_mon"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:layout_weight="1" /> android:layout_weight="1" />
<FrameLayout <FrameLayout
@@ -77,7 +95,7 @@
<FrameLayout <FrameLayout
android:id="@+id/widget_week_header_tue" android:id="@+id/widget_week_header_tue"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:layout_weight="1" /> android:layout_weight="1" />
<FrameLayout <FrameLayout
@@ -88,7 +106,7 @@
<FrameLayout <FrameLayout
android:id="@+id/widget_week_header_wed" android:id="@+id/widget_week_header_wed"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:layout_weight="1" /> android:layout_weight="1" />
<FrameLayout <FrameLayout
@@ -99,7 +117,7 @@
<FrameLayout <FrameLayout
android:id="@+id/widget_week_header_thu" android:id="@+id/widget_week_header_thu"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:layout_weight="1" /> android:layout_weight="1" />
<FrameLayout <FrameLayout
@@ -110,7 +128,7 @@
<FrameLayout <FrameLayout
android:id="@+id/widget_week_header_fri" android:id="@+id/widget_week_header_fri"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:layout_weight="1" /> android:layout_weight="1" />
</LinearLayout> </LinearLayout>
@@ -123,7 +141,7 @@
<FrameLayout <FrameLayout
android:id="@+id/widget_week_time_labels" android:id="@+id/widget_week_time_labels"
android:layout_width="28dp" android:layout_width="30dp"
android:layout_height="match_parent" /> android:layout_height="match_parent" />
<FrameLayout <FrameLayout
@@ -1,28 +1,35 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Mirrors the in-app _DayHeaderCell: weekday in caps, day number below,
today inside an accent circle. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_week_day_header_root" android:id="@+id/widget_week_day_header_root"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:gravity="center" android:gravity="center"
android:paddingTop="2dp" tools:ignore="SpUsage,SmallSp">
android:paddingBottom="3dp">
<TextView <TextView
android:id="@+id/widget_week_day_header_weekday" android:id="@+id/widget_week_day_header_weekday"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:fontFamily="sans-serif-medium"
android:textStyle="bold" android:textSize="9dp"
android:textColor="@color/widget_text_primary" android:textColor="@color/widget_text_secondary"
tools:text="Mo" /> android:maxLines="1"
android:includeFontPadding="false"
tools:text="MO." />
<TextView <TextView
android:id="@+id/widget_week_day_header_date" android:id="@+id/widget_week_day_header_date"
android:layout_width="wrap_content" android:layout_width="18dp"
android:layout_height="wrap_content" android:layout_height="18dp"
android:textSize="9sp" android:layout_marginTop="1dp"
android:textColor="@color/widget_text_secondary" android:gravity="center"
tools:text="06.05." /> android:textSize="11dp"
android:textColor="@color/widget_text_primary"
android:maxLines="1"
android:includeFontPadding="false"
tools:text="6" />
</LinearLayout> </LinearLayout>
@@ -1,10 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Static preview for the week widget. Five day columns with two short <!-- Static picker preview of the week widget, laid out like WidgetRenderer's output. -->
demo blocks each so the structure is recognisable in the picker. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="@color/widget_background"> android:background="@color/widget_background"
tools:ignore="SpUsage,SmallSp,HardcodedText,UseAppTint">
<ImageView <ImageView
android:layout_width="200dp" android:layout_width="200dp"
@@ -22,124 +23,312 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:padding="10dp"> android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="10dp"
android:paddingBottom="8dp">
<TextView
android:layout_width="match_parent"
android:layout_height="20dp"
android:gravity="start|center_vertical"
android:includeFontPadding="false"
android:textSize="14dp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary"
android:maxLines="1"
android:text="KW 19 · 04.05.–08.05." />
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="32dp"
android:orientation="horizontal" android:layout_marginTop="4dp"
android:gravity="center_vertical"> android:orientation="horizontal">
<TextView <FrameLayout android:layout_width="30dp" android:layout_height="match_parent" />
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary"
android:singleLine="true"
android:text="KW 19 · 06.05.–10.05." />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="10sp"
android:textColor="@color/widget_text_secondary"
android:text="Stand: 07:50" />
</LinearLayout>
<!-- Day-name + date row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="6dp">
<FrameLayout android:layout_width="20dp" android:layout_height="wrap_content" />
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center"> <LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Mo" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" android:text="MO." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="06.05." /> <TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="4" />
</LinearLayout> </LinearLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center"> <LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Di" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" android:text="DI." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="07.05." /> <TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="5" />
</LinearLayout> </LinearLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center"> <LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Mi" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" android:text="MI." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="08.05." /> <TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="6" />
</LinearLayout> </LinearLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center"> <LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Do" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" android:text="DO." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="09.05." /> <TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="7" />
</LinearLayout> </LinearLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center"> <LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Fr" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" android:text="FR." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="10.05." /> <TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="8" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
<!-- Time grid: time-label column + 5 day columns -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1" android:layout_weight="1"
android:orientation="horizontal" android:layout_marginTop="2dp"
android:layout_marginTop="2dp"> android:orientation="horizontal">
<FrameLayout android:layout_width="20dp" android:layout_height="match_parent"> <FrameLayout android:layout_width="30dp" android:layout_height="match_parent">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="0dp" android:gravity="end" android:paddingEnd="3dp" android:textSize="8sp" android:textColor="@color/widget_text_secondary" android:text="08" /> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="36dp" android:gravity="end" android:paddingEnd="3dp" android:textSize="8sp" android:textColor="@color/widget_text_secondary" android:text="10" /> <FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="0dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="72dp" android:gravity="end" android:paddingEnd="3dp" android:textSize="8sp" android:textColor="@color/widget_text_secondary" android:text="12" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="07:55" />
</FrameLayout> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="1" />
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="08:40" />
<!-- Mon -->
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="2dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="MA" />
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="40dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular"> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="EN" /> <FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="36dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="08:40" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="2" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="09:25" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="72dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="09:30" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="3" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="10:15" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp">
<ImageView android:layout_width="10dp" android:layout_height="10dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_text_secondary" android:src="@drawable/widget_icon_coffee" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="122dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="10:35" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="4" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="11:20" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="158dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="11:25" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="5" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="12:10" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="194dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="12:15" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="6" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="13:00" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="230dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="13:05" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="7" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="13:50" />
</FrameLayout> </FrameLayout>
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<!-- Tue -->
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1"> <FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="20dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_cancelled"> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="BIO" /> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="60dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular"> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="DE" /> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#5C6BC0">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="MA-LK" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="B11" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="MUE" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="72.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#993333">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="DE" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A204" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="SCHN" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#0097A7">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="EN" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="F2.04" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="KRA" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="158.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#000000">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="BIO" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N103" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="WEB" />
</LinearLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="match_parent" android:background="@drawable/widget_lesson_cancelled_overlay" />
</FrameLayout> </FrameLayout>
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<!-- Wed -->
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1"> <FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="32dp" android:layout_marginTop="2dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular"> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="MA" /> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#26A69A">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="SPO" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="TH1" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="BAU" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="36.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#8F19B3">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="PoWi" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A105" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="HOF" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="84.5dp" android:layout_marginTop="72.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#29639B">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="PH" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N201" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="LEH" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="158.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#9E9D24">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="GE" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A110" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="BER" />
</LinearLayout>
</FrameLayout> </FrameLayout>
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<!-- Thu -->
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1"> <FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="2dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_teacher_changed"> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="GE" /> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="40dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular"> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="PH" /> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#E91E63">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="CH" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N305" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="MEI" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="72.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#5C6BC0">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="MA-LK" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="B11" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="MUE" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#993333">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="DE" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A204" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="SCHN" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="194.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#0097A7">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="EN" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="F2.04" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="KRA" />
</LinearLayout>
</FrameLayout> </FrameLayout>
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<!-- Fri -->
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1"> <FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="20dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular"> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="EN" /> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#78909C">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="EK" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A301" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="LAN" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="36.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#993333">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="BIO" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N103" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="WEB" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#7E57C2">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="MU" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="M1" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="SIN" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="158.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#F4511E">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="KU" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="K2" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="MAL" />
</LinearLayout>
</FrameLayout>
</FrameLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#993333">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="PH" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N201" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="LEH" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="72.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#FFA000">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="LA" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A108" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="CIC" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#9E9D24">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="GE" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A110" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="BER" />
</LinearLayout>
</FrameLayout> </FrameLayout>
</FrameLayout> </FrameLayout>
</LinearLayout> </LinearLayout>
@@ -4,4 +4,6 @@
<color name="widget_text_primary">#FFF1F1F1</color> <color name="widget_text_primary">#FFF1F1F1</color>
<color name="widget_text_secondary">#FFB0B0B0</color> <color name="widget_text_secondary">#FFB0B0B0</color>
<color name="widget_divider">#33FFFFFF</color> <color name="widget_divider">#33FFFFFF</color>
<color name="widget_preview_accent">#FFFFB3AF</color>
<color name="widget_preview_break">#32FFB3AF</color>
</resources> </resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<bool name="widget_supported">true</bool>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Brand accent for notifications; mirrors LightAppTheme.marianumRed
(Color.fromARGB(255, 153, 51, 51)). Used as the FCM default
notification color and must stay in sync with the Dart constant. -->
<color name="notification_accent">#993333</color>
</resources>
@@ -6,6 +6,7 @@
<string name="widget_week_description">Stundenplan und Vertretungen für die ganze Schulwoche.</string> <string name="widget_week_description">Stundenplan und Vertretungen für die ganze Schulwoche.</string>
<string name="widget_no_lessons">Keine Stunden</string> <string name="widget_no_lessons">Keine Stunden</string>
<string name="widget_holiday">Ferien</string> <string name="widget_holiday">Ferien</string>
<string name="widget_school_free">Schulfrei</string>
<string name="widget_login_required">Bitte einloggen, um den Stundenplan zu laden</string> <string name="widget_login_required">Bitte einloggen, um den Stundenplan zu laden</string>
<string name="widget_loading">Lade…</string> <string name="widget_loading">Lade…</string>
<string name="widget_status_label">Stand: %1$s</string> <string name="widget_status_label">Stand: %1$s</string>
@@ -1,17 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!-- Status colors mirror lib/view/pages/timetable/data/lesson_color.dart
exactly so widget tiles match the in-app calendar. -->
<color name="widget_lesson_regular">#FF993333</color>
<color name="widget_lesson_ongoing">#FFC83333</color>
<color name="widget_lesson_past">#FF993333</color>
<color name="widget_lesson_cancelled">#FF000000</color>
<color name="widget_lesson_irregular">#FF8F19B3</color>
<color name="widget_lesson_teacher_changed">#FF29639B</color>
<color name="widget_lesson_event">#FF2E7D32</color>
<color name="widget_background">#FFFCF7F5</color> <color name="widget_background">#FFFCF7F5</color>
<color name="widget_text_primary">#FF111111</color> <color name="widget_text_primary">#FF111111</color>
<color name="widget_text_secondary">#FF555555</color> <color name="widget_text_secondary">#FF555555</color>
<color name="widget_divider">#22000000</color> <color name="widget_divider">#22000000</color>
<color name="widget_preview_accent">#FF904A47</color>
<color name="widget_preview_break">#32904A47</color>
</resources> </resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- WidgetRenderer positions every tile with RemoteViews calls that only
exist from Android 12 (API 31). Below that the widgets are not
offered at all rather than showing up empty. -->
<bool name="widget_supported">false</bool>
</resources>
+23 -3
View File
@@ -5,10 +5,25 @@ allprojects {
} }
} }
rootProject.buildDir = '../build' rootProject.layout.buildDirectory = file('../build')
subprojects { subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}" project.layout.buildDirectory = rootProject.layout.buildDirectory.dir(project.name)
} }
// AGP 9 enables built-in Kotlin, but Flutter's migrator pins
// android.builtInKotlin=false while the plugin ecosystem catches up. Most
// plugins honour that flag and fall back to KGP themselves; file_picker and
// background_downloader only check the AGP major version and silently skip
// applying KGP, which leaves their Kotlin sources uncompiled (missing
// FilePickerPlugin/BackgroundDownloaderPlugin at link time). Apply KGP for
// them as soon as AGP is on the project, i.e. before their android {} block
// runs. Drop this once both plugins ship a builtInKotlin-aware build script.
gradle.beforeProject { sub ->
if (!(sub.name in ['file_picker', 'background_downloader'])) return
sub.plugins.withId('com.android.library') {
sub.pluginManager.apply('org.jetbrains.kotlin.android')
}
}
// Pin every Android subproject to JVM 17 so plugins that ship Kotlin sources // Pin every Android subproject to JVM 17 so plugins that ship Kotlin sources
// compiled with a higher target (e.g. receive_sharing_intent at 21) or stale // compiled with a higher target (e.g. receive_sharing_intent at 21) or stale
// Java compatibility (e.g. home_widget at 1.8) don't break the build under // Java compatibility (e.g. home_widget at 1.8) don't break the build under
@@ -22,6 +37,11 @@ subprojects { sub ->
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
} }
// Some plugins (e.g. app_settings) hard-pin an older compileSdk
// than their transitive AndroidX deps require, which fails the
// AAR metadata check. Align every plugin with the app's
// compileSdk (flutter.compileSdkVersion) so the build passes.
compileSdkVersion 36
} }
} }
sub.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { sub.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
@@ -37,5 +57,5 @@ subprojects {
} }
tasks.register("clean", Delete) { tasks.register("clean", Delete) {
delete rootProject.buildDir delete rootProject.layout.buildDirectory
} }
+6
View File
@@ -0,0 +1,6 @@
package_name("eu.mhsl.marianum.mobile.client")
# Service-Account-JSON für die Google Play Developer API. Pfad über die
# Umgebungsvariable SUPPLY_JSON_KEY setzen (die Datei selbst wird per
# .gitignore nicht eingecheckt).
json_key_file(ENV["SUPPLY_JSON_KEY"] || "fastlane/play-service-account.json")
+29
View File
@@ -0,0 +1,29 @@
default_platform(:android)
# Play-Store-Auslieferung über fastlane supply. Die Screenshots erzeugt
# ../tool/screenshots.sh (Demo-Login + integration_test) direkt in
# metadata/android/<locale>/images/. Texte/Changelogs liegen daneben in
# metadata/android/<locale>/.
platform :android do
desc "Nur Screenshots hochladen (verändert keine Texte/Binaries)"
lane :upload_screenshots do
upload_to_play_store(
skip_upload_apk: true,
skip_upload_aab: true,
skip_upload_metadata: true,
skip_upload_changelogs: true,
skip_upload_images: false,
skip_upload_screenshots: false,
metadata_path: "./fastlane/metadata/android",
)
end
desc "Store-Texte, Changelogs und Screenshots hochladen (keine Binaries)"
lane :upload_metadata do
upload_to_play_store(
skip_upload_apk: true,
skip_upload_aab: true,
metadata_path: "./fastlane/metadata/android",
)
end
end
+2
View File
@@ -0,0 +1,2 @@
# fastlane-Plugins (aktuell keine). Datei vorhanden, damit `fastlane` das
# Plugin-Handling initialisiert.
+35
View File
@@ -0,0 +1,35 @@
# Fastlane – Play-Store-Screenshots & Upload
Schritt 2 der Play-Store-Automatisierung. Die App muss dafür **nicht** neu
gebaut werden – der Demo-Modus (`demo@`-Login) liefert die kompletten Inhalte
aus Fixtures.
## 1. Screenshots aufnehmen
Ein Gerät/Emulator (≥ Android 8) per `adb devices` sichtbar, dann aus dem
**Client**-Wurzelverzeichnis:
```bash
tool/screenshots.sh
```
Fährt Phone (1080×1920), 7″ (1200×1920) und 10″ (1600×2560) nacheinander durch
(setzt die Displaygröße per `adb shell wm size/density`) und legt die PNGs in
`metadata/android/de-DE/images/{phone,sevenInch,tenInch}Screenshots/` ab.
- Nur eine Größe: `PROFILES="phone" tool/screenshots.sh`
- Profile-Build (flüssigere Frames): `BUILD_MODE=profile tool/screenshots.sh`
## 2. Hochladen
Service-Account-JSON der Google Play Developer API besorgen und referenzieren:
```bash
export SUPPLY_JSON_KEY=/pfad/zu/play-service-account.json
cd android
fastlane upload_screenshots # nur Bilder
# oder
fastlane upload_metadata # Bilder + Texte/Changelogs, keine Binaries
```
Der Service-Account-Key wird **nicht** eingecheckt (siehe `.gitignore`).
+4 -4
View File
@@ -1,6 +1,6 @@
org.gradle.jvmargs=-Xmx4G org.gradle.jvmargs=-Xmx4G
android.useAndroidX=true android.useAndroidX=true
android.enableJetifier=true # This newDsl flag was added automatically by Flutter migrator
android.defaults.buildfeatures.buildconfig=true android.newDsl=false
android.nonTransitiveRClass=false # This builtInKotlin flag was added automatically by Flutter migrator
android.nonFinalResIds=false android.builtInKotlin=false
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
+3 -3
View File
@@ -19,9 +19,9 @@ pluginManagement {
plugins { plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version '8.13.2' apply false id "com.android.application" version '9.1.0' apply false
id "com.android.library" version '8.13.2' apply false id "com.android.library" version '9.1.0' apply false
id "org.jetbrains.kotlin.android" version "2.2.20" apply false id "org.jetbrains.kotlin.android" version "2.4.0" apply false
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.10.0' id 'org.gradle.toolchains.foojay-resolver-convention' version '0.10.0'
} }
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -4,4 +4,5 @@ targets:
json_serializable: json_serializable:
options: options:
explicit_to_json: false explicit_to_json: false
checked: true
generic_argument_factories: true generic_argument_factories: true
+40
View File
@@ -0,0 +1,40 @@
---
#
# Hinweis: Die Steuerzeichen ("---") dürfen NICHT entfernt werden!
# Nach dem zweiten Steuerzeichen wird als Markdown interpretiert und es sind keine Kommentare mehr möglich!
# Kommentare sind nur innerhalb des Steuerblocks zugelassen, sowie die Variablen.
#
# Notfall-Nachricht der MarianumMobile-App
#
# Diese Datei wird bei jedem App-Start geladen.
# Solange 'active' nicht true ist, wird NICHTS angezeigt (Normalzustand).
#
# Steuerfelder:
# active: true schaltet die Anzeige ein (Default: false)
# dismissible: true = wegklickbar & gecachte Inhalte der App weiterhin normal sichtbar, false = Vollbild & nicht schließbar (Default: true)
# title: optionale Überschrift
#
# Im Notfall: 'active: false' auf 'active: true' setzen, unten Inhalt anpassen.
# Der Textinhalt wird in Markdown ausgewertet. Siehe https://markdownlivepreview.com/
# VORSICHT: Hashtags (#) sind in Markdown kein Kommentar sondern "Titel"!
# Beispielkonfiguration:
#
# ---
# # Ein Kommentar
# active: true
# dismissible: false
# title: Wichtiger Hinweis
# ---
# Hinweistext in Markdown
#
# ============================================================================
active: false
dismissible: true
title: Hinweis
---
# Serverstörung
Der Zugriff auf einige Funktionen ist derzeit großflächig **eingeschränkt**. Wir arbeiten an einer Lösung.
Bitte prüfe unter folgendem Link auf aktuelle Informationen der Schulleitung.
- Aktuelle Informationen: [www.marianum-fulda.de](https://www.marianum-fulda.de)
+234
View File
@@ -0,0 +1,234 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:marianum_mobile/app.dart';
import 'package:marianum_mobile/main.dart' as app;
import 'package:marianum_mobile/routing/app_routes.dart';
import 'package:marianum_mobile/state/app/modules/app_modules.dart';
import 'package:marianum_mobile/state/app/modules/settings/bloc/settings_cubit.dart';
import 'package:marianum_mobile/view/pages/talk/widgets/chat_tile.dart';
/// Marketing-Screenshot-Lauf für die Stores. Loggt sich über den Demo-Login
/// (`demo@…`, siehe DemoMode) ein und nimmt einen kuratierten Satz Screens auf:
/// Stundenplan (hell + dunkel), Talk-Liste, Talk-Chat, Dateien, „Mehr"-Bereich
/// und Einstellungen. Ausgeführt über `flutter drive` mit
/// test_driver/integration_test.dart, orchestriert von tool/screenshots.sh
/// (Android: Phone / 7″ / 10″) bzw. tool/screenshots_ios.sh (iPhone 6,5″ /
/// iPad 13″).
///
/// Kein Netzwerk: der Demo-Modus beantwortet allen Backend-Verkehr aus
/// Fixtures, daher ist der Lauf deterministisch.
Future<void> main() async {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('Store-Screenshots über den Demo-Login', (tester) async {
// Der integration_test-Harness installiert eigene Error-Handler. app()
// überschreibt sie in main() durch die produktiven (ClientErrorReporter),
// was die End-of-Test-Buchführung des Harness sprengt (_pendingExceptionDetails).
// Daher vorher sichern und nach main() zurücksetzen.
final harnessFlutterOnError = FlutterError.onError;
final harnessDispatcherOnError = PlatformDispatcher.instance.onError;
_log('main() starten');
await app.main();
FlutterError.onError = harnessFlutterOnError;
PlatformDispatcher.instance.onError = harnessDispatcherOnError;
await tester.pump(const Duration(seconds: 1));
_log('Login');
await _login(tester);
// Großzügig: auf einem frisch angelegten iOS-Simulator dauert der erste
// Post-Login-Aufbau deutlich länger als 20s.
_log('warte auf App-Shell');
await _pumpUntil(
tester,
find.byType(App),
timeout: const Duration(seconds: 60),
);
_log('warte auf Post-Login-Splash-Ende');
await _pumpUntilGone(
tester,
find.byKey(const ValueKey('post-login-splash')),
timeout: const Duration(seconds: 12),
);
await tester.pump(const Duration(seconds: 1));
// Screenshots auf Android brauchen eine einmalige Umwandlung der
// Flutter-Surface in eine lesbare Image-Texture. Erst hier – nach Login und
// Splash –, damit der sichtbare Screen erst kurz vor der Aufnahme „einfriert".
if (Platform.isAndroid) {
_log('convertFlutterSurfaceToImage');
await binding.convertFlutterSurfaceToImage();
}
// Den einmaligen Benachrichtigungs-Dialog beim ersten Talk-Besuch
// unterdrücken. Auf Android erteilt tool/screenshots.sh die Permission
// vorab, auf iOS lässt sie sich im Simulator nicht vorab erteilen – der
// Dialog würde sonst über der Talk-Liste liegen.
_markTalkPermissionPromptShown(tester);
// 1 + 2: Stundenplan in beiden Themes.
await _goTo(tester, Modules.timetable);
_setTheme(tester, ThemeMode.light);
await _capture(tester, binding, '1_stundenplan_hell');
_setTheme(tester, ThemeMode.dark);
await _capture(tester, binding, '2_stundenplan_dunkel');
// Restlicher Satz einheitlich im hellen Theme.
_setTheme(tester, ThemeMode.light);
// 3: Talk-Liste.
await _goTo(tester, Modules.talk);
await _capture(tester, binding, '3_talk_liste');
// 4: Talk-Chat (ersten Chat der Liste öffnen).
await tester.tap(find.byType(ChatTile).first);
await _capture(tester, binding, '4_talk_chat');
_popPushedPage(tester);
await tester.pump(const Duration(milliseconds: 500));
// 5: Dateien.
await _goTo(tester, Modules.files);
await _capture(tester, binding, '5_dateien');
// 6: „Mehr"-Bereich (letzter Tab, hinter den Modul-Tabs).
_goToMore(tester);
await _capture(tester, binding, '6_mehr');
// 7: Einstellungen (Vollbild-Push).
AppRoutes.openSettings(tester.element(find.byType(App)));
await _capture(tester, binding, '7_einstellungen');
_popPushedPage(tester);
_log('fertig');
});
}
void _log(String message) => debugPrint('SHOTS: $message');
Future<void> _login(WidgetTester tester) async {
final loginVisible = await _pumpUntil(
tester,
find.byKey(const Key('login-username-field')),
);
if (!loginVisible) {
_log('kein Login-Screen sichtbar – bereits angemeldet, überspringe Login');
return;
}
await tester.enterText(
find.byKey(const Key('login-username-field')),
'demo@screenshots',
);
await tester.enterText(find.byKey(const Key('login-password-field')), 'demo');
await tester.pump();
await tester.tap(find.byKey(const Key('login-submit-button')));
await tester.pump(const Duration(milliseconds: 500));
}
/// Setzt das App-Theme über den [SettingsCubit] (MaterialApp folgt
/// `settings.appTheme`). `val(write: true)` plant den Emit als Microtask.
void _setTheme(WidgetTester tester, ThemeMode mode) {
final context = tester.element(find.byType(App));
context.read<SettingsCubit>().val(write: true).appTheme = mode;
}
void _markTalkPermissionPromptShown(WidgetTester tester) {
final context = tester.element(find.byType(App));
context
.read<SettingsCubit>()
.val(write: true)
.notificationSettings
.talkPermissionPromptShown =
true;
}
/// Navigiert zu [module] – über den Bottom-Tab, wenn er in der Leiste liegt,
/// sonst als Vollbild-Push. Größenunabhängig, da die Tab-Anzahl je Gerät variiert.
Future<void> _goTo(WidgetTester tester, Modules module) async {
final appFinder = find.byType(App);
if (appFinder.evaluate().isEmpty) {
_log('WARN: App-Shell fehlt, kann $module nicht ansteuern');
return;
}
final context = tester.element(appFinder);
if (!AppRoutes.goToTab(context, module)) {
final resolved = AppModule.modules(context)[module];
if (resolved != null) AppRoutes.openModule(context, resolved);
}
}
/// Springt auf den „Mehr"-Tab. Der liegt hinter den Modul-Tabs, sein Index ist
/// also die Anzahl der Bottom-Bar-Module.
void _goToMore(WidgetTester tester) {
final context = tester.element(find.byType(App));
final moreIndex = AppModule.getBottomBarModules(context).length;
app.Main.bottomNavigator.jumpToTab(moreIndex);
}
/// Schließt einen per [AppRoutes] gepushten Vollbild-Screen wieder.
void _popPushedPage(WidgetTester tester) {
final navigator = AppRoutes.rootNavigatorKey.currentState;
if (navigator != null && navigator.canPop()) navigator.pop();
}
/// Lässt die Oberfläche zur Ruhe kommen und legt den Screenshot ab.
Future<void> _capture(
WidgetTester tester,
IntegrationTestWidgetsFlutterBinding binding,
String name,
) async {
// Feste Setzzeit statt „warte auf Spinner-Ende": die Demo-Fixtures kommen
// synchron (keine künstlichen Delays), aber Module wie Dateien/Chat laden erst
// beim Navigieren nach. Eine globale CircularProgressIndicator-Prüfung taugt
// nicht, weil die PersistentTabView alle Tabs am Leben hält und
// Avatar-Platzhalter dauerhaft einen Spinner zeigen. In 500ms-Schritten
// pumpen, damit Timer/Microtasks (u.a. der Theme-Emit) durchlaufen.
await _settle(tester, const Duration(seconds: 6));
_log('-> $name: takeScreenshot');
await binding.takeScreenshot(name);
}
/// Pumpt [duration] in 500ms-Schritten ab, damit die App async Fixtures laden
/// und ihre Frames rendern kann (Ersatz für pumpAndSettle, das an den
/// Dauer-Animationen hängen bleibt).
Future<void> _settle(WidgetTester tester, Duration duration) async {
final steps = duration.inMilliseconds ~/ 500;
for (var i = 0; i < steps; i++) {
await tester.pump(const Duration(milliseconds: 500));
}
}
/// Pumpt in kurzen Schritten, bis [finder] erscheint oder [timeout] abläuft.
/// Gibt zurück, ob [finder] gefunden wurde.
Future<bool> _pumpUntil(
WidgetTester tester,
Finder finder, {
Duration timeout = const Duration(seconds: 10),
}) async {
final end = DateTime.now().add(timeout);
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 200));
if (finder.evaluate().isNotEmpty) return true;
}
return false;
}
/// Gegenstück zu [_pumpUntil]: pumpt, bis [finder] nichts mehr trifft.
Future<bool> _pumpUntilGone(
WidgetTester tester,
Finder finder, {
Duration timeout = const Duration(seconds: 10),
}) async {
final end = DateTime.now().add(timeout);
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 200));
if (finder.evaluate().isEmpty) return true;
}
return false;
}
@@ -0,0 +1 @@
#include "Generated.xcconfig"
@@ -0,0 +1 @@
#include "Generated.xcconfig"
@@ -0,0 +1 @@
#include "Generated.xcconfig"
@@ -0,0 +1,31 @@
<?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>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>NotificationServiceExtension</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.usernotifications.service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,256 @@
import UserNotifications
import Security
/// Notification Service Extension for Nextcloud push-v2.
///
/// Architecture: Nextcloud pushes an E2E-encrypted notification to the
/// MarianumConnect proxy, which forwards it via FCM to this device as an
/// `alert` push with `mutable-content: 1`. That flag makes iOS spin up this
/// extension *before* showing the notification, giving us ~30 s (wall clock,
/// shared with the CPU budget) to decrypt the payload and replace the
/// placeholder alert with the real content.
///
/// The FCM proxy places the two relevant fields at the top level of the APNs
/// payload (mirrored into `request.content.userInfo`):
/// - `subject` base64( RSA-encrypted subject JSON ) — encrypted with THIS
/// device's public key, so we decrypt with our private key.
/// - `signature` base64( SHA-512-with-RSA over the *encrypted* subject bytes )
/// — signed by the per-user server key we stored at registration.
///
/// Key material lives in the shared (App Group) keychain, written by the Dart
/// side via flutter_secure_storage with
/// `IOSOptions(groupId: kPushKeychainGroup, accessibility: first_unlock)`:
/// - `push_device_private_key_pem` crypton PKCS#8-in-"RSA PRIVATE KEY" PEM
/// - `push_server_public_key_pem` SPKI "PUBLIC KEY" PEM (from NC)
///
/// Robustness contract: on ANY failure (missing keys, bad signature, decrypt
/// failure, malformed JSON, timeout) we deliver the untouched placeholder
/// content. We never crash and never deliver empty content.
class NotificationService: UNNotificationServiceExtension {
/// Must exactly match `kPushKeychainGroup` in lib/push/push_secure_storage.dart
/// and the `keychain-access-groups` entitlement of BOTH the Runner and this
/// extension. Wrong value here => keychain reads return nil => placeholder.
private static let keychainAccessGroup = "MY55VF3KPG.eu.mhsl.marianum.mobile.client.push"
private static let devicePrivateKeyAccount = "push_device_private_key_pem"
private static let serverPublicKeyAccount = "push_server_public_key_pem"
private static let talkCategoryId = "TALK_MESSAGE"
private var contentHandler: ((UNNotificationContent) -> Void)?
private var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
self.contentHandler = contentHandler
self.bestAttemptContent =
request.content.mutableCopy() as? UNMutableNotificationContent
guard let content = bestAttemptContent else {
contentHandler(request.content)
return
}
// Any early return delivers the placeholder unchanged — that is the
// intended fallback, never an error path that hides the notification.
let userInfo = content.userInfo
guard
let subjectB64 = userInfo["subject"] as? String,
let signatureB64 = userInfo["signature"] as? String,
let encrypted = Data(base64Encoded: subjectB64),
let signature = Data(base64Encoded: signatureB64)
else {
deliver(content)
return
}
guard let privateKey = loadPrivateKey() else {
NSLog("[NSE] no device private key in keychain, delivering placeholder")
deliver(content)
return
}
// Signature verification is defence-in-depth (the proxy already verified
// it). Skip only if we somehow have no server key; never deliver on a
// *failed* verification.
if let serverKey = loadServerPublicKey() {
if !verifySignature(signature, over: encrypted, with: serverKey) {
NSLog("[NSE] signature verification failed, delivering placeholder")
deliver(content)
return
}
}
guard
let plaintext = decrypt(encrypted, with: privateKey),
let subject = try? JSONSerialization.jsonObject(with: plaintext) as? [String: Any]
else {
NSLog("[NSE] could not decrypt/parse subject, delivering placeholder")
deliver(content)
return
}
apply(subject: subject, to: content)
deliver(content)
}
override func serviceExtensionTimeWillExpire() {
// Ran out of time — hand back whatever we have (at worst the placeholder).
if let handler = contentHandler, let content = bestAttemptContent {
handler(content)
}
}
private func deliver(_ content: UNNotificationContent) {
contentHandler?(content)
}
// MARK: - Content shaping
private func apply(subject: [String: Any], to content: UNMutableNotificationContent) {
// Delete pushes normally arrive as silent `background` pushes that never
// invoke this extension. If one reaches us anyway, an NSE cannot suppress
// the banner (iOS always shows *something*), so we leave the placeholder.
// Actual delete cleanup is done by the Flutter app on next open. See
// ios/PUSH_NSE_SETUP.md "Delete-Handling".
let isDelete = (subject["delete"] as? Bool == true)
|| (subject["delete-multiple"] as? Bool == true)
|| (subject["delete-all"] as? Bool == true)
if isDelete {
NSLog("[NSE] delete push reached NSE — cannot suppress, leaving placeholder")
return
}
let app = subject["app"] as? String
let text = subject["subject"] as? String ?? "Neue Benachrichtigung"
let objectId = stringValue(subject["id"])
let nid = subject["nid"]
if app == "spreed" {
// Talk: "Sender: message" -> title = Sender, body = message.
let (sender, message) = splitSender(text)
content.title = sender
content.body = message
content.categoryIdentifier = NotificationService.talkCategoryId
if let token = objectId, !token.isEmpty {
content.threadIdentifier = "talk_\(token)"
var info = content.userInfo
info["chatToken"] = token
if let nid = nid { info["nid"] = nid }
content.userInfo = info
}
} else {
content.title = text
content.body = ""
}
// Badge intentionally left untouched (the app recomputes it from the
// delivered notifications).
}
/// Splits a Talk subject into header and message. nextcloud/spreed's
/// Notifier separates them with a NEWLINE — `"{user}\n{message}"` for 1:1
/// chats, `"{user} in {call}\n{message}"` for groups (verified against
/// spreed; mirrors parseTalkSubject in lib/push/push_subject.dart). The
/// legacy `": "` form is kept as fallback for older payloads.
private func splitSender(_ subject: String) -> (String, String) {
if let range = subject.range(of: "\n") {
let sender = String(subject[..<range.lowerBound])
.trimmingCharacters(in: .whitespaces)
let message = String(subject[range.upperBound...])
if !sender.isEmpty && !message.isEmpty {
return (sender, message)
}
}
if let range = subject.range(of: ": ") {
let sender = String(subject[..<range.lowerBound])
let message = String(subject[range.upperBound...])
if !sender.isEmpty && !message.isEmpty {
return (sender, message)
}
}
return ("Talk", subject)
}
private func stringValue(_ value: Any?) -> String? {
if let s = value as? String { return s }
if let n = value as? NSNumber { return n.stringValue }
return nil
}
// MARK: - Crypto
private func verifySignature(_ signature: Data, over message: Data, with key: SecKey) -> Bool {
var error: Unmanaged<CFError>?
let ok = SecKeyVerifySignature(
key,
.rsaSignatureMessagePKCS1v15SHA512,
message as CFData,
signature as CFData,
&error
)
return ok
}
private func decrypt(_ data: Data, with key: SecKey) -> Data? {
// NC 32 defaults to OAEP(SHA-1); older instances use PKCS#1 v1.5. Try
// OAEP first, fall back to PKCS#1 — mirrors the Dart PushDecryptor.
for algorithm in [SecKeyAlgorithm.rsaEncryptionOAEPSHA1,
SecKeyAlgorithm.rsaEncryptionPKCS1] {
var error: Unmanaged<CFError>?
if let plain = SecKeyCreateDecryptedData(key, algorithm, data as CFData, &error) as Data? {
return plain
}
}
return nil
}
// MARK: - Key loading
private func loadPrivateKey() -> SecKey? {
guard let pem = keychainString(NotificationService.devicePrivateKeyAccount),
let der = PEM.der(fromPem: pem),
// crypton labels it "RSA PRIVATE KEY" but the body is PKCS#8.
let pkcs1 = PEM.pkcs1PrivateKey(fromPkcs8: der)
else { return nil }
return PEM.rsaKey(pkcs1: pkcs1, isPrivate: true)
}
private func loadServerPublicKey() -> SecKey? {
guard let pem = keychainString(NotificationService.serverPublicKeyAccount),
let der = PEM.der(fromPem: pem),
let pkcs1 = PEM.pkcs1PublicKey(fromSpki: der)
else { return nil }
return PEM.rsaKey(pkcs1: pkcs1, isPrivate: false)
}
// MARK: - Keychain
/// Reads a flutter_secure_storage entry from the shared keychain. Must match
/// exactly how flutter_secure_storage_darwin 0.3.2 stores items:
/// kSecClass = kSecClassGenericPassword
/// kSecAttrAccount = the Dart key, verbatim
/// kSecAttrService = (unset — the Dart IOSOptions set no accountName)
/// kSecAttrAccessGroup = the team-prefixed shared keychain group
/// value = raw UTF-8 bytes of the string
private func keychainString(_ account: String) -> String? {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: account,
kSecAttrAccessGroup: NotificationService.keychainAccessGroup,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne,
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
if status != errSecItemNotFound {
NSLog("[NSE] keychain read '\(account)' failed: \(status)")
}
return nil
}
return String(data: data, encoding: .utf8)
}
}
@@ -0,0 +1,14 @@
<?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>com.apple.security.application-groups</key>
<array>
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push</string>
</array>
</dict>
</plist>
+105
View File
@@ -0,0 +1,105 @@
import Foundation
import Security
/// Minimal PEM/DER helpers to turn the crypton-produced PEM strings into
/// `SecKey`s. iOS's `SecKeyCreateWithData` only accepts *raw PKCS#1* DER for
/// RSA, so we must strip the two wrapper formats crypton/Nextcloud use:
///
/// - Private key: crypton's `toPEM()` emits the `RSA PRIVATE KEY` label but the
/// DER body is actually **PKCS#8** `PrivateKeyInfo`
/// (SEQUENCE { INTEGER version, SEQUENCE algId, OCTET STRING pkcs1 }).
/// We extract the OCTET STRING content = the PKCS#1 `RSAPrivateKey`.
///
/// - Public key: `PUBLIC KEY` label, **SPKI** `SubjectPublicKeyInfo`
/// (SEQUENCE { SEQUENCE algId, BIT STRING spki }). We extract the BIT STRING
/// content and drop its leading 0x00 "unused bits" byte = the PKCS#1
/// `RSAPublicKey`.
///
/// Only DER lengths up to 4 bytes are handled — more than enough for RSA-2048.
enum PEM {
/// Strips the PEM armor and returns the base64-decoded DER.
static func der(fromPem pem: String) -> Data? {
let body = pem
.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
.filter { !$0.hasPrefix("-----") }
.joined()
return Data(base64Encoded: body)
}
/// PKCS#8 `PrivateKeyInfo` -> inner PKCS#1 `RSAPrivateKey`.
static func pkcs1PrivateKey(fromPkcs8 der: Data) -> Data? {
var reader = DERReader(der)
guard let outer = reader.readTLV(), outer.tag == 0x30 else { return nil }
var inner = DERReader(Data(outer.value))
guard let version = inner.readTLV(), version.tag == 0x02 else { return nil } // INTEGER version
guard let algId = inner.readTLV(), algId.tag == 0x30 else { return nil } // SEQUENCE algId
guard let octet = inner.readTLV(), octet.tag == 0x04 else { return nil } // OCTET STRING
return Data(octet.value)
}
/// SPKI `SubjectPublicKeyInfo` -> inner PKCS#1 `RSAPublicKey`.
static func pkcs1PublicKey(fromSpki der: Data) -> Data? {
var reader = DERReader(der)
guard let outer = reader.readTLV(), outer.tag == 0x30 else { return nil }
var inner = DERReader(Data(outer.value))
guard let algId = inner.readTLV(), algId.tag == 0x30 else { return nil } // SEQUENCE algId
guard let bitString = inner.readTLV(), bitString.tag == 0x03 else { return nil } // BIT STRING
var bytes = Array(bitString.value)
guard let first = bytes.first, first == 0x00 else { return nil } // unused-bits count
bytes.removeFirst()
return Data(bytes)
}
/// Builds an RSA `SecKey` from raw PKCS#1 DER.
static func rsaKey(pkcs1 der: Data, isPrivate: Bool) -> SecKey? {
let attributes: [CFString: Any] = [
kSecAttrKeyType: kSecAttrKeyTypeRSA,
kSecAttrKeyClass: isPrivate ? kSecAttrKeyClassPrivate : kSecAttrKeyClassPublic,
kSecAttrKeySizeInBits: 2048,
]
var error: Unmanaged<CFError>?
let key = SecKeyCreateWithData(der as CFData, attributes as CFDictionary, &error)
if key == nil {
NSLog("[NSE] SecKeyCreateWithData failed: \(String(describing: error?.takeRetainedValue()))")
}
return key
}
}
/// Tiny non-recursive DER TLV reader over a byte buffer.
private struct DERReader {
private let bytes: [UInt8]
private var pos = 0
init(_ data: Data) { bytes = [UInt8](data) }
/// Reads one tag-length-value triple, advancing past the value. Returns the
/// tag byte and the value bytes, or nil on a malformed/truncated buffer.
mutating func readTLV() -> (tag: UInt8, value: ArraySlice<UInt8>)? {
guard pos < bytes.count else { return nil }
let tag = bytes[pos]
pos += 1
guard pos < bytes.count else { return nil }
var length = Int(bytes[pos])
pos += 1
if length & 0x80 != 0 {
let numLengthBytes = length & 0x7F
guard numLengthBytes > 0, numLengthBytes <= 4, pos + numLengthBytes <= bytes.count else {
return nil
}
length = 0
for _ in 0..<numLengthBytes {
length = (length << 8) | Int(bytes[pos])
pos += 1
}
}
guard pos + length <= bytes.count else { return nil }
let value = bytes[pos..<pos + length]
pos += length
return (tag, value)
}
}
+312
View File
@@ -0,0 +1,312 @@
# iOS Notification Service Extension (NSE) — Setup & Übergabe
> **Für wen:** Eine spätere (kontextlose) Claude-Session **und** den Menschen, der
> auf einem Mac mit Xcode weiterbaut. Dieses Dokument ist die einzige Quelle der
> Wahrheit für die iOS-Push-Fertigstellung. Die Swift-/Dart-Dateien existieren
> bereits (auf Linux textuell erstellt, **nie kompiliert**). Was fehlt, ist die
> reine Xcode-Verdrahtung (Target anlegen, Capabilities, Signing) — das geht
> **nur** auf dem Mac.
---
## 1. Architektur-Kurzfassung
```
Nextcloud (cloud.marianum-fulda.de)
│ push-v2: E2E-verschlüsselte Notification (mit Device-Public-Key verschlüsselt)
▼
MarianumConnect Push-Proxy (connect.marianum-fulda.de/push-proxy/notifications)
│ verifiziert Signatur, leitet per FCM HTTP v1 weiter
▼
FCM → APNs → iOS-Gerät
│ alert-Push mit `mutable-content: 1`, `subject` + `signature` top-level im Payload
▼
NotificationServiceExtension (DIESES Target)
│ liest Device-Private-Key + Server-Public-Key aus dem geteilten Keychain
│ verifiziert Signatur → entschlüsselt subject → setzt Titel/Body/Category
▼
iOS zeigt die fertige Notification
```
- **Verschlüsselung:** OAEP-SHA1 (NC-32-Default) mit Fallback PKCS#1 v1.5.
- **Signatur:** `SHA512withRSA` über die **base64-dekodierten (verschlüsselten)**
subject-Bytes, geprüft mit dem per-User-Server-Public-Key aus der Registrierung.
- **Dart-Referenz:** `lib/push/` — insb. `push_decryptor.dart` (Krypto-Spiegel),
`push_secure_storage.dart` (Keychain-Optionen), `push_keypair.dart` (Keyformat),
`push_registration_store.dart` (was group-scoped gespeichert wird),
`push_registration.dart` (`_persistNativeAuthContext`).
- **Backend-Referenz:** MarianumConnect-Service `marMobileApi`, Endpunkte
`push-proxy/notifications` (NC→Proxy) und `PUT/DELETE me/push-device`.
- **Talk-Actions (Antworten / Als gelesen markieren):** werden **nativ** im
`AppDelegate` per `URLSession` an die Talk-OCS-API geschickt, weil bei einer
Notification-Action nicht garantiert ist, dass die Flutter-Engine läuft.
---
## 2. Dateiinventar
| Datei | Status | Anmerkung |
|-------|--------|-----------|
| `ios/NotificationServiceExtension/NotificationService.swift` | **existiert** | NSE-Hauptklasse (entschlüsseln + Content setzen) |
| `ios/NotificationServiceExtension/PEM.swift` | **existiert** | PEM/DER→`SecKey` (PKCS#8/SPKI-Stripping) |
| `ios/NotificationServiceExtension/Info.plist` | **existiert** | `NSExtensionPointIdentifier = com.apple.usernotifications.service` |
| `ios/NotificationServiceExtension/NotificationServiceExtension.entitlements` | **existiert** | App Group + keychain-access-groups |
| `ios/Runner/Runner.entitlements` | **geändert** | `keychain-access-groups` (widget-Group) ergänzt |
| `ios/Runner/AppDelegate.swift` | **geändert** | TALK_MESSAGE-Category + native Action-Behandlung |
| `lib/push/push_registration_store.dart` | **geändert** | schreibt `nextcloud_username` + `nextcloud_base_url` group-scoped |
| `lib/push/push_registration.dart` | **geändert** | `_persistNativeAuthContext()` bei `register()` |
| **Xcode-Target „NotificationServiceExtension"** | **existiert** | programmatisch via `xcodeproj`-Gem angelegt (2026-07-07), gespiegelt an der Share-Extension |
| `ios/Runner.xcodeproj/project.pbxproj` | **geändert** | NSE-Target, Dependency + „Embed Foundation Extensions" ergänzt |
| `ios/Flutter/NotificationServiceExtension-{Debug,Release,Profile}.xcconfig` | **neu** | Base-Configs, inkludieren `Generated.xcconfig` (Flutter-Versionsvariablen) |
> **Wichtig:** Die vier Dateien unter `ios/NotificationServiceExtension/` liegen
> schon auf der Platte. Beim Anlegen des Targets erzeugt Xcode eigene
> Platzhalter-Dateien — **diese löschen und die vorhandenen Dateien zuordnen**
> (siehe 3.2), sonst überschreibst du die fertige Implementierung.
---
## 3. Xcode-Checkliste (auf dem Mac)
> **Stand 2026-07-07:** Abschnitte 3.1–3.2 (Target anlegen, Dateien zuordnen) sind
> bereits **programmatisch** erledigt (via `xcodeproj`-Gem). Der unsignierte Build
> aller Targets läuft durch (`flutter build ios --no-codesign`), die
> `NotificationServiceExtension.appex` wird korrekt in `Runner.app/PlugIns/`
> eingebettet. **Offen bleiben nur noch Signing/Capabilities (3.3–3.4, 3.6)** —
> die brauchen den Apple-Developer-Account und einen signierten Build/Archive.
> Die 3.1/3.2-Anleitung unten bleibt als Referenz stehen (falls das Target mal neu
> aufgesetzt werden muss).
### 3.1 Target anlegen
1. `ios/Runner.xcworkspace` in Xcode öffnen (nicht `.xcodeproj`).
2. **File → New → Target… → iOS → Notification Service Extension**.
3. **Product Name exakt: `NotificationServiceExtension`** (der
`NSExtensionPrincipalClass` in der Info.plist ist
`$(PRODUCT_MODULE_NAME).NotificationService` — bei abweichendem Namen zerbricht
das). Language: Swift. „Embed in Application": Runner.
4. Beim Dialog „Activate scheme?" → Activate.
5. **Bundle Identifier:** `eu.mhsl.marianum.mobile.client.NotificationServiceExtension`
(Muster wie bei den bestehenden Extensions).
6. **Deployment Target = 15.0** (identisch zum Runner; im `post_install` des
Podfiles wird ohnehin alles auf 15.0 gezwungen).
### 3.2 Generierte Dateien durch die vorhandenen ersetzen
- Xcode legt eine eigene `NotificationService.swift` und `Info.plist` im
Target-Ordner an. **Beide aus dem Projekt entfernen** („Move to Trash" für die
frisch generierten) und stattdessen die bereits vorhandenen Dateien hinzufügen:
- `NotificationService.swift`, `PEM.swift` → **Target Membership: nur NSE**.
- `Info.plist` → in den **Build Settings** des NSE-Targets als
`INFOPLIST_FILE = NotificationServiceExtension/Info.plist` setzen (bzw. die
vorhandene Datei als Info.plist des Targets referenzieren).
- `NotificationServiceExtension.entitlements` → in Build Settings
`CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements`.
### 3.3 Capabilities (in **beiden** Targets: Runner **und** NSE)
- **App Groups:** `group.eu.mhsl.marianum.mobile.client.widget` aktivieren.
(Beim Runner ist die Group bereits vorhanden; beim NSE neu hinzufügen.)
- **Keychain Sharing:** Keychain-Group `group.eu.mhsl.marianum.mobile.client.widget`
hinzufügen. Die Entitlement-Dateien enthalten das schon als
`keychain-access-groups`; über die Capabilities-UI stellst du sicher, dass das
Provisioning-Profil es abdeckt.
- Prüfen, dass die Capabilities-UI **keine** doppelten/abweichenden Einträge
erzeugt hat (Xcode schreibt gern in die `.entitlements`; danach Abschnitt 4
gegenchecken).
### 3.4 Signing
- Beide Targets: **Team** = dasselbe wie Runner. Automatic Signing an, oder die
passenden Profile wählen. Der App-Identifier-Prefix (Team-ID) muss identisch
sein, sonst greift die Keychain-Group nicht.
### 3.5 Pods / Podfile
- Die NSE ist **komplett selbst-enthalten** (`Foundation`, `Security`,
`UserNotifications` — alles System-Frameworks). **Kein** Pod-Target nötig.
- Prüfen, ob irgendein Flutter-Plugin explizit ein NSE-Target im Podfile verlangt
(aktuell **nein** — `firebase_messaging` braucht keins). Falls doch mal nötig:
im Podfile analog zur `Share Extension` einen `target
'NotificationServiceExtension' do inherit! :search_paths end`-Block ergänzen,
danach `pod install`.
- Nach dem Anlegen einmal `cd ios && pod install` laufen lassen (aktualisiert die
Workspace-Referenzen).
### 3.6 aps-environment (Production!)
- `ios/Runner/Runner.entitlements` hat aktuell `aps-environment = development`.
**Für TestFlight/Release muss `production` aktiv sein.** Das wurde hier bewusst
**nicht** hart umgestellt, um den Debug-Flow nicht zu brechen.
- Empfehlung: über die **Capabilities → Push Notifications** und die
Build-Konfiguration steuern (Xcode setzt beim Archive automatisch `production`,
wenn das Profil ein Distribution-Profil ist). Beim Archive-Export kontrollieren,
dass in der finalen `.ipa` `aps-environment = production` steht (siehe 5.2).
---
## 4. Ermittelte Keychain-Details (verbindlich)
Die Dart-Seite schreibt mit
`IOSOptions(groupId: 'MY55VF3KPG.eu.mhsl.marianum.mobile.client.push', accessibility: first_unlock)`.
> **Wichtig (Stand 2026-07-07):** Als Keychain-Access-Group wird **nicht** mehr die
> App-Group (`group.*`) genutzt, sondern eine **team-prefixed** Group
> (`$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push`). Grund: Die
> Xcode-verwalteten Provisioning-Profile gewähren als `keychain-access-groups`
> nur `<TeamID>.*` — eine `group.*`-App-Group fällt da **nicht** drunter, was
> Automatic Signing mit „doesn't match the entitlements file's value for the
> keychain-access-groups entitlement" abbricht. Runner und NSE teilen die Group,
> weil sie mit demselben Team (`MY55VF3KPG`) signieren. Der `MY55VF3KPG.`-Prefix
> ist der stabile AppIdentifierPrefix und in Dart/Swift hart hinterlegt.
Aus dem Quellcode von **`flutter_secure_storage_darwin` 0.3.2** (gepinnt in
`pubspec.lock`) ergibt sich die exakte Ablage im Keychain:
| Keychain-Attribut | Wert |
|-------------------|------|
| `kSecClass` | `kSecClassGenericPassword` |
| `kSecAttrAccount` | der Dart-**Key**, **wortwörtlich** (kein Hash, kein Prefix) |
| `kSecAttrService` | **nicht gesetzt** (die `IOSOptions` setzen kein `accountName`) |
| `kSecAttrAccessGroup` | `MY55VF3KPG.eu.mhsl.marianum.mobile.client.push` (team-prefixed) |
| `kSecAttrAccessible` | `kSecAttrAccessibleAfterFirstUnlock` (aus `first_unlock`) |
| Wert (`kSecValueData`) | **rohe UTF-8-Bytes** des Strings (PEM/Passwort im Klartext) |
**Deshalb** fragt die Swift-Seite (`keychainString(...)` in NSE **und**
AppDelegate) exakt so ab: `GenericPassword` + `kSecAttrAccount = <key>` +
`kSecAttrAccessGroup` + `kSecMatchLimit=One` + `kSecReturnData=true`, **ohne**
`kSecAttrService`. (Ein gesetztes `kSecAttrService` würde nicht matchen.)
### Konkrete Einträge (Account-Namen)
| Account (`kSecAttrAccount`) | Inhalt | Geschrieben von | Genutzt von |
|-----------------------------|--------|-----------------|-------------|
| `push_device_private_key_pem` | Device-**Private**-Key, PEM | `push_keypair.dart` | **NSE** (entschlüsseln) |
| `push_device_public_key_pem` | Device-Public-Key, SPKI-PEM | `push_keypair.dart` | — (nur Registrierung) |
| `push_server_public_key_pem` | **Server**-Public-Key (per User), SPKI-PEM | `push_registration_store.dart` | **NSE** (Signatur prüfen) |
| `push_device_identifier` | NC Device-Identifier | `push_registration_store.dart` | Dart |
| `push_registered_fcm_token` | FCM-Token der Registrierung | `push_registration_store.dart` | Dart |
| `nextcloud_app_password` | NC App-Password | `session_manager.dart` | **AppDelegate** (Basic-Auth) |
| `nextcloud_username` | NC Username | `push_registration_store.dart` (**neu**) | **AppDelegate** (Basic-Auth) |
| `nextcloud_base_url` | z.B. `https://cloud.marianum-fulda.de` | `push_registration_store.dart` (**neu**) | **AppDelegate** (OCS-URL) |
> `username` und `password` (Realpasswort) liegen im **Default**-Storage des SessionManagers
> **ohne** `groupId` → nur im primären Access-Group der App, **für die NSE
> unsichtbar**. Deshalb werden `nextcloud_username` + `nextcloud_base_url` bei
> jeder `register()`-Runde zusätzlich **group-scoped** geschrieben. Das
> App-Password lag schon immer group-scoped.
### Schlüsselformate (kritisch für das PEM-Parsing)
`crypton` (2.2.1) erzeugt die Keys:
- **Private Key:** Label `-----BEGIN RSA PRIVATE KEY-----`, der DER-Body ist aber
**PKCS#8** `PrivateKeyInfo` (SEQ{ INTEGER version, SEQ algId, OCTET STRING
pkcs1 }) — **nicht** rohes PKCS#1! `SecKeyCreateWithData` will bei RSA aber
**rohes PKCS#1**. `PEM.pkcs1PrivateKey(fromPkcs8:)` strippt den PKCS#8-Wrapper
(extrahiert den OCTET-STRING-Inhalt).
- **Public Key** (Device **und** der von NC gelieferte Server-Key): Label
`-----BEGIN PUBLIC KEY-----`, DER = **SPKI** `SubjectPublicKeyInfo`.
`PEM.pkcs1PublicKey(fromSpki:)` extrahiert den BIT-STRING-Inhalt und wirft das
führende `0x00`-Byte (unused bits) weg → PKCS#1 `RSAPublicKey`.
---
## 5. Verifikation auf dem Mac
### 5.1 Build
- `flutter clean && flutter pub get`
- `cd ios && pod install`
- In Xcode: **beide** Targets bauen. NSE-Target-Scheme separat bauen, um Swift-
Fehler früh zu sehen.
- Auf echtem Gerät installieren (Push funktioniert **nicht** im Simulator).
### 5.2 aps-environment im Archive prüfen
- Product → Archive → Distribute (oder `.ipa` exportieren) → `.ipa` entpacken →
`codesign -d --entitlements :- Payload/Runner.app` → muss
`aps-environment = production` zeigen.
### 5.3 Testbenachrichtigung (schneller Smoke-Test, ohne NC-Krypto)
- In den App-Einstellungen → Benachrichtigungen → **Testbenachrichtigung**
(Backend `POST me/push-device/test`). Erwartung: Alert „Testbenachrichtigung /
Push-Benachrichtigungen funktionieren! 🎉". Testet Registrierung + FCM +
Rendering — die NSE ist hier **nicht** beteiligt (Connect-Push, kein
`mutable-content`).
### 5.4 Echte Talk-Nachricht (End-to-End inkl. NSE)
- Gerät sperren. Von einem Zweitaccount eine Talk-Nachricht schicken.
- **Erwartung:** Banner mit **echtem Absender + Nachrichtentext** (nicht der
Platzhalter „Neue Benachrichtigung / Tippen zum Öffnen"). Long-press →
Actions „Antworten" + „Als gelesen markieren".
- „Antworten" → Text senden → Nachricht erscheint im Chat (nativ via URLSession),
Chat wird als gelesen markiert.
- Alternativ auf der NC-Instanz: `occ notification:test-push --talk <user> -v`.
### 5.5 Woran erkenne ich, ob die NSE lief?
- **NSE lief & OK:** echter Absender/Text im Banner.
- **NSE lief nicht / Fehler:** Platzhalter-Text bleibt stehen. Das ist der
bewusste Fallback (nie leer, nie Crash).
- **Logs:** Über die macOS-**Console.app** das Gerät wählen und nach `[NSE]` bzw.
`[Talk action]` filtern (`NSLog`-Ausgaben). Oder in Xcode: Debug → Attach to
Process → `NotificationServiceExtension`.
---
## 6. Bekannte Fehlerbilder + Fixes
| Symptom | Wahrscheinliche Ursache | Fix |
|---------|-------------------------|-----|
| NSE greift gar nicht (immer Platzhalter, auch bei Talk) | `mutable-content` fehlt / Signing des NSE-Targets kaputt / Bundle-ID falsch | Backend-`FcmMessageFactory` setzt `mutable-content:1` (prüfen). NSE-Target-Signing + Provisioning prüfen. Bundle-ID = `…client.NotificationServiceExtension`. |
| Banner zeigt Platzhalter trotz Talk | PEM-Parsing/Padding schlägt fehl | Console.app nach `[NSE] SecKeyCreateWithData failed` / `could not decrypt`. Prüfen, dass Private-Key wirklich als PKCS#8-in-„RSA PRIVATE KEY" ankommt (crypton). OAEP↔PKCS1-Fallback ist schon drin. |
| `[NSE] no device private key in keychain` | Keychain-Group greift nicht | In **beiden** Targets Keychain Sharing + App Group aktiv? Team-ID identisch? Access-Group-String exakt `group.eu.mhsl.marianum.mobile.client.widget`? |
| Signatur schlägt immer fehl | Falscher/kein Server-Public-Key | `push_server_public_key_pem` vorhanden? (wird bei `register()` gesetzt). Notfalls: ohne Server-Key überspringt die NSE die Prüfung — bleibt sie hängen, ist der Key da aber falsch geparst. |
| Reply-Action tut nichts | Credentials fehlen / Delegate-Konflikt (s.u.) | Console.app nach `[Talk action]`. `nextcloud_username`/`_app_password`/`_base_url` im Keychain? App einmal neu einloggen (schreibt sie via `register()`). |
| Reply wird **doppelt** gesendet | AppDelegate **und** ein Plugin behandeln dieselbe Action | Der AppDelegate returnt bei TALK_REPLY/TALK_MARK_READ **ohne** `super`-Aufruf, das Plugin sieht die Action also nicht. Falls doch doppelt: prüfen, ob `flutter_local_notifications` auf iOS separat als Delegate registriert ist. |
### ⚠️ Delegate-Ownership (wichtigster Verifikationspunkt)
`FlutterAppDelegate` konformiert (über `FlutterAppLifeCycleProvider`) zu
`UNUserNotificationCenterDelegate` und **leitet** `userNotificationCenter(...)` an
die registrierten Plugins weiter (firebase_messaging,
flutter_local_notifications). Der `AppDelegate` setzt sich in
`didFinishLaunching` als `UNUserNotificationCenter.current().delegate = self`,
**überschreibt** die beiden Delegate-Methoden und ruft für alles **außer** den
zwei Talk-Actions `super` auf — dadurch bleibt das Plugin-Forwarding intakt. Das
ist der fragilste Teil und **muss auf dem Gerät verifiziert werden**:
- Normale Push-Taps müssen weiterhin ins richtige Chat/Ziel navigieren
(läuft über `super` → Plugins).
- Foreground-Darstellung von FCM darf nicht kaputtgehen (`willPresent` wird hier
bewusst **nicht** überschrieben, läuft komplett über `super`/Plugins).
- Sollte ein Plugin den Delegate **nach** `didFinishLaunching` erneut übernehmen
(und damit unsere Talk-Actions abfangen), das Setzen von
`UNUserNotificationCenter.current().delegate = self` an einen späteren Punkt
verschieben (z. B. nach dem ersten Dart-Frame). Ohne Gerät nicht abschließend
testbar.
> **Compile-Hinweis:** `override` an den beiden `userNotificationCenter`-Methoden
> ist korrekt, weil die Methoden über das von `FlutterAppDelegate` adoptierte
> Protokoll `FlutterAppLifeCycleProvider : UNUserNotificationCenterDelegate`
> sichtbar sind (verifiziert im Flutter-3.44-Engine-Header). `super.userNotification…`
> ist daher aufrufbar.
---
## 7. Offene TODOs / bewusste Einschränkungen
1. **Delete-Handling auf iOS (best effort).** Delete-Pushes kommen als silent
`background`-Pushes (`content-available`, **kein** `mutable-content`) → die NSE
läuft dafür **nicht**. Das Wegräumen erledigt der Flutter-Background-Handler
(`PushMessageHandler`) bzw. ein Sweep beim App-Öffnen. Erreicht doch mal ein
Delete-Subject die NSE, **kann** sie die Notification **nicht** unterdrücken
(eine NSE muss immer *irgendeinen* Content liefern) — sie lässt den Platzhalter
stehen und loggt. Kein Fix möglich, dokumentierte iOS-Limitierung.
2. **Rich-Detail via OCS (spätere Erweiterung).** v1 lädt **nichts** nach. Optional
könnte die NSE bei Talk `GET …/ocs/v2.php/apps/notifications/api/v2/notifications/{nid}`
mit dem App-Password abrufen, um Avatar/Rich-Subject anzuzeigen (Timeout < 25 s,
innerhalb des NSE-Budgets). Nicht implementiert.
3. **`aps-environment = production`** ist noch nicht hart gesetzt (Abschnitt 3.6) —
vor dem Release erledigen und im Archive gegenchecken (5.2).
4. **Keychain-Access-Group-Schreibweise (gelöst 2026-07-07).** Die Entitlements
listen `$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push` als
`keychain-access-groups` (team-prefixed, **keine** App-Group). Damit greift das
`<TeamID>.*` der Xcode-Profile und Automatic Signing läuft ohne Portal-Änderung
durch (verifiziert: `flutter build ios --release` signiert Runner **und** NSE
mit `MY55VF3KPG.eu.mhsl.marianum.mobile.client.push`). Der frühere App-Group-
Ansatz (`group.*`) scheiterte an genau diesem Profil-Matching. Falls der
Keychain-Zugriff zur Laufzeit doch scheitert (Status `-34018` /
`errSecMissingEntitlement`), prüfen, dass Dart (`push_secure_storage.dart`) und
Swift (`AppDelegate.swift`, `NotificationService.swift`) **exakt denselben**
vollqualifizierten Group-String verwenden.
+3
View File
@@ -46,5 +46,8 @@ end
post_install do |installer| post_install do |installer|
installer.pods_project.targets.each do |target| installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target) flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
end
end end
end end
+4 -269
View File
@@ -1,311 +1,46 @@
PODS: PODS:
- connectivity_plus (0.0.1):
- Flutter
- device_info_plus (0.0.1):
- Flutter
- DKImagePickerController/Core (4.3.9):
- DKImagePickerController/ImageDataManager
- DKImagePickerController/Resource
- DKImagePickerController/ImageDataManager (4.3.9)
- DKImagePickerController/PhotoGallery (4.3.9):
- DKImagePickerController/Core
- DKPhotoGallery
- DKImagePickerController/Resource (4.3.9)
- DKPhotoGallery (0.0.19):
- DKPhotoGallery/Core (= 0.0.19)
- DKPhotoGallery/Model (= 0.0.19)
- DKPhotoGallery/Preview (= 0.0.19)
- DKPhotoGallery/Resource (= 0.0.19)
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Core (0.0.19):
- DKPhotoGallery/Model
- DKPhotoGallery/Preview
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Model (0.0.19):
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Preview (0.0.19):
- DKPhotoGallery/Model
- DKPhotoGallery/Resource
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Resource (0.0.19):
- SDWebImage
- SwiftyGif
- emoji_picker_flutter (0.0.1):
- Flutter
- eraser (0.0.1): - eraser (0.0.1):
- Flutter - Flutter
- file_picker (0.0.1):
- DKImagePickerController/PhotoGallery
- Flutter
- Firebase/CoreOnly (12.12.0):
- FirebaseCore (~> 12.12.0)
- Firebase/Messaging (12.12.0):
- Firebase/CoreOnly
- FirebaseMessaging (~> 12.12.0)
- firebase_core (4.7.0):
- Firebase/CoreOnly (= 12.12.0)
- Flutter
- firebase_messaging (16.2.0):
- Firebase/Messaging (= 12.12.0)
- firebase_core
- Flutter
- FirebaseCore (12.12.1):
- FirebaseCoreInternal (~> 12.12.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Logger (~> 8.1)
- FirebaseCoreInternal (12.12.0):
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- FirebaseInstallations (12.12.0):
- FirebaseCore (~> 12.12.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- PromisesObjC (~> 2.4)
- FirebaseMessaging (12.12.0):
- FirebaseCore (~> 12.12.0)
- FirebaseInstallations (~> 12.12.0)
- GoogleDataTransport (~> 10.1)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Reachability (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- nanopb (~> 3.30910.0)
- Flutter (1.0.0) - Flutter (1.0.0)
- flutter_app_badge (2.0.0): - flutter_app_badge (2.0.0):
- Flutter - Flutter
- flutter_local_notifications (0.0.1):
- Flutter
- flutter_native_splash (2.4.3):
- Flutter
- flutter_secure_storage_darwin (10.0.0):
- Flutter
- FlutterMacOS
- GoogleDataTransport (10.1.0):
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
- GoogleUtilities/AppDelegateSwizzler (8.1.0):
- GoogleUtilities/Environment
- GoogleUtilities/Logger
- GoogleUtilities/Network
- GoogleUtilities/Privacy
- GoogleUtilities/Environment (8.1.0):
- GoogleUtilities/Privacy
- GoogleUtilities/Logger (8.1.0):
- GoogleUtilities/Environment
- GoogleUtilities/Privacy
- GoogleUtilities/Network (8.1.0):
- GoogleUtilities/Logger
- "GoogleUtilities/NSData+zlib"
- GoogleUtilities/Privacy
- GoogleUtilities/Reachability
- "GoogleUtilities/NSData+zlib (8.1.0)":
- GoogleUtilities/Privacy
- GoogleUtilities/Privacy (8.1.0)
- GoogleUtilities/Reachability (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GoogleUtilities/UserDefaults (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- home_widget (0.0.1):
- Flutter
- image_picker_ios (0.0.1):
- Flutter
- in_app_review (2.0.0):
- Flutter
- nanopb (3.30910.0):
- nanopb/decode (= 3.30910.0)
- nanopb/encode (= 3.30910.0)
- nanopb/decode (3.30910.0)
- nanopb/encode (3.30910.0)
- open_filex (0.0.2): - open_filex (0.0.2):
- Flutter - Flutter
- package_info_plus (0.4.5):
- Flutter
- PhoneNumberKit (3.7.11): - PhoneNumberKit (3.7.11):
- PhoneNumberKit/PhoneNumberKitCore (= 3.7.11) - PhoneNumberKit/PhoneNumberKitCore (= 3.7.11)
- PhoneNumberKit/UIKit (= 3.7.11) - PhoneNumberKit/UIKit (= 3.7.11)
- PhoneNumberKit/PhoneNumberKitCore (3.7.11) - PhoneNumberKit/PhoneNumberKitCore (3.7.11)
- PhoneNumberKit/UIKit (3.7.11): - PhoneNumberKit/UIKit (3.7.11):
- PhoneNumberKit/PhoneNumberKitCore - PhoneNumberKit/PhoneNumberKitCore
- PromisesObjC (2.4.0)
- receive_sharing_intent (1.8.1):
- Flutter
- screen_brightness_ios (2.1.3):
- Flutter
- SDWebImage (5.21.7):
- SDWebImage/Core (= 5.21.7)
- SDWebImage/Core (5.21.7)
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- SwiftyGif (5.4.5)
- syncfusion_flutter_pdfviewer (0.0.1):
- Flutter
- url_launcher_ios (0.0.1):
- Flutter
- video_player_avfoundation (0.0.1):
- Flutter
- FlutterMacOS
- wakelock_plus (0.0.1):
- Flutter
- workmanager_apple (0.0.1):
- Flutter
DEPENDENCIES: DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- emoji_picker_flutter (from `.symlinks/plugins/emoji_picker_flutter/ios`)
- eraser (from `.symlinks/plugins/eraser/ios`) - eraser (from `.symlinks/plugins/eraser/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
- Flutter (from `Flutter`) - Flutter (from `Flutter`)
- flutter_app_badge (from `.symlinks/plugins/flutter_app_badge/ios`) - flutter_app_badge (from `.symlinks/plugins/flutter_app_badge/ios`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
- home_widget (from `.symlinks/plugins/home_widget/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
- open_filex (from `.symlinks/plugins/open_filex/ios`) - open_filex (from `.symlinks/plugins/open_filex/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- PhoneNumberKit (~> 3.7.6) - PhoneNumberKit (~> 3.7.6)
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
- screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- syncfusion_flutter_pdfviewer (from `.symlinks/plugins/syncfusion_flutter_pdfviewer/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)
SPEC REPOS: SPEC REPOS:
trunk: trunk:
- DKImagePickerController
- DKPhotoGallery
- Firebase
- FirebaseCore
- FirebaseCoreInternal
- FirebaseInstallations
- FirebaseMessaging
- GoogleDataTransport
- GoogleUtilities
- nanopb
- PhoneNumberKit - PhoneNumberKit
- PromisesObjC
- SDWebImage
- SwiftyGif
EXTERNAL SOURCES: EXTERNAL SOURCES:
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
emoji_picker_flutter:
:path: ".symlinks/plugins/emoji_picker_flutter/ios"
eraser: eraser:
:path: ".symlinks/plugins/eraser/ios" :path: ".symlinks/plugins/eraser/ios"
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
firebase_core:
:path: ".symlinks/plugins/firebase_core/ios"
firebase_messaging:
:path: ".symlinks/plugins/firebase_messaging/ios"
Flutter: Flutter:
:path: Flutter :path: Flutter
flutter_app_badge: flutter_app_badge:
:path: ".symlinks/plugins/flutter_app_badge/ios" :path: ".symlinks/plugins/flutter_app_badge/ios"
flutter_local_notifications:
:path: ".symlinks/plugins/flutter_local_notifications/ios"
flutter_native_splash:
:path: ".symlinks/plugins/flutter_native_splash/ios"
flutter_secure_storage_darwin:
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
home_widget:
:path: ".symlinks/plugins/home_widget/ios"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
in_app_review:
:path: ".symlinks/plugins/in_app_review/ios"
open_filex: open_filex:
:path: ".symlinks/plugins/open_filex/ios" :path: ".symlinks/plugins/open_filex/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
receive_sharing_intent:
:path: ".symlinks/plugins/receive_sharing_intent/ios"
screen_brightness_ios:
:path: ".symlinks/plugins/screen_brightness_ios/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
syncfusion_flutter_pdfviewer:
:path: ".symlinks/plugins/syncfusion_flutter_pdfviewer/ios"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
video_player_avfoundation:
:path: ".symlinks/plugins/video_player_avfoundation/darwin"
wakelock_plus:
:path: ".symlinks/plugins/wakelock_plus/ios"
workmanager_apple:
:path: ".symlinks/plugins/workmanager_apple/ios"
SPEC CHECKSUMS: SPEC CHECKSUMS:
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
emoji_picker_flutter: ece213fc274bdddefb77d502d33080dc54e616cc
eraser: 83a4b06985f3702aa3d8dec816f9693266012937 eraser: 83a4b06985f3702aa3d8dec816f9693266012937
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
Firebase: aa154fee4e9b8eac17aa42344988865b3e857d33
firebase_core: 9156a152117c843440b0b990c785aa0259bc5447
firebase_messaging: 0d962ab44ff24ed36deb8fa2ee043c4671858269
FirebaseCore: 86241206e656f5c80c995e370e6c975913b9b284
FirebaseCoreInternal: 7c12fc3011d889085e765e317d7b9fd1cef97af9
FirebaseInstallations: 4e6e162aa4abaaeeeb01dd00179dfc5ad9c2194e
FirebaseMessaging: 341004946fa7ffc741344b20f1b667514fc93e31
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_app_badge: ca742dd659a157c1090ef7cd881cb78f48f3bcdf flutter_app_badge: ca742dd659a157c1090ef7cd881cb78f48f3bcdf
flutter_local_notifications: 643a3eda1ce1c0599413ca31672536d423dee214
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
in_app_review: 7dd1ea365263f834b8464673f9df72c80c17c937
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1 open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 PhoneNumberKit: 9ff0c5ae9fe4770193b68a3d3e6c938fe976788c
PhoneNumberKit: ced55861269312a5e3bc2ef82a58d6255b1c976a
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
screen_brightness_ios: 212d950bb99c915eee971c884f4a6c87c92cd13d
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
syncfusion_flutter_pdfviewer: 90dc48305d2e33d4aa20681d1e98ddeda891bc14
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
video_player_avfoundation: 3453f792138786248960ca029747fcd9f318ef52
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
PODFILE CHECKSUM: 424a9b4c0fe81d8ebeaa9cb0dfedb60a68b19a0d PODFILE CHECKSUM: 087d168982f24fb137e2d46f893b771b4b9955c6
COCOAPODS: 1.16.2 COCOAPODS: 1.17.0
+201 -19
View File
@@ -3,7 +3,7 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 54; objectVersion = 70;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
@@ -12,13 +12,18 @@
3321F80F2FB1C00C0011C712 /* Share Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3321F8052FB1C00C0011C712 /* Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 3321F80F2FB1C00C0011C712 /* Share Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3321F8052FB1C00C0011C712 /* Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
33FDB0982EE9ABDC000B2391 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */; }; 33FDB0982EE9ABDC000B2391 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
725388B5C3A724B19BD6FD06 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2960F029246C39E2A03F6D87 /* NotificationService.swift */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7832A860F2264966809A9402 /* NotificationServiceExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C3B91710361EFED8934F0FDC /* NotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
97D9AFE39CF2369A97F04721 /* PEM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 509DCCD474353408FE5806C5 /* PEM.swift */; };
AA0101070000000011111111 /* TimetableWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA0101020000000011111111 /* TimetableWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; AA0101070000000011111111 /* TimetableWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA0101020000000011111111 /* TimetableWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
AA0102010000000022222222 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0102020000000022222222 /* SceneDelegate.swift */; }; AA0102010000000022222222 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0102020000000022222222 /* SceneDelegate.swift */; };
B8263932DB64B022CCEE7A53 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90960A132A5F91779B3FBE28 /* Pods_Runner.framework */; }; B8263932DB64B022CCEE7A53 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90960A132A5F91779B3FBE28 /* Pods_Runner.framework */; };
C40CF71846788CD98CB99E2B /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F5B421EAF56B77B775E58E92 /* Foundation.framework */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -36,6 +41,13 @@
remoteGlobalIDString = AA0101010000000011111111; remoteGlobalIDString = AA0101010000000011111111;
remoteInfo = TimetableWidgetExtension; remoteInfo = TimetableWidgetExtension;
}; };
AABEF26FF24F76E01DA5ADEA /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = AEDC710FEBFF2CC736D88AB2;
remoteInfo = NotificationServiceExtension;
};
/* End PBXContainerItemProxy section */ /* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */ /* Begin PBXCopyFilesBuildPhase section */
@@ -47,6 +59,7 @@
files = ( files = (
3321F80F2FB1C00C0011C712 /* Share Extension.appex in Embed Foundation Extensions */, 3321F80F2FB1C00C0011C712 /* Share Extension.appex in Embed Foundation Extensions */,
AA0101070000000011111111 /* TimetableWidgetExtension.appex in Embed Foundation Extensions */, AA0101070000000011111111 /* TimetableWidgetExtension.appex in Embed Foundation Extensions */,
7832A860F2264966809A9402 /* NotificationServiceExtension.appex in Embed Foundation Extensions */,
); );
name = "Embed Foundation Extensions"; name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -64,17 +77,24 @@
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
12B96C78930441C73F123636 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
17E2EE012C4361AC05DCA4C9 /* NotificationServiceExtension-Release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Release.xcconfig"; path = "Flutter/NotificationServiceExtension-Release.xcconfig"; sourceTree = "<group>"; };
2960F029246C39E2A03F6D87 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
3321F8052FB1C00C0011C712 /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 3321F8052FB1C00C0011C712 /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; }; 33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Debug.xcconfig"; path = "Flutter/NotificationServiceExtension-Debug.xcconfig"; sourceTree = "<group>"; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
4509EC31CB08BA9BF367AF6C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; }; 4509EC31CB08BA9BF367AF6C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Share_Extension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Share_Extension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
509DCCD474353408FE5806C5 /* PEM.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PEM.swift; sourceTree = "<group>"; };
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = NotificationServiceExtension.entitlements; sourceTree = "<group>"; };
60E1803A3FB28FCC6F435E99 /* Pods-Share Extension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.release.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.release.xcconfig"; sourceTree = "<group>"; }; 60E1803A3FB28FCC6F435E99 /* Pods-Share Extension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.release.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.release.xcconfig"; sourceTree = "<group>"; };
64801C012A9112D500E8B558 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; }; 64801C012A9112D500E8B558 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
90960A132A5F91779B3FBE28 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 90960A132A5F91779B3FBE28 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
@@ -93,9 +113,12 @@
BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Debug.xcconfig"; path = "Flutter/TimetableWidget-Debug.xcconfig"; sourceTree = "<group>"; }; BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Debug.xcconfig"; path = "Flutter/TimetableWidget-Debug.xcconfig"; sourceTree = "<group>"; };
BB0001050000000011111111 /* TimetableWidget-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Release.xcconfig"; path = "Flutter/TimetableWidget-Release.xcconfig"; sourceTree = "<group>"; }; BB0001050000000011111111 /* TimetableWidget-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Release.xcconfig"; path = "Flutter/TimetableWidget-Release.xcconfig"; sourceTree = "<group>"; };
BB0001060000000011111111 /* TimetableWidget-Profile.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Profile.xcconfig"; path = "Flutter/TimetableWidget-Profile.xcconfig"; sourceTree = "<group>"; }; BB0001060000000011111111 /* TimetableWidget-Profile.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Profile.xcconfig"; path = "Flutter/TimetableWidget-Profile.xcconfig"; sourceTree = "<group>"; };
C3B91710361EFED8934F0FDC /* NotificationServiceExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationServiceExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
C7E1879BE78835C7E3256316 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; }; C7E1879BE78835C7E3256316 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
DD904D7C0FC0AD11449CEB80 /* Pods-Share Extension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.debug.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.debug.xcconfig"; sourceTree = "<group>"; }; DD904D7C0FC0AD11449CEB80 /* Pods-Share Extension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.debug.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.debug.xcconfig"; sourceTree = "<group>"; };
EF5279D9BF8FCBB117AF998E /* Pods-Share Extension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.profile.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.profile.xcconfig"; sourceTree = "<group>"; }; EF5279D9BF8FCBB117AF998E /* Pods-Share Extension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.profile.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.profile.xcconfig"; sourceTree = "<group>"; };
F5B421EAF56B77B775E58E92 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
F758339399E7B5FD1A88FC92 /* NotificationServiceExtension-Profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Profile.xcconfig"; path = "Flutter/NotificationServiceExtension-Profile.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -155,6 +178,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
B8263932DB64B022CCEE7A53 /* Pods_Runner.framework in Frameworks */, B8263932DB64B022CCEE7A53 /* Pods_Runner.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -166,6 +190,14 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
D2447D92E9CCD96B3292B17B /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
C40CF71846788CD98CB99E2B /* Foundation.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
@@ -182,18 +214,39 @@
path = Pods; path = Pods;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
553E8F3190182FD2E527FEB5 /* NotificationServiceExtension */ = {
isa = PBXGroup;
children = (
2960F029246C39E2A03F6D87 /* NotificationService.swift */,
509DCCD474353408FE5806C5 /* PEM.swift */,
12B96C78930441C73F123636 /* Info.plist */,
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */,
);
path = NotificationServiceExtension;
sourceTree = SOURCE_ROOT;
};
731388A08E3B330B216381D0 /* Frameworks */ = { 731388A08E3B330B216381D0 /* Frameworks */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
90960A132A5F91779B3FBE28 /* Pods_Runner.framework */, 90960A132A5F91779B3FBE28 /* Pods_Runner.framework */,
4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */, 4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */,
80D9B9919D3D7CCA2A80C8C5 /* iOS */,
); );
name = Frameworks; name = Frameworks;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
80D9B9919D3D7CCA2A80C8C5 /* iOS */ = {
isa = PBXGroup;
children = (
F5B421EAF56B77B775E58E92 /* Foundation.framework */,
);
name = iOS;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = { 9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */, 9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
@@ -204,6 +257,9 @@
BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */, BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */,
BB0001050000000011111111 /* TimetableWidget-Release.xcconfig */, BB0001050000000011111111 /* TimetableWidget-Release.xcconfig */,
BB0001060000000011111111 /* TimetableWidget-Profile.xcconfig */, BB0001060000000011111111 /* TimetableWidget-Profile.xcconfig */,
36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */,
17E2EE012C4361AC05DCA4C9 /* NotificationServiceExtension-Release.xcconfig */,
F758339399E7B5FD1A88FC92 /* NotificationServiceExtension-Profile.xcconfig */,
); );
name = Flutter; name = Flutter;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -218,6 +274,7 @@
97C146EF1CF9000F007C117D /* Products */, 97C146EF1CF9000F007C117D /* Products */,
345F4BD4143471FDA71626DE /* Pods */, 345F4BD4143471FDA71626DE /* Pods */,
731388A08E3B330B216381D0 /* Frameworks */, 731388A08E3B330B216381D0 /* Frameworks */,
553E8F3190182FD2E527FEB5 /* NotificationServiceExtension */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -227,6 +284,7 @@
97C146EE1CF9000F007C117D /* Runner.app */, 97C146EE1CF9000F007C117D /* Runner.app */,
3321F8052FB1C00C0011C712 /* Share Extension.appex */, 3321F8052FB1C00C0011C712 /* Share Extension.appex */,
AA0101020000000011111111 /* TimetableWidgetExtension.appex */, AA0101020000000011111111 /* TimetableWidgetExtension.appex */,
C3B91710361EFED8934F0FDC /* NotificationServiceExtension.appex */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -286,15 +344,18 @@
3321F8102FB1C00C0011C712 /* Embed Foundation Extensions */, 3321F8102FB1C00C0011C712 /* Embed Foundation Extensions */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
174B54D80220E5F588BD9737 /* [CP] Embed Pods Frameworks */, 174B54D80220E5F588BD9737 /* [CP] Embed Pods Frameworks */,
859FAB4E05FAC31B7B1A62D7 /* [CP] Copy Pods Resources */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
3321F80E2FB1C00C0011C712 /* PBXTargetDependency */, 3321F80E2FB1C00C0011C712 /* PBXTargetDependency */,
AA0101090000000011111111 /* PBXTargetDependency */, AA0101090000000011111111 /* PBXTargetDependency */,
43763BD5552A36CA28890DFA /* PBXTargetDependency */,
); );
name = Runner; name = Runner;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
productName = Runner; productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
@@ -319,6 +380,23 @@
productReference = AA0101020000000011111111 /* TimetableWidgetExtension.appex */; productReference = AA0101020000000011111111 /* TimetableWidgetExtension.appex */;
productType = "com.apple.product-type.app-extension"; productType = "com.apple.product-type.app-extension";
}; };
AEDC710FEBFF2CC736D88AB2 /* NotificationServiceExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = C3A6F3FA3E3FD220B736D6E5 /* Build configuration list for PBXNativeTarget "NotificationServiceExtension" */;
buildPhases = (
DF8C1170E7DF96711030EAC0 /* Sources */,
D2447D92E9CCD96B3292B17B /* Frameworks */,
239068341DC6E6B36193EC96 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = NotificationServiceExtension;
productName = NotificationServiceExtension;
productReference = C3B91710361EFED8934F0FDC /* NotificationServiceExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */ /* End PBXNativeTarget section */
/* Begin PBXProject section */ /* Begin PBXProject section */
@@ -351,6 +429,9 @@
Base, Base,
); );
mainGroup = 97C146E51CF9000F007C117D; mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */; productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = ""; projectDirPath = "";
projectRoot = ""; projectRoot = "";
@@ -358,11 +439,19 @@
97C146ED1CF9000F007C117D /* Runner */, 97C146ED1CF9000F007C117D /* Runner */,
3321F8042FB1C00C0011C712 /* Share Extension */, 3321F8042FB1C00C0011C712 /* Share Extension */,
AA0101010000000011111111 /* TimetableWidgetExtension */, AA0101010000000011111111 /* TimetableWidgetExtension */,
AEDC710FEBFF2CC736D88AB2 /* NotificationServiceExtension */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */ /* Begin PBXResourcesBuildPhase section */
239068341DC6E6B36193EC96 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
3321F8032FB1C00C0011C712 /* Resources */ = { 3321F8032FB1C00C0011C712 /* Resources */ = {
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -425,23 +514,6 @@
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
}; };
859FAB4E05FAC31B7B1A62D7 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
9740EEB61CF901F6004384FC /* Run Script */ = { 9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1; alwaysOutOfDate = 1;
@@ -528,6 +600,15 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
DF8C1170E7DF96711030EAC0 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
725388B5C3A724B19BD6FD06 /* NotificationService.swift in Sources */,
97D9AFE39CF2369A97F04721 /* PEM.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */ /* Begin PBXTargetDependency section */
@@ -536,6 +617,12 @@
target = 3321F8042FB1C00C0011C712 /* Share Extension */; target = 3321F8042FB1C00C0011C712 /* Share Extension */;
targetProxy = 3321F80D2FB1C00C0011C712 /* PBXContainerItemProxy */; targetProxy = 3321F80D2FB1C00C0011C712 /* PBXContainerItemProxy */;
}; };
43763BD5552A36CA28890DFA /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
name = NotificationServiceExtension;
target = AEDC710FEBFF2CC736D88AB2 /* NotificationServiceExtension */;
targetProxy = AABEF26FF24F76E01DA5ADEA /* PBXContainerItemProxy */;
};
AA0101090000000011111111 /* PBXTargetDependency */ = { AA0101090000000011111111 /* PBXTargetDependency */ = {
isa = PBXTargetDependency; isa = PBXTargetDependency;
target = AA0101010000000011111111 /* TimetableWidgetExtension */; target = AA0101010000000011111111 /* TimetableWidgetExtension */;
@@ -937,6 +1024,29 @@
}; };
name = Release; name = Release;
}; };
A2B046F78AD55232F08583F3 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */;
buildSettings = {
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = MY55VF3KPG;
INFOPLIST_FILE = NotificationServiceExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = eu.mhsl.marianum.mobile.client.NotificationServiceExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
AA01010A0000000011111111 /* Debug */ = { AA01010A0000000011111111 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */; baseConfigurationReference = BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */;
@@ -1059,6 +1169,54 @@
}; };
name = Profile; name = Profile;
}; };
E5EE58583E83B7694F6C3C5E /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 17E2EE012C4361AC05DCA4C9 /* NotificationServiceExtension-Release.xcconfig */;
buildSettings = {
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = MY55VF3KPG;
INFOPLIST_FILE = NotificationServiceExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = eu.mhsl.marianum.mobile.client.NotificationServiceExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
F418DF43742DC2E30B161652 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = F758339399E7B5FD1A88FC92 /* NotificationServiceExtension-Profile.xcconfig */;
buildSettings = {
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = MY55VF3KPG;
INFOPLIST_FILE = NotificationServiceExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = eu.mhsl.marianum.mobile.client.NotificationServiceExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
@@ -1102,7 +1260,31 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
C3A6F3FA3E3FD220B736D6E5 /* Build configuration list for PBXNativeTarget "NotificationServiceExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
E5EE58583E83B7694F6C3C5E /* Release */,
A2B046F78AD55232F08583F3 /* Debug */,
F418DF43742DC2E30B161652 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
}; };
rootObject = 97C146E61CF9000F007C117D /* Project object */; rootObject = 97C146E61CF9000F007C117D /* Project object */;
} }
@@ -0,0 +1,176 @@
{
"pins" : [
{
"identity" : "abseil-cpp-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/abseil-cpp-binary.git",
"state" : {
"revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5",
"version" : "1.2024072200.0"
}
},
{
"identity" : "app-check",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/app-check.git",
"state" : {
"revision" : "97f7d74dd0e8f3d0fe5fc75cc8957aa73bdc948a",
"version" : "11.3.2"
}
},
{
"identity" : "dkcamera",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKCamera",
"state" : {
"branch" : "master",
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
}
},
{
"identity" : "dkimagepickercontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKImagePickerController",
"state" : {
"branch" : "4.3.9",
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
}
},
{
"identity" : "dkphotogallery",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
"state" : {
"branch" : "master",
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
}
},
{
"identity" : "firebase-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : {
"revision" : "27eaab3918e0bf78711cf1abf240577176326432",
"version" : "12.19.0"
}
},
{
"identity" : "google-ads-on-device-conversion-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
"state" : {
"revision" : "0208e2681ec81f8a9c81084696f60fcce26cb7ee",
"version" : "3.7.0"
}
},
{
"identity" : "googleappmeasurement",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : {
"revision" : "b8455dd2ff55cc269fda7a4f9eac6c1e3588c53f",
"version" : "12.19.2"
}
},
{
"identity" : "googledatatransport",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleDataTransport.git",
"state" : {
"revision" : "ba3358d3c3dbae8ef230b58a46b97ad65e84e974",
"version" : "10.1.1"
}
},
{
"identity" : "googleutilities",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git",
"state" : {
"revision" : "92c8f6dc3ac375d6febdfcb3db68bc3d10633db3",
"version" : "8.1.3"
}
},
{
"identity" : "grpc-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/grpc-binary.git",
"state" : {
"revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6",
"version" : "1.69.1"
}
},
{
"identity" : "gtm-session-fetcher",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/gtm-session-fetcher.git",
"state" : {
"revision" : "724a52eea6329b7e12d3ad8300d76ca9f3895fcc",
"version" : "5.3.1"
}
},
{
"identity" : "interop-ios-for-google-sdks",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
"state" : {
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
"version" : "101.0.0"
}
},
{
"identity" : "leveldb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/leveldb.git",
"state" : {
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
"version" : "1.22.5"
}
},
{
"identity" : "nanopb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/nanopb.git",
"state" : {
"revision" : "3851d94a41890dea16dc3db34caf60e585cb4163",
"version" : "2.30910.1"
}
},
{
"identity" : "promises",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/promises.git",
"state" : {
"revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837",
"version" : "2.4.1"
}
},
{
"identity" : "sdwebimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/SDWebImage",
"state" : {
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
"version" : "5.21.7"
}
},
{
"identity" : "swiftygif",
"kind" : "remoteSourceControl",
"location" : "https://github.com/kirualex/SwiftyGif.git",
"state" : {
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
"version" : "5.4.5"
}
},
{
"identity" : "tocropviewcontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/TimOliver/TOCropViewController",
"state" : {
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
"version" : "2.8.0"
}
}
],
"version" : 2
}
@@ -5,6 +5,24 @@
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"> buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries> <BuildActionEntries>
<BuildActionEntry <BuildActionEntry
buildForTesting = "YES" buildForTesting = "YES"
@@ -5,8 +5,44 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/abseil-cpp-binary.git", "location" : "https://github.com/google/abseil-cpp-binary.git",
"state" : { "state" : {
"revision" : "bfc0b6f81adc06ce5121eb23f628473638d67c5c", "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5",
"version" : "1.2022062300.0" "version" : "1.2024072200.0"
}
},
{
"identity" : "app-check",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/app-check.git",
"state" : {
"revision" : "bb4002485ff867768dec13bf904a2ddb050bd1b1",
"version" : "11.3.0"
}
},
{
"identity" : "dkcamera",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKCamera",
"state" : {
"branch" : "master",
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
}
},
{
"identity" : "dkimagepickercontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKImagePickerController",
"state" : {
"branch" : "4.3.9",
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
}
},
{
"identity" : "dkphotogallery",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
"state" : {
"branch" : "master",
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
} }
}, },
{ {
@@ -14,8 +50,17 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk", "location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : { "state" : {
"revision" : "df2171b0c6afb9e9d4f7e07669d558c510b9f6be", "revision" : "27eaab3918e0bf78711cf1abf240577176326432",
"version" : "10.13.0" "version" : "12.19.0"
}
},
{
"identity" : "google-ads-on-device-conversion-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
"state" : {
"revision" : "0208e2681ec81f8a9c81084696f60fcce26cb7ee",
"version" : "3.7.0"
} }
}, },
{ {
@@ -23,8 +68,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git", "location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : { "state" : {
"revision" : "03b9beee1a61f62d32c521e172e192a1663a5e8b", "revision" : "b8455dd2ff55cc269fda7a4f9eac6c1e3588c53f",
"version" : "10.13.0" "version" : "12.19.2"
} }
}, },
{ {
@@ -32,8 +77,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleDataTransport.git", "location" : "https://github.com/google/GoogleDataTransport.git",
"state" : { "state" : {
"revision" : "aae45a320fd0d11811820335b1eabc8753902a40", "revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
"version" : "9.2.5" "version" : "10.1.0"
} }
}, },
{ {
@@ -41,8 +86,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git", "location" : "https://github.com/google/GoogleUtilities.git",
"state" : { "state" : {
"revision" : "c38ce365d77b04a9a300c31061c5227589e5597b", "revision" : "92c8f6dc3ac375d6febdfcb3db68bc3d10633db3",
"version" : "7.11.5" "version" : "8.1.3"
} }
}, },
{ {
@@ -50,8 +95,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/grpc-binary.git", "location" : "https://github.com/google/grpc-binary.git",
"state" : { "state" : {
"revision" : "f1b366129d1125be7db83247e003fc333104b569", "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6",
"version" : "1.50.2" "version" : "1.69.1"
} }
}, },
{ {
@@ -59,8 +104,17 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/gtm-session-fetcher.git", "location" : "https://github.com/google/gtm-session-fetcher.git",
"state" : { "state" : {
"revision" : "d415594121c9e8a4f9d79cecee0965cf35e74dbd", "revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a",
"version" : "3.1.1" "version" : "5.3.0"
}
},
{
"identity" : "interop-ios-for-google-sdks",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
"state" : {
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
"version" : "101.0.0"
} }
}, },
{ {
@@ -68,8 +122,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/leveldb.git", "location" : "https://github.com/firebase/leveldb.git",
"state" : { "state" : {
"revision" : "0706abcc6b0bd9cedfbb015ba840e4a780b5159b", "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
"version" : "1.22.2" "version" : "1.22.5"
} }
}, },
{ {
@@ -77,8 +131,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/nanopb.git", "location" : "https://github.com/firebase/nanopb.git",
"state" : { "state" : {
"revision" : "819d0a2173aff699fb8c364b6fb906f7cdb1a692", "revision" : "3851d94a41890dea16dc3db34caf60e585cb4163",
"version" : "2.30909.0" "version" : "2.30910.1"
} }
}, },
{ {
@@ -86,17 +140,35 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/promises.git", "location" : "https://github.com/google/promises.git",
"state" : { "state" : {
"revision" : "e70e889c0196c76d22759eb50d6a0270ca9f1d9e", "revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837",
"version" : "2.3.1" "version" : "2.4.1"
} }
}, },
{ {
"identity" : "swift-protobuf", "identity" : "sdwebimage",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-protobuf.git", "location" : "https://github.com/SDWebImage/SDWebImage",
"state" : { "state" : {
"revision" : "ce20dc083ee485524b802669890291c0d8090170", "revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
"version" : "1.22.1" "version" : "5.21.7"
}
},
{
"identity" : "swiftygif",
"kind" : "remoteSourceControl",
"location" : "https://github.com/kirualex/SwiftyGif.git",
"state" : {
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
"version" : "5.4.5"
}
},
{
"identity" : "tocropviewcontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/TimOliver/TOCropViewController",
"state" : {
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
"version" : "2.8.0"
} }
} }
], ],
+202 -1
View File
@@ -1,16 +1,217 @@
import Flutter import Flutter
import UIKit import UIKit
import UserNotifications
import workmanager_apple
@main @main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
// Kept in sync with lib/push/push_actions.dart (kTalkReplyActionId /
// kTalkMarkReadActionId) and lib/push/push_renderer.dart (iosTalkCategory).
private let talkCategoryId = "TALK_MESSAGE"
private let replyActionId = "TALK_REPLY"
private let markReadActionId = "TALK_MARK_READ"
// Shared (App Group) keychain — same group as the NSE and the Dart side.
private let keychainAccessGroup = "MY55VF3KPG.eu.mhsl.marianum.mobile.client.push"
private let usernameAccount = "nextcloud_username"
private let appPasswordAccount = "nextcloud_app_password"
private let baseUrlAccount = "nextcloud_base_url"
override func application( override func application(
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions) let result = super.application(application, didFinishLaunchingWithOptions: launchOptions)
registerTalkCategory()
// BGAppRefresh for the home-screen widget. Must all happen before
// didFinishLaunching returns: with the UIScene lifecycle Flutter registers
// plugins only during scene connection, which is past BGTaskScheduler's
// registration deadline — registerLaunchHandlers() bridges that gap.
// The task identifier mirrors WidgetBackgroundTask.periodicTaskName; the
// plugin re-submits the refresh request itself after every run.
WorkmanagerPlugin.setPluginRegistrantCallback { registry in
GeneratedPluginRegistrant.register(with: registry)
}
WorkmanagerPlugin.registerPeriodicTask(
withIdentifier: "eu.mhsl.marianum.widget.refresh",
earliestBeginInSeconds: 1800)
WorkmanagerPlugin.registerLaunchHandlers()
// FlutterAppDelegate conforms to UNUserNotificationCenterDelegate and
// forwards these callbacks to the plugins (firebase_messaging,
// flutter_local_notifications). We route Talk actions natively here — the
// Flutter engine is not guaranteed to run for a background action — and
// forward everything else to `super` so plugin behaviour is preserved.
UNUserNotificationCenter.current().delegate = self
return result
} }
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
} }
private func registerTalkCategory() {
let reply = UNTextInputNotificationAction(
identifier: replyActionId,
title: "Antworten",
options: [],
textInputButtonTitle: "Senden",
textInputPlaceholder: "Nachricht"
)
let markRead = UNNotificationAction(
identifier: markReadActionId,
title: "Als gelesen markieren",
options: []
)
let category = UNNotificationCategory(
identifier: talkCategoryId,
actions: [reply, markRead],
intentIdentifiers: [],
options: []
)
UNUserNotificationCenter.current().setNotificationCategories([category])
}
// MARK: - UNUserNotificationCenterDelegate
override func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let actionId = response.actionIdentifier
if actionId == replyActionId || actionId == markReadActionId {
// Handled natively; deliberately NOT forwarded to super so the plugins
// don't also process it (which would double-send the reply).
handleTalkAction(response, completion: completionHandler)
return
}
super.userNotificationCenter(
center, didReceive: response, withCompletionHandler: completionHandler)
}
// MARK: - Native Talk action handling
private func handleTalkAction(
_ response: UNNotificationResponse,
completion: @escaping () -> Void
) {
guard
let token = chatToken(from: response),
let credentials = loadCredentials()
else {
completion()
return
}
let group = DispatchGroup()
if response.actionIdentifier == replyActionId,
let text = (response as? UNTextInputNotificationResponse)?.userText
.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty {
group.enter()
ocsPost(
credentials: credentials,
path: "apps/spreed/api/v1/chat/\(token)",
form: ["message": text]
) { group.leave() }
}
// Both reply and mark-read clear the unread marker on the chat.
group.enter()
ocsPost(
credentials: credentials,
path: "apps/spreed/api/v1/chat/\(token)/read",
form: nil
) { group.leave() }
group.notify(queue: .main) { completion() }
}
private func chatToken(from response: UNNotificationResponse) -> String? {
let userInfo = response.notification.request.content.userInfo
if let token = userInfo["chatToken"] as? String, !token.isEmpty {
return token
}
let thread = response.notification.request.content.threadIdentifier
if thread.hasPrefix("talk_") {
let token = String(thread.dropFirst("talk_".count))
return token.isEmpty ? nil : token
}
return nil
}
private struct Credentials {
let baseUrl: String
let authorization: String
}
private func loadCredentials() -> Credentials? {
guard
let username = keychainString(usernameAccount),
let appPassword = keychainString(appPasswordAccount),
let baseUrl = keychainString(baseUrlAccount),
let token = "\(username):\(appPassword)".data(using: .utf8)
else { return nil }
let authorization = "Basic \(token.base64EncodedString())"
let trimmed = baseUrl.hasSuffix("/") ? String(baseUrl.dropLast()) : baseUrl
return Credentials(baseUrl: trimmed, authorization: authorization)
}
private func ocsPost(
credentials: Credentials,
path: String,
form: [String: String]?,
completion: @escaping () -> Void
) {
guard let url = URL(string: "\(credentials.baseUrl)/ocs/v2.php/\(path)") else {
completion()
return
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue(credentials.authorization, forHTTPHeaderField: "Authorization")
request.setValue("true", forHTTPHeaderField: "OCS-APIRequest")
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let form = form {
request.setValue(
"application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.httpBody = form
.map { "\($0.key)=\(formEncode($0.value))" }
.joined(separator: "&")
.data(using: .utf8)
}
URLSession.shared.dataTask(with: request) { _, response, error in
if let error = error {
NSLog("[Talk action] \(path) failed: \(error.localizedDescription)")
} else if let http = response as? HTTPURLResponse,
!(200..<300).contains(http.statusCode) {
NSLog("[Talk action] \(path) -> HTTP \(http.statusCode)")
}
completion()
}.resume()
}
private func formEncode(_ value: String) -> String {
var allowed = CharacterSet.alphanumerics
allowed.insert(charactersIn: "-._~")
return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
}
// MARK: - Keychain (shared App Group)
private func keychainString(_ account: String) -> String? {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: account,
kSecAttrAccessGroup: keychainAccessGroup,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne,
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
} }
+86 -82
View File
@@ -1,90 +1,94 @@
<?xml version="1.0" encoding="UTF-8"?> <?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"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>AppGroupId</key> <key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string> <string>$(CUSTOM_GROUP_ID)</string>
<key>CADisableMinimumFrameDurationOnPhone</key> <key>BGTaskSchedulerPermittedIdentifiers</key>
<true/> <array>
<key>CFBundleDevelopmentRegion</key> <string>eu.mhsl.marianum.widget.refresh</string>
<string>$(DEVELOPMENT_LANGUAGE)</string> </array>
<key>CFBundleDisplayName</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<string>Marianum Fulda</string> <true/>
<key>CFBundleExecutable</key> <key>CFBundleDevelopmentRegion</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleIdentifier</key> <key>CFBundleDisplayName</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> <string>Marianum Fulda</string>
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleExecutable</key>
<string>6.0</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleName</key> <key>CFBundleIdentifier</key>
<string>client</string> <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundlePackageType</key> <key>CFBundleInfoDictionaryVersion</key>
<string>APPL</string> <string>6.0</string>
<key>CFBundleShortVersionString</key> <key>CFBundleName</key>
<string>$(FLUTTER_BUILD_NAME)</string> <string>client</string>
<key>CFBundleSignature</key> <key>CFBundlePackageType</key>
<string>????</string> <string>APPL</string>
<key>CFBundleURLTypes</key> <key>CFBundleShortVersionString</key>
<array> <string>$(FLUTTER_BUILD_NAME)</string>
<dict> <key>CFBundleSignature</key>
<key>CFBundleTypeRole</key> <string>????</string>
<string>Editor</string> <key>CFBundleURLTypes</key>
<key>CFBundleURLSchemes</key> <array>
<array>
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Um Fotos direkt aus der App aufnehmen und teilen zu können wird Zugriff auf die Kamera benötigt.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Um Medien mit anderen zu teilen wird Zugriff zu deine Dateien benötigt.</string>
<key>UIApplicationSceneManifest</key>
<dict> <dict>
<key>UIApplicationSupportsMultipleScenes</key> <key>CFBundleTypeRole</key>
<false/> <string>Editor</string>
<key>UISceneConfigurations</key> <key>CFBundleURLSchemes</key>
<dict> <array>
<key>UIWindowSceneSessionRoleApplication</key> <string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<array> </array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict> </dict>
<key>UIApplicationSupportsIndirectInputEvents</key> </array>
<true/> <key>CFBundleVersion</key>
<key>UIBackgroundModes</key> <string>$(FLUTTER_BUILD_NUMBER)</string>
<array> <key>LSRequiresIPhoneOS</key>
<string>fetch</string> <true/>
<string>remote-notification</string> <key>NSCameraUsageDescription</key>
</array> <string>Um Fotos direkt aus der App aufnehmen und teilen zu können wird Zugriff auf die Kamera benötigt.</string>
<key>UILaunchStoryboardName</key> <key>NSPhotoLibraryUsageDescription</key>
<string>LaunchScreen</string> <string>Um Medien mit anderen zu teilen wird Zugriff zu deine Dateien benötigt.</string>
<key>UIMainStoryboardFile</key> <key>UIApplicationSceneManifest</key>
<string>Main</string> <dict>
<key>UIStatusBarHidden</key> <key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/> <false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict> </dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>remote-notification</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIStatusBarHidden</key>
<false/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist> </plist>
+4
View File
@@ -9,5 +9,9 @@
<string>group.eu.mhsl.marianum.mobile.client.widget</string> <string>group.eu.mhsl.marianum.mobile.client.widget</string>
<string>group.eu.mhsl.marianum.mobile.client.share</string> <string>group.eu.mhsl.marianum.mobile.client.share</string>
</array> </array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push</string>
</array>
</dict> </dict>
</plist> </plist>
+27 -4
View File
@@ -13,21 +13,44 @@ class SceneDelegate: FlutterSceneDelegate {
options connectionOptions: UIScene.ConnectionOptions options connectionOptions: UIScene.ConnectionOptions
) { ) {
super.scene(scene, willConnectTo: session, options: connectionOptions) super.scene(scene, willConnectTo: session, options: connectionOptions)
for context in connectionOptions.urlContexts { var handledShare = false
_ = SwiftReceiveSharingIntentPlugin.instance.application( for context in connectionOptions.urlContexts where isShareUrl(context.url) {
_ = ReceiveSharingIntentPlugin.instance.application(
UIApplication.shared, UIApplication.shared,
didFinishLaunchingWithOptions: [UIApplication.LaunchOptionsKey.url: context.url] didFinishLaunchingWithOptions: [UIApplication.LaunchOptionsKey.url: context.url]
) )
handledShare = true
} }
if handledShare { clearSharedPayload() }
} }
override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) { override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
for context in URLContexts { let shares = URLContexts.filter { isShareUrl($0.url) }
_ = SwiftReceiveSharingIntentPlugin.instance.application( for context in shares {
_ = ReceiveSharingIntentPlugin.instance.application(
UIApplication.shared, UIApplication.shared,
open: context.url, open: context.url,
options: [:] options: [:]
) )
} }
if !shares.isEmpty { clearSharedPayload() }
let others = URLContexts.subtracting(shares)
if !others.isEmpty { super.scene(scene, openURLContexts: others) }
}
private func isShareUrl(_ url: URL) -> Bool {
ReceiveSharingIntentPlugin.instance.hasMatchingSchemePrefix(url: url)
}
// The plugin keeps the payload in memory once read but never removes it
// from the App Group defaults. The Share Extension opens the app twice per
// share and a scene reconnect replays the URL, so every later delivery
// would publish the finished share again.
private func clearSharedPayload() {
let groupId = Bundle.main.object(forInfoDictionaryKey: "AppGroupId") as? String
let defaults = UserDefaults(
suiteName: groupId ?? "group.\(Bundle.main.bundleIdentifier ?? "")")
defaults?.removeObject(forKey: "ShareKey")
defaults?.removeObject(forKey: "ShareMessageKey")
} }
} }
+47 -11
View File
@@ -3,7 +3,7 @@ import UniformTypeIdentifiers
import AVFoundation import AVFoundation
// Datenmodell muss byte-für-byte zu dem passen, was // Datenmodell muss byte-für-byte zu dem passen, was
// SwiftReceiveSharingIntentPlugin auf der Host-App-Seite decodiert. // ReceiveSharingIntentPlugin auf der Host-App-Seite decodiert.
private enum SharedMediaType: String, Codable { private enum SharedMediaType: String, Codable {
case image, video, text, file, url case image, video, text, file, url
} }
@@ -152,17 +152,54 @@ final class ShareViewController: UIViewController {
} }
} }
private func copyIntoAppGroup(src: URL) -> URL? { // loadItem-Callbacks laufen parallel; Ordneranlage und Namenswahl seriell.
private let fileQueue = DispatchQueue(label: "share.files")
// Ein Ordner pro Share: Gleichnamige Dateien (z. B. bearbeitete Fotos, alle
// "FullSizeRender.jpg") überschrieben sich sonst gegenseitig, auch über
// zwei Shares hinweg. Die App löscht den Ordner, sobald der Share erledigt ist.
private lazy var shareDir: URL? = {
guard let container = FileManager.default guard let container = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: appGroupId) else { .containerURL(forSecurityApplicationGroupIdentifier: appGroupId) else {
return nil return nil
} }
let name = src.lastPathComponent.isEmpty ? UUID().uuidString : src.lastPathComponent let root = container.appendingPathComponent("share_intent", isDirectory: true)
let dst = container.appendingPathComponent(name) sweepStaleShares(in: root)
let dir = root.appendingPathComponent(UUID().uuidString, isDirectory: true)
do {
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
} catch {
return nil
}
}()
// Shares, deren App-Flow nie abgeschlossen wurde (Prozess beendet o. ä.),
// räumt sonst niemand weg — der App-Group-Container wird nie geleert.
private func sweepStaleShares(in root: URL) {
let fm = FileManager.default
guard let entries = try? fm.contentsOfDirectory(
at: root, includingPropertiesForKeys: [.creationDateKey]) else { return }
let cutoff = Date().addingTimeInterval(-24 * 60 * 60)
for entry in entries {
let created = (try? entry.resourceValues(forKeys: [.creationDateKey]))?.creationDate
if let created, created < cutoff { try? fm.removeItem(at: entry) }
}
}
private func copyIntoAppGroup(src: URL) -> URL? {
fileQueue.sync { copyIntoShareDir(src: src) }
}
private func copyIntoShareDir(src: URL) -> URL? {
guard let dir = shareDir else { return nil }
let name = src.lastPathComponent.isEmpty ? UUID().uuidString : src.lastPathComponent
var dst = dir.appendingPathComponent(name)
// Mehrfachauswahl mit gleichem Namen innerhalb eines Shares.
if FileManager.default.fileExists(atPath: dst.path) {
dst = dir.appendingPathComponent("\(UUID().uuidString.prefix(8))-\(name)")
}
do { do {
if FileManager.default.fileExists(atPath: dst.path) {
try FileManager.default.removeItem(at: dst)
}
try FileManager.default.copyItem(at: src, to: dst) try FileManager.default.copyItem(at: src, to: dst)
return dst return dst
} catch { } catch {
@@ -171,10 +208,9 @@ final class ShareViewController: UIViewController {
} }
private func writePng(image: UIImage) -> URL? { private func writePng(image: UIImage) -> URL? {
guard let container = FileManager.default guard let data = image.pngData(),
.containerURL(forSecurityApplicationGroupIdentifier: appGroupId), let dir = fileQueue.sync(execute: { shareDir }) else { return nil }
let data = image.pngData() else { return nil } let dst = dir.appendingPathComponent("\(UUID().uuidString).png")
let dst = container.appendingPathComponent("\(UUID().uuidString).png")
return (try? data.write(to: dst)) != nil ? dst : nil return (try? data.write(to: dst)) != nil ? dst : nil
} }
@@ -0,0 +1,109 @@
import Foundation
/// Pure anchor/slicing logic mirroring `lib/widget_data/widget_data_mapper.dart`
/// (resolveDayAnchor / resolveWeekAnchor). Keep both sides in sync — the Dart
/// unit tests in test/widget_data/widget_data_mapper_test.dart double as the
/// review checklist here:
/// resolveDayAnchor(Wed 2026-05-06 10:00) == 2026-05-06 (before cutoff)
/// resolveDayAnchor(Wed 2026-05-06 19:00) == 2026-05-07 (after cutoff)
/// resolveDayAnchor(Fri 2026-05-08 18:00) == 2026-05-11 (Fri → Mon)
/// resolveDayAnchor(Sat 2026-05-09 10:00) == 2026-05-11
/// resolveDayAnchor(Sun 2026-05-10 22:00) == 2026-05-11
/// resolveWeekAnchor(Tue 2026-05-05 10:00) == 2026-05-04
/// resolveWeekAnchor(Sun 2026-05-10 10:00) == 2026-05-11
enum TimetableAnchor {
/// After 17:00 the user's question shifts from "what's left today" to
/// "what's tomorrow", so the day widget rolls forward.
static let dayCutoffHour = 17
static func resolveDayAnchor(_ now: Date, calendar: Calendar = .current) -> Date {
var candidate = calendar.startOfDay(for: now)
let shiftToTomorrow =
calendar.component(.hour, from: now) >= dayCutoffHour || isWeekend(candidate, calendar: calendar)
if shiftToTomorrow {
candidate = nextDay(candidate, calendar: calendar)
}
while isWeekend(candidate, calendar: calendar) {
candidate = nextDay(candidate, calendar: calendar)
}
return candidate
}
static func resolveWeekAnchor(_ now: Date, calendar: Calendar = .current) -> Date {
let anchor = resolveDayAnchor(now, calendar: calendar)
// Swift weekday: 1 = Sunday … 7 = Saturday → distance back to Monday.
let daysFromMonday = (calendar.component(.weekday, from: anchor) + 5) % 7
let monday = calendar.date(byAdding: .day, value: -daysFromMonday, to: anchor) ?? anchor
return calendar.startOfDay(for: monday)
}
/// Future instants at which the rendered anchor can change: each day's
/// midnight (rollover) and 17:00 (cutoff), within the horizon. Sorted,
/// strictly after `now`.
static func boundaryDates(
from now: Date,
horizonDays: Int = 3,
calendar: Calendar = .current
) -> [Date] {
var result: [Date] = []
let today = calendar.startOfDay(for: now)
for offset in 0...horizonDays {
guard let day = calendar.date(byAdding: .day, value: offset, to: today) else { continue }
let midnight = calendar.startOfDay(for: day)
if midnight > now { result.append(midnight) }
if let cutoff = calendar.date(
bySettingHour: dayCutoffHour, minute: 0, second: 0, of: day
), cutoff > now {
result.append(cutoff)
}
}
return result.sorted()
}
/// Derives a day payload from the 14-day week payload — same shape the
/// Dart buildDayData produces, since the week payload runs through the
/// identical per-day merge/collision pipeline.
static func slice(
week: WidgetTimetableData,
forDay anchor: Date,
calendar: Calendar = .current
) -> WidgetTimetableData {
let lessons = week.lessons.filter { calendar.isDate($0.start, inSameDayAs: anchor) }
// Every v2 week payload carries `days` — the key bump guarantees it.
let dayInfo = week.days?.first { calendar.isDate($0.date, inSameDayAs: anchor) }
return WidgetTimetableData(
fetchedAt: week.fetchedAt,
anchorDate: anchor,
lessons: lessons,
periods: week.periods,
isHoliday: dayInfo?.isHoliday ?? false,
holidayName: dayInfo?.holidayName,
days: nil
)
}
/// Week payload re-anchored to the week containing/following `anchorDate`.
/// The week view filters its five columns off `anchorDate`, so this alone
/// performs the Friday-evening/weekend jump to next week.
static func retarget(week: WidgetTimetableData, anchorDate: Date) -> WidgetTimetableData {
WidgetTimetableData(
fetchedAt: week.fetchedAt,
anchorDate: anchorDate,
lessons: week.lessons,
periods: week.periods,
isHoliday: week.isHoliday,
holidayName: week.holidayName,
days: week.days
)
}
private static func isWeekend(_ date: Date, calendar: Calendar) -> Bool {
let weekday = calendar.component(.weekday, from: date)
return weekday == 1 || weekday == 7
}
private static func nextDay(_ date: Date, calendar: Calendar) -> Date {
let next = calendar.date(byAdding: .day, value: 1, to: date) ?? date
return calendar.startOfDay(for: next)
}
}
@@ -1,59 +1,6 @@
import SwiftUI import SwiftUI
import WidgetKit import WidgetKit
// Layout constants — must mirror WidgetRenderer.kt on Android, otherwise
// the platforms drift apart on the same widget size.
let FALLBACK_VIRTUAL_MINUTES = 11 * 60
let MIN_HOUR_HEIGHT: CGFloat = 18
let MAX_HOUR_HEIGHT: CGFloat = 72
let MIN_BLOCK_HEIGHT: CGFloat = 16
let LESSON_GAP: CGFloat = 1.5
func realMinutesToVirtual(_ realMin: Int, periods: [WidgetPeriod]) -> CGFloat {
guard !periods.isEmpty else { return CGFloat(realMin) }
for p in periods where realMin >= p.startMinutes && realMin <= p.endMinutes {
return CGFloat(p.virtualStartMinutes + (realMin - p.startMinutes))
}
let first = periods.first!
if realMin < first.startMinutes {
return CGFloat(realMin - first.startMinutes + first.virtualStartMinutes)
}
let last = periods.last!
if realMin > last.endMinutes {
return CGFloat(last.virtualEndMinutes + (realMin - last.endMinutes))
}
var prev = first
for i in 1..<periods.count {
let curr = periods[i]
if realMin > prev.endMinutes && realMin < curr.startMinutes {
let gap = curr.startMinutes - prev.endMinutes
let virtualGap = curr.virtualStartMinutes - prev.virtualEndMinutes
if gap > 0 {
return CGFloat(prev.virtualEndMinutes) +
CGFloat(realMin - prev.endMinutes) * CGFloat(virtualGap) / CGFloat(gap)
}
return CGFloat(curr.virtualStartMinutes)
}
prev = curr
}
return 0
}
// Below these tile heights the secondary stack collapses progressively:
// shorter than ROOM_MIN drops everything, between ROOM_MIN and
// TEACHER_SEPARATE_MIN keeps the room but drops the teacher.
let BLOCK_SHOW_ROOM_MIN: CGFloat = 13
let BLOCK_SHOW_TEACHER_SEPARATE_MIN: CGFloat = 20
let MIN_SUBJECT_FONT: CGFloat = 9
let MAX_SUBJECT_FONT: CGFloat = 14
let MIN_SECONDARY_FONT: CGFloat = 7
func subjectFont(forHourHeight hourHeight: CGFloat) -> CGFloat {
let t = max(0, min(1, (hourHeight - MIN_HOUR_HEIGHT) / (MAX_HOUR_HEIGHT - MIN_HOUR_HEIGHT)))
return MIN_SUBJECT_FONT + t * (MAX_SUBJECT_FONT - MIN_SUBJECT_FONT)
}
struct TimetableDayView: View { struct TimetableDayView: View {
let entry: TimetableEntry let entry: TimetableEntry
@Environment(\.widgetPalette) private var palette @Environment(\.widgetPalette) private var palette
@@ -73,7 +20,7 @@ struct TimetableDayView: View {
@ViewBuilder @ViewBuilder
private func content(data: WidgetTimetableData) -> some View { private func content(data: WidgetTimetableData) -> some View {
VStack(alignment: .leading, spacing: 3) { VStack(alignment: .leading, spacing: GRID_MARGIN_TOP) {
header(data: data) header(data: data)
if data.isHoliday { if data.isHoliday {
emptyState(text: data.holidayName ?? "Ferien") emptyState(text: data.holidayName ?? "Ferien")
@@ -81,37 +28,41 @@ struct TimetableDayView: View {
emptyState(text: "Keine Stunden") emptyState(text: "Keine Stunden")
} else { } else {
GeometryReader { geo in GeometryReader { geo in
let totalMin = CGFloat(data.periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES) let periods = trimTrailingPeriods(data.periods, lessons: data.lessons)
TimeGridView( HStack(alignment: .top, spacing: 0) {
lessons: data.lessons, PeriodRuler(
periods: data.periods, periods: periods,
hourHeight: max( hourHeight: resolveHourHeight(forGridHeight: geo.size.height, periods: periods)
MIN_HOUR_HEIGHT, )
min(MAX_HOUR_HEIGHT, geo.size.height / max(totalMin, 60) * 60) Rectangle()
), .fill(palette.divider)
showRoom: true, .frame(width: 1)
showTeacher: true, TimeGridView(
showTimeLabels: true lessons: data.lessons,
) periods: periods,
hourHeight: resolveHourHeight(forGridHeight: geo.size.height, periods: periods)
)
}
} }
} }
} }
} }
private func header(data: WidgetTimetableData) -> some View { private func header(data: WidgetTimetableData) -> some View {
HStack(spacing: 4) { HStack(spacing: 6) {
Text(dayLabel(for: data.anchorDate)) Text(dayLabel(for: data.anchorDate, relativeTo: entry.date))
.font(.system(size: 13, weight: .semibold)) .font(.system(size: 14, weight: .bold))
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.7) .minimumScaleFactor(0.65)
Spacer(minLength: 4) .frame(maxWidth: .infinity, alignment: .leading)
Text("Stand: \(freshnessLabel(for: data.fetchedAt))") Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
.font(.system(size: 9)) .font(.system(size: 10))
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.7) .fixedSize()
} }
.frame(height: TITLE_ROW_HEIGHT)
} }
private func emptyState(text: String) -> some View { private func emptyState(text: String) -> some View {
@@ -139,54 +90,23 @@ struct TimetableDayView: View {
} }
} }
struct TimeGridView: View { /// Mirrors the in-app _PeriodLabel: period number centred, start time above,
let lessons: [WidgetLesson] /// end time below, a cup in the breaks.
struct PeriodRuler: View {
let periods: [WidgetPeriod] let periods: [WidgetPeriod]
let hourHeight: CGFloat let hourHeight: CGFloat
let showRoom: Bool
let showTeacher: Bool
let showTimeLabels: Bool
/// Week-widget passes 3 for narrow columns; day-widget keeps 7.
var horizontalPadding: CGFloat = 7
@Environment(\.widgetPalette) private var palette @Environment(\.widgetPalette) private var palette
private var totalVirtualMinutes: Int {
periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES
}
private var totalHeight: CGFloat { private var totalHeight: CGFloat {
CGFloat(totalVirtualMinutes) * hourHeight / 60.0 CGFloat(totalVirtualMinutes(periods)) * hourHeight / 60.0
} }
/// Below this per-hour height the two-line label collapses to a single
/// period number — time + number overlap otherwise.
private var compactLabels: Bool { hourHeight < 26 }
var body: some View { var body: some View {
HStack(alignment: .top, spacing: 0) { // Every child has layout-y 0 (`.offset` only shifts the rendering),
if showTimeLabels { // so both the ZStack and its frame need `.top` — the default
// 28pt matches the Week widget's time-label column so the // `.center` would anchor the whole ruler at the column's midpoint.
// visual rhythm is identical across both widgets. ZStack(alignment: .top) {
timeLabelsColumn
.frame(width: 28, alignment: .topTrailing)
Rectangle()
.fill(palette.divider)
.frame(width: 1)
}
ZStack(alignment: .top) {
gridLines
breakBlocks
ForEach(lessons.indices, id: \.self) { idx in
lessonBlock(lessons[idx])
}
}
.frame(maxWidth: .infinity, minHeight: totalHeight, alignment: .top)
}
}
private var timeLabelsColumn: some View {
ZStack(alignment: .topTrailing) {
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
Rectangle() Rectangle()
.fill(palette.divider) .fill(palette.divider)
@@ -194,39 +114,91 @@ struct TimeGridView: View {
.offset(y: CGFloat(virtualMin) * hourHeight / 60.0) .offset(y: CGFloat(virtualMin) * hourHeight / 60.0)
} }
ForEach(periods, id: \.startMinutes) { period in ForEach(periods, id: \.startMinutes) { period in
VStack(alignment: .trailing, spacing: -2) { cell(period)
if compactLabels { }
Text("\(period.name).") ForEach(periodBreaks(periods), id: \.self) { pause in
.font(.system(size: 9, weight: .bold)) let height = CGFloat(pause.virtualMinutes) * hourHeight / 60.0
.foregroundStyle(palette.textPrimary) if height >= BREAK_ICON_MIN_HEIGHT {
.lineLimit(1) Image(systemName: "cup.and.saucer")
} else { .font(.system(size: 8))
Text(formatHm(period.startMinutes)) .foregroundStyle(palette.textSecondary)
.font(.system(size: 9)) .frame(width: RULER_WIDTH, height: height)
.foregroundStyle(palette.textPrimary) .offset(y: CGFloat(pause.virtualStart) * hourHeight / 60.0)
.lineLimit(1)
Text("\(period.name).")
.font(.system(size: 7, weight: .bold))
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
}
} }
.padding(.trailing, 4)
.offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0)
} }
} }
.frame(height: totalHeight, alignment: .topTrailing) .frame(width: RULER_WIDTH, height: totalHeight, alignment: .top)
}
private func cell(_ period: WidgetPeriod) -> some View {
let height = CGFloat(period.virtualEndMinutes - period.virtualStartMinutes) * hourHeight / 60.0
let timeFont = max(6, min(8.5, height * 0.19))
return ZStack {
Text(period.name)
.font(.system(size: max(8, min(13, height * 0.3)), weight: .medium))
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
if height >= RULER_TIMES_MIN_CELL {
VStack(spacing: 0) {
Text(formatHm(period.startMinutes))
Spacer(minLength: 0)
Text(formatHm(period.endMinutes))
}
.font(.system(size: timeFont))
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
.padding(.vertical, 2)
}
}
.frame(width: RULER_WIDTH, height: height)
.offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0)
}
private func formatHm(_ minutes: Int) -> String {
String(format: "%02d:%02d", minutes / 60, minutes % 60)
}
}
/// One day column: grid lines, break bands and the lesson tiles. Shared by
/// the day widget and each of the week widget's five columns.
struct TimeGridView: View {
let lessons: [WidgetLesson]
let periods: [WidgetPeriod]
let hourHeight: CGFloat
var holiday: WidgetDayInfo? = nil
var isToday: Bool = false
@Environment(\.widgetPalette) private var palette
private var totalHeight: CGFloat {
CGFloat(totalVirtualMinutes(periods)) * hourHeight / 60.0
}
var body: some View {
GeometryReader { geo in
let metrics = TileMetrics(periods: periods, hourHeight: hourHeight)
let innerWidth = geo.size.width - 2 * (TILE_MARGIN_H + TILE_PADDING_H)
ZStack(alignment: .top) {
if let holiday {
holidayColumn(holiday)
} else {
if isToday {
palette.todayTint.frame(height: totalHeight)
}
gridLines
breakBlocks
}
ForEach(lessons.indices, id: \.self) { idx in
lessonBlock(lessons[idx], metrics: metrics, innerWidth: innerWidth)
}
}
.frame(width: geo.size.width, height: totalHeight, alignment: .top)
}
.frame(height: totalHeight)
} }
private var gridLines: some View { private var gridLines: some View {
// `.frame(height: totalHeight, alignment: .top)` — without the // `.top` on both the ZStack and the frame — see PeriodRuler.
// explicit `.top`, SwiftUI's frame modifier defaults to `.center`.
// Every Rectangle here has layout-y=0 (the `.offset()`s shift the
// rendered position only, not layout), so the inner ZStack's
// natural height is just 1pt; a 1pt ZStack centred in a 300pt
// frame anchors at the middle, and every line then renders
// relative to that midpoint — the grid visually starts halfway
// down the column and the later periods clip past the bottom.
ZStack(alignment: .top) { ZStack(alignment: .top) {
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
Rectangle() Rectangle()
@@ -239,170 +211,200 @@ struct TimeGridView: View {
} }
private var breakBlocks: some View { private var breakBlocks: some View {
// Same `.center`-vs-`.top` bug as gridLines — see comment above.
ZStack(alignment: .top) { ZStack(alignment: .top) {
ForEach(0..<max(0, periods.count - 1), id: \.self) { i in ForEach(periodBreaks(periods), id: \.self) { pause in
let curr = periods[i] let height = CGFloat(pause.virtualMinutes) * hourHeight / 60.0
let next = periods[i + 1] ZStack {
let virtualGap = next.virtualStartMinutes - curr.virtualEndMinutes palette.breakBlock
if virtualGap > 0 { if height >= BREAK_ICON_MIN_HEIGHT {
Rectangle() Image(systemName: "fork.knife")
.fill(palette.breakBlock) .font(.system(size: 9))
.frame(height: CGFloat(virtualGap) * hourHeight / 60.0) .foregroundStyle(palette.accent)
.padding(.horizontal, 1) }
.offset(y: CGFloat(curr.virtualEndMinutes) * hourHeight / 60.0)
} }
.frame(height: height)
.offset(y: CGFloat(pause.virtualStart) * hourHeight / 60.0)
} }
} }
.frame(height: totalHeight, alignment: .top) .frame(height: totalHeight, alignment: .top)
} }
private func formatHm(_ minutes: Int) -> String { /// Mirrors the in-app holiday region: icon and "Schulfrei" on top, the
String(format: "%02d:%02d", minutes / 60, minutes % 60) /// holiday name rotated along the column.
private func holidayColumn(_ holiday: WidgetDayInfo) -> some View {
let nameLength = max(40, totalHeight - 2 * HOLIDAY_NAME_INSET)
return ZStack {
palette.holidayTint
VStack(spacing: 2) {
Image(systemName: "calendar")
.font(.system(size: 12))
Text("Schulfrei")
.font(.system(size: 9))
.lineLimit(1)
.minimumScaleFactor(0.6)
}
.padding(.top, 8)
.padding(.horizontal, 2)
.frame(maxHeight: .infinity, alignment: .top)
// The outer frame carries the rotated footprint: rotationEffect
// alone leaves the layout box at its unrotated size.
Text(holiday.holidayName ?? "")
.font(.system(size: 11, weight: .bold))
.kerning(1)
.lineLimit(1)
.minimumScaleFactor(0.6)
.frame(width: nameLength, height: 14)
.rotationEffect(.degrees(90))
.frame(width: 14, height: nameLength)
}
.foregroundStyle(palette.textPrimary)
.frame(height: totalHeight)
} }
@ViewBuilder @ViewBuilder
private func lessonBlock(_ lesson: WidgetLesson) -> some View { private func lessonBlock(
let cal = Calendar.current _ lesson: WidgetLesson,
let comps = cal.dateComponents([.hour, .minute], from: lesson.start) metrics: TileMetrics,
let startMinutes = (comps.hour ?? 0) * 60 + (comps.minute ?? 0) innerWidth: CGFloat
) -> some View {
let startMinutes = minutesOfDay(lesson.start)
let durationMinutes = max(15, Int(lesson.end.timeIntervalSince(lesson.start) / 60)) let durationMinutes = max(15, Int(lesson.end.timeIntervalSince(lesson.start) / 60))
let virtualStart = realMinutesToVirtual(startMinutes, periods: periods) let virtualStart = realMinutesToVirtual(startMinutes, periods: periods)
let virtualEnd = realMinutesToVirtual(startMinutes + durationMinutes, periods: periods) let virtualEnd = realMinutesToVirtual(startMinutes + durationMinutes, periods: periods)
if virtualEnd > virtualStart { if virtualEnd > virtualStart {
// Half the gap above + half below so the grid line under the
// tile stays visible.
let top = virtualStart * hourHeight / 60.0 + LESSON_GAP / 2 let top = virtualStart * hourHeight / 60.0 + LESSON_GAP / 2
let height = max( let height = max(
MIN_BLOCK_HEIGHT, MIN_BLOCK_HEIGHT,
(virtualEnd - virtualStart) * hourHeight / 60.0 - LESSON_GAP (virtualEnd - virtualStart) * hourHeight / 60.0 - LESSON_GAP
) )
let subjectSize = subjectFont(forHourHeight: hourHeight) LessonTile(
let secondarySize = max(MIN_SECONDARY_FONT, subjectSize - 2) lesson: lesson,
let room = lesson.room height: height,
let teacher = lesson.teacher ?? lesson.originalTeacher metrics: metrics,
let hasSecondary = (room?.isEmpty == false) || (teacher?.isEmpty == false) layout: TileTextLayout(
HStack(alignment: .top, spacing: 4) { lesson: lesson,
Text(subjectLabel(lesson)) innerHeight: height - 2 * TILE_PADDING_V,
.font(.system(size: subjectSize, weight: .semibold)) innerWidth: innerWidth,
.foregroundStyle(.white) metrics: metrics
.lineLimit(hasSecondary ? 1 : 2) )
.minimumScaleFactor(0.5) )
if hasSecondary {
Spacer(minLength: 0)
VStack(alignment: .trailing, spacing: -1) {
if showRoom && height >= BLOCK_SHOW_ROOM_MIN {
if let room, !room.isEmpty {
Text(room)
.font(.system(size: secondarySize))
.foregroundStyle(.white.opacity(0.85))
.lineLimit(1)
.minimumScaleFactor(0.5)
}
if showTeacher,
height >= BLOCK_SHOW_TEACHER_SEPARATE_MIN,
let teacher,
!teacher.isEmpty {
Text(teacher)
.font(.system(size: secondarySize))
.foregroundStyle(.white.opacity(0.7))
.lineLimit(1)
.minimumScaleFactor(0.5)
}
}
}
}
}
.padding(.horizontal, horizontalPadding)
.padding(.vertical, 3)
.frame(maxWidth: .infinity, alignment: .topLeading)
.frame(height: height, alignment: .topLeading)
.background(blockColor(lesson))
.cornerRadius(6)
.overlay(alignment: .bottomLeading) {
// Separate fixed-size badge so the +N hint stays readable
// when the subject autoshrinks on narrow tiles.
if let count = lesson.siblingCount, count > 0 {
Text("+\(count)")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(.white)
.padding(.leading, horizontalPadding)
.padding(.bottom, 2)
}
}
.overlay {
// CrossPainter parity: clip cross to the rounded shape so
// the diagonals don't bleed past the corners.
if lesson.status == .cancelled {
ZStack {
RoundedRectangle(cornerRadius: 6)
.stroke(Color.red.opacity(0.78), lineWidth: 1.5)
GeometryReader { geo in
Path { p in
p.move(to: .zero)
p.addLine(to: CGPoint(x: geo.size.width, y: geo.size.height))
p.move(to: CGPoint(x: geo.size.width, y: 0))
p.addLine(to: CGPoint(x: 0, y: geo.size.height))
}
.stroke(Color.red.opacity(0.78), lineWidth: 3)
}
}
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
.padding(.horizontal, showRoom ? 2 : 1)
.offset(y: top) .offset(y: top)
} }
} }
}
private func subjectLabel(_ lesson: WidgetLesson) -> String { /// Mirrors the in-app AppointmentTile: title on top, room and teacher
!lesson.subjectShort.isEmpty /// stacked below, each line shrinking on its own to fit the tile width.
? lesson.subjectShort private struct LessonTile: View {
: (lesson.subjectLong ?? "—") let lesson: WidgetLesson
} let height: CGFloat
let metrics: TileMetrics
let layout: TileTextLayout
/// Mirrors lesson_color.dart + custom_event_colors.dart so the widget private static let cancelledRed =
/// matches the in-app calendar exactly. Color(red: 244 / 255, green: 67 / 255, blue: 54 / 255).opacity(200 / 255)
private func blockColor(_ lesson: WidgetLesson) -> Color {
if lesson.status == .event, let custom = lesson.customColor { var body: some View {
switch custom { let shape = RoundedRectangle(cornerRadius: TILE_CORNER_RADIUS)
case "orange": return Color(red: 239/255.0, green: 108/255.0, blue: 0/255.0) VStack(alignment: .leading, spacing: 0) {
case "red": return Color(red: 153/255.0, green: 51/255.0, blue: 51/255.0) titleRow
case "green": return Color(red: 76/255.0, green: 175/255.0, blue: 80/255.0) ForEach(Array(layout.body.enumerated()), id: \.offset) { index, line in
case "blue": return Color(red: 33/255.0, green: 150/255.0, blue: 243/255.0) Text(line)
default: break .font(.system(size: metrics.bodyFont))
.lineLimit(1)
.minimumScaleFactor(MIN_BODY_LINE_FONT / metrics.bodyFont)
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: metrics.bodyBox)
.padding(.trailing, badgeInset(line: index + 1))
} }
} }
switch lesson.status { .foregroundStyle(.white)
// `.ongoing` deliberately collapses into the regular red — the .padding(.horizontal, TILE_PADDING_H)
// widget timeline only ticks every ~30min, so highlighting the .padding(.vertical, TILE_PADDING_V)
// "current" lesson would be stale most of the day and misleads .frame(maxWidth: .infinity, alignment: .topLeading)
// more than it helps. .frame(height: height, alignment: .topLeading)
case .regular, .past, .ongoing: .background(lesson.tileColor)
return Color(red: 153/255.0, green: 51/255.0, blue: 51/255.0) .clipShape(shape)
case .cancelled: return .black .overlay(alignment: .bottomTrailing) {
case .irregular: return Color(red: 143/255.0, green: 25/255.0, blue: 179/255.0) if let count = lesson.siblingCount, count > 0 {
case .teacherChanged: return Color(red: 41/255.0, green: 99/255.0, blue: 155/255.0) Text("+\(count)")
case .event: return Color(red: 239/255.0, green: 108/255.0, blue: 0/255.0) .font(.system(size: 9, weight: .bold))
.foregroundStyle(.white)
.padding(.trailing, TILE_PADDING_H)
.padding(.bottom, TILE_PADDING_V)
}
} }
.overlay {
if lesson.status == .cancelled {
ZStack {
shape.strokeBorder(LessonTile.cancelledRed, lineWidth: 2)
GeometryReader { geo in
Path { p in
p.move(to: .zero)
p.addLine(to: CGPoint(x: geo.size.width, y: geo.size.height))
p.move(to: CGPoint(x: geo.size.width, y: 0))
p.addLine(to: CGPoint(x: 0, y: geo.size.height))
}
.stroke(LessonTile.cancelledRed, lineWidth: 2)
}
}
.clipShape(shape)
}
}
.padding(.horizontal, TILE_MARGIN_H)
}
private var titleRow: some View {
HStack(alignment: .center, spacing: 3) {
Text(layout.title)
.font(.system(size: layout.titleFont, weight: .medium))
.lineLimit(layout.titleLines)
.minimumScaleFactor(min(1, MIN_TITLE_FONT / layout.titleFont))
.frame(maxWidth: .infinity, alignment: .leading)
if let inline = layout.inline {
Text(inline)
.font(.system(size: metrics.bodyFont))
.lineLimit(1)
.fixedSize()
}
}
.frame(height: layout.titleBox)
.padding(.trailing, badgeInset(line: 0))
}
private func badgeInset(line: Int) -> CGFloat {
layout.badgeLine == line ? BADGE_WIDTH : 0
} }
} }
/// Period boundaries deduped: adjacent periods share a line, periods on /// Fixed-locale formatters cached once — DateFormatter setup is the
/// either side of a break get their own (bracketing the break block). /// expensive part and the multi-entry timelines render up to ~8 entries per
func periodBoundaries(_ periods: [WidgetPeriod]) -> [Int] { /// reload. Only touched from WidgetKit's archival rendering, so the shared
var seen = Set<Int>() /// instances are safe.
var result: [Int] = [] enum WidgetDateFormatters {
for p in periods { static let shortDate = make("dd.MM.")
for v in [p.virtualStartMinutes, p.virtualEndMinutes] { static let weekdayShort = make("EE")
if seen.insert(v).inserted { result.append(v) } static let weekdayDate = make("EEEE · dd.MM.")
} static let time = make("HH:mm")
static let dateTime = make("dd.MM. HH:mm")
private static func make(_ format: String) -> DateFormatter {
let f = DateFormatter()
f.locale = Locale(identifier: "de_DE")
f.dateFormat = format
return f
} }
return result.sorted()
} }
func dayLabel(for date: Date) -> String { /// `now` is the timeline entry's date, not `Date()`: WidgetKit archives
/// entries ahead of time, so wall-clock reads would be wrong for every
/// entry after the first.
func dayLabel(for date: Date, relativeTo now: Date) -> String {
let cal = Calendar.current let cal = Calendar.current
let today = cal.startOfDay(for: Date()) let today = cal.startOfDay(for: now)
let anchor = cal.startOfDay(for: date) let anchor = cal.startOfDay(for: date)
if anchor == today { if anchor == today {
return "Heute · \(shortDate(date))" return "Heute · \(shortDate(date))"
@@ -410,35 +412,23 @@ func dayLabel(for date: Date) -> String {
if let tomorrow = cal.date(byAdding: .day, value: 1, to: today), anchor == tomorrow { if let tomorrow = cal.date(byAdding: .day, value: 1, to: today), anchor == tomorrow {
return "Morgen · \(shortDate(date))" return "Morgen · \(shortDate(date))"
} }
let formatter = DateFormatter() return WidgetDateFormatters.weekdayDate.string(from: date)
formatter.locale = Locale(identifier: "de_DE")
formatter.dateFormat = "EEEE · dd.MM."
return formatter.string(from: date)
} }
func shortDate(_ date: Date) -> String { func shortDate(_ date: Date) -> String {
let f = DateFormatter() WidgetDateFormatters.shortDate.string(from: date)
f.locale = Locale(identifier: "de_DE")
f.dateFormat = "dd.MM."
return f.string(from: date)
} }
func freshnessLabel(for fetchedAt: Date) -> String { func freshnessLabel(for fetchedAt: Date, relativeTo now: Date) -> String {
let cal = Calendar.current let cal = Calendar.current
let today = cal.startOfDay(for: Date()) let today = cal.startOfDay(for: now)
let fetchedDay = cal.startOfDay(for: fetchedAt) let fetchedDay = cal.startOfDay(for: fetchedAt)
let timeFmt = DateFormatter()
timeFmt.locale = Locale(identifier: "de_DE")
timeFmt.dateFormat = "HH:mm"
if fetchedDay == today { if fetchedDay == today {
return timeFmt.string(from: fetchedAt) return WidgetDateFormatters.time.string(from: fetchedAt)
} }
if let yesterday = cal.date(byAdding: .day, value: -1, to: today), if let yesterday = cal.date(byAdding: .day, value: -1, to: today),
fetchedDay == yesterday { fetchedDay == yesterday {
return "gestern \(timeFmt.string(from: fetchedAt))" return "gestern \(WidgetDateFormatters.time.string(from: fetchedAt))"
} }
let dateTimeFmt = DateFormatter() return WidgetDateFormatters.dateTime.string(from: fetchedAt)
dateTimeFmt.locale = Locale(identifier: "de_DE")
dateTimeFmt.dateFormat = "dd.MM. HH:mm"
return dateTimeFmt.string(from: fetchedAt)
} }
@@ -0,0 +1,279 @@
import SwiftUI
import UIKit
// Layout constants and rules — must mirror WidgetRenderer.kt on Android,
// otherwise the platforms drift apart on the same widget size.
let FALLBACK_VIRTUAL_MINUTES = 11 * 60
let FALLBACK_SLOT_MINUTES = 45
/// Trailing periods without lessons are cut off so the tiles get more
/// height — but never below this many periods, or a short day would blow up
/// to a handful of oversized tiles.
let MIN_VISIBLE_PERIODS = 6
let MIN_HOUR_HEIGHT: CGFloat = 18
let MAX_HOUR_HEIGHT: CGFloat = 72
let MIN_BLOCK_HEIGHT: CGFloat = 16
let LESSON_GAP: CGFloat = 1.5
let TITLE_ROW_HEIGHT: CGFloat = 20
let GRID_MARGIN_TOP: CGFloat = 4
let WEEK_HEADER_HEIGHT: CGFloat = 32
let RULER_WIDTH: CGFloat = 30
let TILE_MARGIN_H: CGFloat = 1
let TILE_PADDING_H: CGFloat = 4
let TILE_PADDING_V: CGFloat = 2
let TILE_CORNER_RADIUS: CGFloat = 7
// In-app tile: 15sp title over 10sp body lines. The widget keeps that ratio
// and scales both so title + room + teacher fill one period.
let TITLE_TO_BODY_RATIO: CGFloat = 1.5
let MAX_BODY_FONT: CGFloat = 10
let MIN_BODY_FONT: CGFloat = 6.5
let MIN_TITLE_FONT: CGFloat = 6.5
let MIN_BODY_LINE_FONT: CGFloat = 5.5
let LINE_BOX_PER_FONT: CGFloat = 1.2
/// If stacking room and teacher would push the body font below this, both
/// share one line instead so the text stays readable.
let STACKED_BODY_MIN_FONT: CGFloat = 7.5
/// A title that needs to shrink below this to fit on one line wraps onto a
/// second line instead, if the tile has the height for it.
let TITLE_WRAP_BELOW_FONT: CGFloat = 8
// Footprint of the "+N" badge in the tile's bottom trailing corner.
let BADGE_WIDTH: CGFloat = 16
let BADGE_HEIGHT: CGFloat = 11
let RULER_TIMES_MIN_CELL: CGFloat = 30
let BREAK_ICON_MIN_HEIGHT: CGFloat = 12
/// Space the holiday column keeps free above and below the rotated name for
/// the icon and the "Schulfrei" label.
let HOLIDAY_NAME_INSET: CGFloat = 44
func realMinutesToVirtual(_ realMin: Int, periods: [WidgetPeriod]) -> CGFloat {
guard !periods.isEmpty else { return CGFloat(realMin) }
for p in periods where realMin >= p.startMinutes && realMin <= p.endMinutes {
return CGFloat(p.virtualStartMinutes + (realMin - p.startMinutes))
}
let first = periods.first!
if realMin < first.startMinutes {
return CGFloat(realMin - first.startMinutes + first.virtualStartMinutes)
}
let last = periods.last!
if realMin > last.endMinutes {
return CGFloat(last.virtualEndMinutes + (realMin - last.endMinutes))
}
var prev = first
for i in 1..<periods.count {
let curr = periods[i]
if realMin > prev.endMinutes && realMin < curr.startMinutes {
let gap = curr.startMinutes - prev.endMinutes
let virtualGap = curr.virtualStartMinutes - prev.virtualEndMinutes
if gap > 0 {
return CGFloat(prev.virtualEndMinutes) +
CGFloat(realMin - prev.endMinutes) * CGFloat(virtualGap) / CGFloat(gap)
}
return CGFloat(curr.virtualStartMinutes)
}
prev = curr
}
return 0
}
func minutesOfDay(_ date: Date) -> Int {
let comps = Calendar.current.dateComponents([.hour, .minute], from: date)
return (comps.hour ?? 0) * 60 + (comps.minute ?? 0)
}
func trimTrailingPeriods(_ periods: [WidgetPeriod], lessons: [WidgetLesson]) -> [WidgetPeriod] {
guard let latestEnd = lessons.map({ minutesOfDay($0.end) }).max() else { return periods }
let lastUsed = periods.lastIndex { $0.startMinutes < latestEnd } ?? -1
return Array(periods.prefix(max(lastUsed + 1, MIN_VISIBLE_PERIODS)))
}
func totalVirtualMinutes(_ periods: [WidgetPeriod]) -> Int {
periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES
}
func resolveHourHeight(forGridHeight height: CGFloat, periods: [WidgetPeriod]) -> CGFloat {
let virtualHours = max(CGFloat(totalVirtualMinutes(periods)) / 60, 1)
return max(MIN_HOUR_HEIGHT, min(MAX_HOUR_HEIGHT, height / virtualHours))
}
/// Period boundaries deduped: adjacent periods share a line, periods on
/// either side of a break get their own (bracketing the break block).
func periodBoundaries(_ periods: [WidgetPeriod]) -> [Int] {
var seen = Set<Int>()
var result: [Int] = []
for p in periods {
for v in [p.virtualStartMinutes, p.virtualEndMinutes] {
if seen.insert(v).inserted { result.append(v) }
}
}
return result.sorted()
}
struct PeriodBreak: Hashable {
let virtualStart: Int
let virtualMinutes: Int
}
/// Only big breaks (Hofpause, Mittagspause) survive the mapper's small-break
/// collapse as a virtual gap between two periods.
func periodBreaks(_ periods: [WidgetPeriod]) -> [PeriodBreak] {
guard periods.count > 1 else { return [] }
return (0..<periods.count - 1).compactMap { i in
let gap = periods[i + 1].virtualStartMinutes - periods[i].virtualEndMinutes
return gap > 0
? PeriodBreak(virtualStart: periods[i].virtualEndMinutes, virtualMinutes: gap)
: nil
}
}
/// Font and line-box sizes shared by every tile of one widget, derived from
/// the height of a single period.
struct TileMetrics {
let maxBodyLines: Int
let bodyFont: CGFloat
let titleBox: CGFloat
let bodyBox: CGFloat
init(periods: [WidgetPeriod], hourHeight: CGFloat) {
let slotMinutes = periods.first.map { $0.virtualEndMinutes - $0.virtualStartMinutes }
?? FALLBACK_SLOT_MINUTES
let slotHeight = CGFloat(slotMinutes) * hourHeight / 60
let inner = slotHeight - LESSON_GAP - 2 * TILE_PADDING_V
func font(lines: CGFloat) -> CGFloat {
inner / (LINE_BOX_PER_FONT * (TITLE_TO_BODY_RATIO + lines))
}
maxBodyLines = font(lines: 2) < STACKED_BODY_MIN_FONT ? 1 : 2
bodyFont = max(MIN_BODY_FONT, min(MAX_BODY_FONT, font(lines: CGFloat(maxBodyLines))))
titleBox = bodyFont * TITLE_TO_BODY_RATIO * LINE_BOX_PER_FONT
bodyBox = bodyFont * LINE_BOX_PER_FONT
}
}
/// Distributes the tile height like the in-app _TileContent: the title comes
/// first, then as many body lines as fit.
struct TileTextLayout {
let title: String
let titleBox: CGFloat
let titleFont: CGFloat
let titleLines: Int
let body: [String]
/// Set when there is no room for a line below the title: the room moves
/// next to it instead.
let inline: String?
/// Index of the line (0 = title row) that has to stay clear of the "+N"
/// badge, or nil when the text ends above it.
let badgeLine: Int?
init(lesson: WidgetLesson, innerHeight: CGFloat, innerWidth: CGFloat, metrics: TileMetrics) {
title = !lesson.subjectShort.isEmpty ? lesson.subjectShort : (lesson.subjectLong ?? "—")
let candidates = [lesson.room, lesson.teacher ?? lesson.originalTeacher]
.compactMap { $0 }
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
let singleTitleBox = min(metrics.titleBox, innerHeight)
let capacity = max(
0,
min(metrics.maxBodyLines, Int((innerHeight - singleTitleBox) / metrics.bodyBox + 0.05))
)
if capacity >= candidates.count {
body = candidates
} else if capacity == 1 {
// Rather share one line than drop the teacher entirely.
body = [candidates.joined(separator: " · ")]
} else {
body = []
}
if capacity == 0, let first = candidates.first, !title.contains(first) {
inline = first
} else {
inline = nil
}
let spare = innerHeight - singleTitleBox - CGFloat(body.count) * metrics.bodyBox
if spare >= singleTitleBox,
let wrappedLine = TileTextLayout.wrappedTitleLineBox(
title, innerWidth: innerWidth, metrics: metrics
) {
titleBox = 2 * wrappedLine
titleFont = wrappedLine / LINE_BOX_PER_FONT
titleLines = 2
} else {
titleBox = singleTitleBox
titleFont = singleTitleBox / LINE_BOX_PER_FONT
titleLines = 1
}
let content = titleBox + CGFloat(body.count) * metrics.bodyBox
let hasBadge = (lesson.siblingCount ?? 0) > 0
badgeLine = hasBadge && innerHeight - content < BADGE_HEIGHT ? body.count : nil
}
/// Line box for a title wrapped onto two lines, or nil when it should
/// stay on one: it fits there at a readable size, or it has no word
/// boundary to break at. The box is capped so the longest word still
/// fits the tile width.
private static func wrappedTitleLineBox(
_ title: String,
innerWidth: CGFloat,
metrics: TileMetrics
) -> CGFloat? {
func widthPerFontPoint(_ text: String) -> CGFloat {
let font = UIFont.systemFont(ofSize: 100, weight: .medium)
return (text as NSString).size(withAttributes: [.font: font]).width / 100
}
if widthPerFontPoint(title) * TITLE_WRAP_BELOW_FONT <= innerWidth { return nil }
let words = title
.replacingOccurrences(of: "-", with: "- ")
.split(separator: " ")
.map(String.init)
guard words.count > 1, let widest = words.map(widthPerFontPoint).max(), widest > 0 else {
return nil
}
return min(metrics.titleBox, innerWidth / widest * LINE_BOX_PER_FONT)
}
}
extension Color {
/// Parses the `#RRGGBB` tile colour resolved in Dart.
init?(widgetHex hex: String?) {
guard let hex, hex.count == 7, hex.hasPrefix("#"),
let value = UInt32(hex.dropFirst(), radix: 16) else {
return nil
}
self.init(
red: Double((value >> 16) & 0xFF) / 255,
green: Double((value >> 8) & 0xFF) / 255,
blue: Double(value & 0xFF) / 255
)
}
}
extension WidgetLesson {
var tileColor: Color {
if let resolved = Color(widgetHex: color) { return resolved }
// Only reached for payloads written before Dart shipped the resolved
// tile colour; mirrors lesson_color.dart.
switch status {
// No time-based highlight: the widget timeline only ticks every
// ~30min, so "current" and "past" would be stale most of the day.
case .regular, .past, .ongoing:
return Color(red: 153 / 255, green: 51 / 255, blue: 51 / 255)
case .cancelled: return .black
case .irregular: return Color(red: 143 / 255, green: 25 / 255, blue: 179 / 255)
case .teacherChanged: return Color(red: 41 / 255, green: 99 / 255, blue: 155 / 255)
case .duty: return Color(red: 0, green: 121 / 255, blue: 107 / 255)
case .event:
return customColor != nil
? Color(red: 1, green: 160 / 255, blue: 0)
: Color(red: 46 / 255, green: 125 / 255, blue: 50 / 255)
}
}
}
@@ -20,36 +20,44 @@ struct TimetableWeekView: View {
@ViewBuilder @ViewBuilder
private func content(data: WidgetTimetableData) -> some View { private func content(data: WidgetTimetableData) -> some View {
let cal = Calendar.current
let days = (0..<5).map { offset in
cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate
}
let weekLessons = data.lessons.filter { lesson in
days.contains { cal.isDate(lesson.start, inSameDayAs: $0) }
}
let periods = trimTrailingPeriods(data.periods, lessons: weekLessons)
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
header(data: data) header(data: data)
dayHeaderRow(data: data) dayHeaderRow(days: days)
.padding(.top, GRID_MARGIN_TOP)
GeometryReader { geo in GeometryReader { geo in
let totalMin = CGFloat(data.periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES) let height = resolveHourHeight(forGridHeight: geo.size.height, periods: periods)
let hourHeight = max(
MIN_HOUR_HEIGHT,
min(MAX_HOUR_HEIGHT, geo.size.height / max(totalMin, 60) * 60)
)
let dayColumnWidth = (geo.size.width - 28 - 4) / 5
// 45pt is the minimum where a 7pt-font room (e.g. "F2.04")
// plus the subject fits without `minimumScaleFactor`
// shrinking the subject into illegibility.
let subjectOnly = dayColumnWidth < 45
HStack(alignment: .top, spacing: 0) { HStack(alignment: .top, spacing: 0) {
timeLabelsColumn(hourHeight: hourHeight, periods: data.periods) PeriodRuler(periods: periods, hourHeight: height)
.frame(width: 28, alignment: .topTrailing)
columnDivider columnDivider
ForEach(0..<5, id: \.self) { offset in ForEach(days.indices, id: \.self) { index in
column( TimeGridView(
data: data, lessons: weekLessons.filter {
offset: offset, cal.isDate($0.start, inSameDayAs: days[index])
hourHeight: hourHeight, },
subjectOnly: subjectOnly periods: periods,
hourHeight: height,
holiday: holiday(on: days[index], data: data),
isToday: cal.isDate(days[index], inSameDayAs: entry.date)
) )
.frame(maxWidth: .infinity) if index < 4 { columnDivider }
if offset < 4 { columnDivider }
} }
} }
} }
.padding(.top, 2)
}
}
private func holiday(on day: Date, data: WidgetTimetableData) -> WidgetDayInfo? {
data.days?.first {
$0.isHoliday && Calendar.current.isDate($0.date, inSameDayAs: day)
} }
} }
@@ -63,115 +71,55 @@ struct TimetableWeekView: View {
let cal = Calendar.current let cal = Calendar.current
let week = cal.component(.weekOfYear, from: data.anchorDate) let week = cal.component(.weekOfYear, from: data.anchorDate)
let endDate = cal.date(byAdding: .day, value: 4, to: data.anchorDate) ?? data.anchorDate let endDate = cal.date(byAdding: .day, value: 4, to: data.anchorDate) ?? data.anchorDate
return HStack { return HStack(spacing: 6) {
Text("KW \(week) · \(shortDate(data.anchorDate))–\(shortDate(endDate))") Text("KW \(week) · \(shortDate(data.anchorDate))–\(shortDate(endDate))")
.font(.system(size: 12, weight: .semibold)) .font(.system(size: 14, weight: .bold))
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.8) .minimumScaleFactor(0.65)
Spacer() .frame(maxWidth: .infinity, alignment: .leading)
Text("Stand: \(freshnessLabel(for: data.fetchedAt))") Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
.font(.system(size: 9)) .font(.system(size: 10))
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
.lineLimit(1) .lineLimit(1)
.fixedSize()
} }
.frame(height: TITLE_ROW_HEIGHT)
} }
private func dayHeaderRow(data: WidgetTimetableData) -> some View { /// Mirrors the in-app _DayHeaderCell: weekday in caps, day number below,
let cal = Calendar.current /// today inside an accent circle.
// `.frame(maxWidth: .infinity)` is critical: without an explicit private func dayHeaderRow(days: [Date]) -> some View {
// greedy width (or a true `Spacer()` inside), this HStack collapses
// to the sum of its fixed-width children in a `.leading` VStack
// and the 5 day-columns end up sharing ~5pt of width — the labels
// crunch to the left and stop aligning with the grid columns below.
// The fixed height keeps `columnDivider`'s vertically-flexible // The fixed height keeps `columnDivider`'s vertically-flexible
// Rectangle from stealing space from the GeometryReader. // Rectangle from stealing space from the GeometryReader below.
return HStack(spacing: 0) { HStack(spacing: 0) {
Spacer().frame(width: 28) Spacer().frame(width: RULER_WIDTH)
columnDivider columnDivider
ForEach(0..<5, id: \.self) { offset in ForEach(days.indices, id: \.self) { index in
let day = cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate dayHeaderCell(days[index])
VStack(spacing: -1) { .frame(maxWidth: .infinity)
Text(weekday(for: day)) if index < 4 { columnDivider }
.font(.system(size: 10, weight: .bold))
.foregroundStyle(palette.textPrimary)
Text(shortDate(day))
.font(.system(size: 8))
.foregroundStyle(palette.textSecondary)
}
.frame(maxWidth: .infinity)
if offset < 4 { columnDivider }
} }
} }
// 20pt fits 10pt weekday + 8pt date with tight -1pt spacing. .frame(maxWidth: .infinity, minHeight: WEEK_HEADER_HEIGHT, maxHeight: WEEK_HEADER_HEIGHT)
.frame(maxWidth: .infinity, minHeight: 20, maxHeight: 20)
} }
private func timeLabelsColumn(hourHeight: CGFloat, periods: [WidgetPeriod]) -> some View { private func dayHeaderCell(_ day: Date) -> some View {
let totalMin = periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES // `entry.date`, not `Date()` — WidgetKit archives entries ahead of
let totalHeight = CGFloat(totalMin) * hourHeight / 60.0 // time, the midnight entry has to mark its own day.
// Mirrors TimeGridView.compactLabels — below 26pt/hour a stacked let isToday = Calendar.current.isDate(day, inSameDayAs: entry.date)
// time+name no longer fits in one period slot and the two labels return VStack(spacing: 1) {
// visually clip into the next slot. Drop to a single bold period Text(WidgetDateFormatters.weekdayShort.string(from: day).uppercased())
// number until there's room to read both lines. .font(.system(size: 9, weight: .medium))
let compact = hourHeight < 26 .foregroundStyle(isToday ? palette.accent : palette.textSecondary)
return ZStack(alignment: .topTrailing) { .lineLimit(1)
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in Text("\(Calendar.current.component(.day, from: day))")
Rectangle() .font(.system(size: 11, weight: isToday ? .bold : .regular))
.fill(palette.divider) .foregroundStyle(isToday ? palette.onAccent : palette.textPrimary)
.frame(height: 1) .lineLimit(1)
.offset(y: CGFloat(virtualMin) * hourHeight / 60.0) .frame(width: 18, height: 18)
} .background(Circle().fill(isToday ? palette.accent : Color.clear))
ForEach(periods, id: \.startMinutes) { period in
VStack(alignment: .trailing, spacing: -2) {
if compact {
Text("\(period.name).")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
} else {
Text(String(format: "%02d:%02d", period.startMinutes / 60, period.startMinutes % 60))
.font(.system(size: 9))
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
Text("\(period.name).")
.font(.system(size: 7, weight: .bold))
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
}
}
.padding(.trailing, 4)
.offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0)
}
} }
.frame(height: totalHeight, alignment: .topTrailing)
}
private func column(
data: WidgetTimetableData,
offset: Int,
hourHeight: CGFloat,
subjectOnly: Bool
) -> some View {
let cal = Calendar.current
let day = cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate
let lessonsForDay = data.lessons.filter { cal.isDate($0.start, inSameDayAs: day) }
return TimeGridView(
lessons: lessonsForDay,
periods: data.periods,
hourHeight: hourHeight,
showRoom: !subjectOnly,
showTeacher: !subjectOnly,
showTimeLabels: false,
horizontalPadding: 3
)
}
private func weekday(for date: Date) -> String {
let f = DateFormatter()
f.locale = Locale(identifier: "de_DE")
f.dateFormat = "EE"
return f.string(from: date)
} }
private func placeholder(_ message: String) -> some View { private func placeholder(_ message: String) -> some View {
@@ -41,11 +41,13 @@ struct TimetableDayProvider: TimelineProvider {
in context: Context, in context: Context,
completion: @escaping (Timeline<TimetableEntry>) -> Void completion: @escaping (Timeline<TimetableEntry>) -> Void
) { ) {
let entry = TimetableEntry.current(variant: .day) let now = Date()
// 30 min mirrors the Dart workmanager cadence. iOS treats this as // 30 min mirrors the Dart workmanager cadence. iOS treats this as
// advisory; the "Stand:" label tells the user when data is stale. // advisory; the boundary entries below keep the rendered day correct
let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date() // even when no reload is granted, and the "Stand:" label tells the
completion(Timeline(entries: [entry], policy: .after(next))) // user when the underlying data is stale.
let next = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now
completion(Timeline(entries: TimetableEntry.dayEntries(now: now), policy: .after(next)))
} }
} }
@@ -80,9 +82,9 @@ struct TimetableWeekProvider: TimelineProvider {
in context: Context, in context: Context,
completion: @escaping (Timeline<TimetableEntry>) -> Void completion: @escaping (Timeline<TimetableEntry>) -> Void
) { ) {
let entry = TimetableEntry.current(variant: .week) let now = Date()
let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date() let next = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now
completion(Timeline(entries: [entry], policy: .after(next))) completion(Timeline(entries: TimetableEntry.weekEntries(now: now), policy: .after(next)))
} }
} }
@@ -120,6 +122,58 @@ struct TimetableEntry: TimelineEntry {
themeMode: WidgetDataLoader.themeMode() themeMode: WidgetDataLoader.themeMode()
) )
} }
/// Day timeline derived from the 14-day week payload, so the widget shows
/// the right day even when iOS grants no reload for days.
static func dayEntries(now: Date) -> [TimetableEntry] {
entries(now: now, variant: .day) { week, date in
TimetableAnchor.slice(week: week, forDay: TimetableAnchor.resolveDayAnchor(date))
}
}
/// Week timeline: re-anchoring performs the Friday-evening/weekend jump
/// into next week from cached data, and the midnight entries keep the
/// "Stand:" freshness label honest.
static func weekEntries(now: Date) -> [TimetableEntry] {
entries(now: now, variant: .week) { week, date in
TimetableAnchor.retarget(week: week, anchorDate: TimetableAnchor.resolveWeekAnchor(date))
}
}
/// Shared timeline skeleton: one entry now plus one per anchor boundary
/// (midnight rollover, 17:00 cutoff). Boundaries that cannot change the
/// render — same anchor and same calendar day for the header labels —
/// are dropped.
private static func entries(
now: Date,
variant: TimetableVariant,
transform: (WidgetTimetableData, Date) -> WidgetTimetableData
) -> [TimetableEntry] {
guard WidgetDataLoader.isLoggedIn(), let week = WidgetDataLoader.loadWeek() else {
// Logged out, or no v2 week snapshot yet (fresh app update):
// fall back to the legacy single-entry payload.
return [TimetableEntry.current(variant: variant)]
}
let theme = WidgetDataLoader.themeMode()
let cal = Calendar.current
var result: [TimetableEntry] = []
for date in [now] + TimetableAnchor.boundaryDates(from: now) {
let data = transform(week, date)
if let previous = result.last, let previousData = previous.data,
cal.isDate(previousData.anchorDate, inSameDayAs: data.anchorDate),
cal.isDate(previous.date, inSameDayAs: date) {
continue
}
result.append(TimetableEntry(
date: date,
variant: variant,
data: data,
isLoggedIn: true,
themeMode: theme
))
}
return result
}
} }
extension View { extension View {
+26 -1
View File
@@ -10,6 +10,15 @@ enum WidgetLessonStatus: String, Codable {
case irregular case irregular
case teacherChanged case teacherChanged
case event case event
case duty
/// Unknown future statuses degrade to `.regular` instead of failing the
/// whole payload decode (mirrors WidgetData.kt's fromWire fallback) — a
/// single new enum value must never blank the widget to the placeholder.
init(from decoder: Decoder) throws {
let raw = try decoder.singleValueContainer().decode(String.self)
self = WidgetLessonStatus(rawValue: raw) ?? .regular
}
} }
struct WidgetLesson: Codable { struct WidgetLesson: Codable {
@@ -18,10 +27,15 @@ struct WidgetLesson: Codable {
let subjectShort: String let subjectShort: String
let subjectLong: String? let subjectLong: String?
let room: String? let room: String?
// On teacher accounts this carries the class label ("7a") instead of the
// teacher short name (originalTeacher is nil then) — mapped in Dart.
let teacher: String? let teacher: String?
let originalTeacher: String? let originalTeacher: String?
let status: WidgetLessonStatus let status: WidgetLessonStatus
let customColor: String? let customColor: String?
// Tile colour resolved in Dart as "#RRGGBB" (subject colours, palettes);
// nil only for payloads written before the field existed.
let color: String?
let siblingCount: Int? let siblingCount: Int?
} }
@@ -33,6 +47,12 @@ struct WidgetPeriod: Codable {
let virtualEndMinutes: Int let virtualEndMinutes: Int
} }
struct WidgetDayInfo: Codable {
let date: Date
let isHoliday: Bool
let holidayName: String?
}
struct WidgetTimetableData: Codable { struct WidgetTimetableData: Codable {
let fetchedAt: Date let fetchedAt: Date
let anchorDate: Date let anchorDate: Date
@@ -40,12 +60,17 @@ struct WidgetTimetableData: Codable {
let periods: [WidgetPeriod] let periods: [WidgetPeriod]
let isHoliday: Bool let isHoliday: Bool
let holidayName: String? let holidayName: String?
/// Week payload (v2) only; optional so day payloads keep decoding.
let days: [WidgetDayInfo]?
} }
/// Mirrors lib/widget_data/widget_sync.dart (the canonical key list) — a
/// schema bump must land in Dart, Kotlin (WidgetRenderer.kt) and here
/// together, or the out-of-sync platform silently blanks to the placeholder.
enum WidgetDataKey { enum WidgetDataKey {
static let appGroupId = "group.eu.mhsl.marianum.mobile.client.widget" static let appGroupId = "group.eu.mhsl.marianum.mobile.client.widget"
static let dayData = "widget_data_day_v1" static let dayData = "widget_data_day_v1"
static let weekData = "widget_data_week_v1" static let weekData = "widget_data_week_v2"
static let loggedIn = "widget_data_logged_in_v1" static let loggedIn = "widget_data_logged_in_v1"
static let themeMode = "widget_setting_theme_mode_v1" static let themeMode = "widget_setting_theme_mode_v1"
} }
@@ -9,15 +9,27 @@ struct WidgetPalette {
let textPrimary: Color let textPrimary: Color
let textSecondary: Color let textSecondary: Color
let divider: Color let divider: Color
let accent: Color
let onAccent: Color
let breakBlock: Color let breakBlock: Color
let todayTint: Color
let holidayTint: Color
let watermarkOpacity: Double let watermarkOpacity: Double
// ColorScheme.fromSeed(#993333).primary of the app's light / dark theme.
private static let lightAccent = Color(red: 0x90 / 255, green: 0x4A / 255, blue: 0x47 / 255)
private static let darkAccent = Color(red: 0xFF / 255, green: 0xB3 / 255, blue: 0xAF / 255)
static let light = WidgetPalette( static let light = WidgetPalette(
background: Color(red: 0xFC / 255, green: 0xF7 / 255, blue: 0xF5 / 255), background: Color(red: 0xFC / 255, green: 0xF7 / 255, blue: 0xF5 / 255),
textPrimary: Color(red: 0x11 / 255, green: 0x11 / 255, blue: 0x11 / 255), textPrimary: Color(red: 0x11 / 255, green: 0x11 / 255, blue: 0x11 / 255),
textSecondary: Color(red: 0x55 / 255, green: 0x55 / 255, blue: 0x55 / 255), textSecondary: Color(red: 0x55 / 255, green: 0x55 / 255, blue: 0x55 / 255),
divider: Color.black.opacity(0x22 / 255.0), divider: Color.black.opacity(0x22 / 255.0),
breakBlock: Color.black.opacity(0x0C / 255.0), accent: lightAccent,
onAccent: .white,
breakBlock: lightAccent.opacity(0x32 / 255.0),
todayTint: lightAccent.opacity(0x0E / 255.0),
holidayTint: Color.black.opacity(0x32 / 255.0),
watermarkOpacity: 0.014 watermarkOpacity: 0.014
) )
@@ -26,7 +38,11 @@ struct WidgetPalette {
textPrimary: Color(red: 0xF1 / 255, green: 0xF1 / 255, blue: 0xF1 / 255), textPrimary: Color(red: 0xF1 / 255, green: 0xF1 / 255, blue: 0xF1 / 255),
textSecondary: Color(red: 0xB0 / 255, green: 0xB0 / 255, blue: 0xB0 / 255), textSecondary: Color(red: 0xB0 / 255, green: 0xB0 / 255, blue: 0xB0 / 255),
divider: Color.white.opacity(0x33 / 255.0), divider: Color.white.opacity(0x33 / 255.0),
breakBlock: Color.white.opacity(0x14 / 255.0), accent: darkAccent,
onAccent: Color(red: 0x57 / 255, green: 0x1D / 255, blue: 0x1D / 255),
breakBlock: darkAccent.opacity(0x32 / 255.0),
todayTint: darkAccent.opacity(0x0E / 255.0),
holidayTint: Color.white.opacity(0x32 / 255.0),
watermarkOpacity: 0.025 watermarkOpacity: 0.025
) )
+2
View File
@@ -0,0 +1,2 @@
app_identifier("eu.mhsl.marianum.mobile.client")
team_id("MY55VF3KPG")
+27
View File
@@ -0,0 +1,27 @@
default_platform(:ios)
# App-Store-Auslieferung über fastlane deliver. Die Screenshots erzeugt
# ../tool/screenshots_ios.sh (Demo-Login + integration_test) direkt in
# screenshots/de-DE/; deliver ordnet sie anhand der Pixelgröße iPhone 6,5″ bzw.
# iPad 13″ zu.
#
# Authentifizierung über einen App-Store-Connect-API-Key (JSON im fastlane-
# Format, siehe README.md). Pfad per APP_STORE_CONNECT_API_KEY setzen; die Datei
# selbst wird per .gitignore nicht eingecheckt.
platform :ios do
desc "Nur Screenshots hochladen (ersetzt die vorhandenen, verändert keine Texte/Binaries)"
lane :upload_screenshots do
upload_to_app_store(
api_key_path: ENV["APP_STORE_CONNECT_API_KEY"] || "fastlane/app-store-connect-api-key.json",
skip_binary_upload: true,
skip_metadata: true,
skip_app_version_update: true,
skip_screenshots: false,
overwrite_screenshots: true,
screenshots_path: "./fastlane/screenshots",
precheck_include_in_app_purchases: false,
run_precheck_before_submit: false,
force: true, # kein HTML-Vorschau-Bestätigungsschritt
)
end
end
+49
View File
@@ -0,0 +1,49 @@
# Fastlane – App-Store-Screenshots & Upload
iOS-Gegenstück zu `android/fastlane`. Die App muss dafür **nicht** neu gebaut
werden – der Demo-Modus (`demo@`-Login) liefert die kompletten Inhalte aus
Fixtures.
## 1. Screenshots aufnehmen
Aus dem **Client**-Wurzelverzeichnis:
```bash
tool/screenshots_ios.sh
```
Fährt „iPhone 14 Plus“ (6,5″, 1284×2778) und „iPad Pro 13-inch (M5)“
(13″, 2064×2752) nacheinander durch und legt die PNGs in
`materials/screenshots/{ios/6-5-inch,ipados/13-inch}/` sowie als JPEG-Upload-Kopie
(App Store Connect lehnt Alpha-Kanäle ab) in `screenshots/de-DE/` ab. Fehlende
Simulatoren legt das Skript selbst an.
- Nur eine Größe: `PROFILES="iphone" tool/screenshots_ios.sh`
- Andere Simulatoren: `IPHONE_SIM="iPhone 11 Pro Max" IPAD_SIM="…" tool/screenshots_ios.sh`
App und Schlüsselbund der verwendeten Simulatoren werden dabei zurückgesetzt.
## 2. Hochladen
fastlane installieren (`brew install fastlane`), in App Store Connect unter
*Benutzer und Zugriff → Integrationen → App Store Connect API* einen Key mit
Rolle *App-Manager* anlegen und als JSON ablegen:
```json
{
"key_id": "ABC123XYZ",
"issuer_id": "00000000-0000-0000-0000-000000000000",
"key": "-----BEGIN PRIVATE KEY-----\n…\n-----END PRIVATE KEY-----",
"in_house": false
}
```
```bash
export APP_STORE_CONNECT_API_KEY=/pfad/zu/app-store-connect-api-key.json
cd ios
fastlane upload_screenshots
```
Die Screenshots landen in der aktuell bearbeitbaren App-Store-Version
(„Vorbereitung für Einreichung“); vorhandene Screenshots werden ersetzt. Der
API-Key wird **nicht** eingecheckt (siehe `.gitignore`).
-1
View File
@@ -1 +0,0 @@
class ApiRequest {}
+5
View File
@@ -7,4 +7,9 @@ abstract class ApiResponse {
@JsonKey(includeIfNull: false) @JsonKey(includeIfNull: false)
Map<String, String>? headers; Map<String, String>? headers;
/// Entries the decoder skipped (see `decodeListLenient`), reported by the
/// API layer once the response is back on the main isolate.
@JsonKey(includeFromJson: false, includeToJson: false)
List<String> parseWarnings = const [];
} }
+219
View File
@@ -0,0 +1,219 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import '../session/session_manager.dart';
import '../utils/directory_size.dart';
/// One cached response: the raw JSON payload and when it was stored.
class CacheEntry {
final String json;
final int lastUpdate;
const CacheEntry({required this.json, required this.lastUpdate});
}
/// File-per-key store behind [RequestCache] and friends.
///
/// Replaces `localstore` for response caching: localstore reads and decodes
/// the entire collection synchronously on first access, keeps every document
/// in memory for the whole session and JSON-encodes payloads a second time.
/// Here every access is async and touches exactly one file, and the payload is
/// stored verbatim behind a one-line header (`<lastUpdate>\n<json>`).
///
/// Every account has its own directory; all access goes to the one of the
/// active account, in every isolate.
class CacheStore {
CacheStore._();
static final CacheStore instance = CacheStore._();
/// Payloads above this size are decoded on a background isolate.
static const int isolateDecodeThreshold = 64 * 1024;
static const _directoryPrefix = 'request_cache';
final Map<String, Future<Directory>> _dirs = {};
final Map<String, Future<void>> _writes = {};
/// Directory name for an account's storage [namespace]; null while no
/// account is active. The account from before multi-account support
/// (namespace '') keeps the original directory.
@visibleForTesting
static String directoryName(String? namespace) => switch (namespace) {
null => '$_directoryPrefix-signed-out',
'' => _directoryPrefix,
_ => '$_directoryPrefix-$namespace',
};
Future<Directory> _directory() {
final name = directoryName(SessionManager().activeAccount?.namespace);
return _dirs[name] ??= () async {
final base = await getApplicationCacheDirectory();
final dir = Directory('${base.path}/$name');
await dir.create(recursive: true);
return dir;
}();
}
Future<File> _file(String key) async =>
File('${(await _directory()).path}/${_safeName(key)}');
/// Keys are internal ids (`nc-chat-<token>`, `wd-folder-<md5>`); only path
/// separators would be a problem in a file name.
static String _safeName(String key) => key.replaceAll(RegExp(r'[/\\]'), '_');
Future<CacheEntry?> read(String key) async {
try {
final file = await _file(key);
await _writes[file.path];
return parse(await file.readAsString());
} on Object {
// Missing (PathNotFoundException) or unreadable: a cache miss.
return null;
}
}
/// All entries whose key starts with [prefix], read concurrently.
Future<Map<String, CacheEntry>> readAll({String prefix = ''}) async {
final keys = await this.keys(prefix: prefix);
final entries = await Future.wait(keys.map(read));
return {for (var i = 0; i < keys.length; i++) keys[i]: ?entries[i]};
}
Future<void> write(String key, String json) async {
// Resolved up front: a queued write must not follow an account switch
// into the next account's directory.
final file = await _file(key);
final path = file.path;
final previous = _writes[path] ?? Future<void>.value();
late final Future<void> current;
current = previous.then((_) => _write(file, json)).whenComplete(() {
if (identical(_writes[path], current)) _writes.remove(path);
});
return _writes[path] = current;
}
Future<void> _write(File file, String json) async {
try {
// Write-then-rename so a reader (or the widget background isolate)
// never sees a half-written file.
final tmp = File('${file.path}.tmp');
await tmp.writeAsString(
'${DateTime.now().millisecondsSinceEpoch}\n$json',
flush: true,
);
await tmp.rename(file.path);
} on Object catch (e) {
debugPrint('CacheStore.write(${file.path}) failed: $e');
}
}
Future<void> delete(String key) async {
try {
final file = await _file(key);
await _writes[file.path];
await file.delete();
} on Object {
// A missing or locked file is as good as deleted for a cache.
}
}
/// Empties the active account's cache.
Future<void> clear() async {
try {
final dir = await _directory();
if (dir.existsSync()) await dir.delete(recursive: true);
} on Object catch (e) {
debugPrint('CacheStore.clear failed: $e');
}
_dirs.clear();
}
/// Removes the cache of the account with [namespace], active or not.
Future<void> deleteNamespace(String namespace) async {
final name = directoryName(namespace);
try {
final base = await getApplicationCacheDirectory();
final dir = Directory('${base.path}/$name');
if (dir.existsSync()) await dir.delete(recursive: true);
} on Object catch (e) {
debugPrint('CacheStore.deleteNamespace failed: $e');
}
_dirs.removeWhere((key, _) => key == name);
}
/// All stored keys, optionally filtered by [prefix].
Future<List<String>> keys({String prefix = ''}) async {
try {
final dir = await _directory();
return [
await for (final entity in dir.list())
if (entity is File && !entity.path.endsWith('.tmp'))
if (entity.uri.pathSegments.last case final name
when name.startsWith(prefix))
name,
];
} on Object {
return const [];
}
}
/// Removes entries not written for [maxAge] from every account's cache,
/// judged by file mtime so nothing has to be read or decoded.
Future<void> deleteOlderThan(Duration maxAge) async {
try {
final base = await getApplicationCacheDirectory();
final cutoff = DateTime.now().subtract(maxAge);
await for (final dir in base.list()) {
if (dir is! Directory) continue;
final name = dir.uri.pathSegments.lastWhere((s) => s.isNotEmpty);
if (!name.startsWith(_directoryPrefix)) continue;
await for (final entity in dir.list()) {
if (entity is! File) continue;
// ignore: avoid_slow_async_io
final modified = (await entity.stat()).modified;
if (modified.isBefore(cutoff)) await entity.delete();
}
}
} on Object catch (e) {
debugPrint('CacheStore.deleteOlderThan failed: $e');
}
}
/// Total size of all entries in bytes.
Future<int> totalSize() async => directorySize(await _directory());
/// Parses the stored file format; public for tests.
static CacheEntry? parse(String raw) {
final newline = raw.indexOf('\n');
if (newline < 0) return null;
final lastUpdate = int.tryParse(raw.substring(0, newline));
if (lastUpdate == null) return null;
return CacheEntry(json: raw.substring(newline + 1), lastUpdate: lastUpdate);
}
/// Decodes a cached payload, off the UI isolate when it is large.
static Future<Map<String, dynamic>> decode(String json) =>
json.length > isolateDecodeThreshold
? compute(_decodeMap, json)
: Future.value(_decodeMap(json));
static Map<String, dynamic> _decodeMap(String json) =>
jsonDecode(json) as Map<String, dynamic>;
/// Deletes the directory the old localstore-based cache lived in. It is
/// never read again and would otherwise linger with up to 200 days of data.
static Future<void> deleteLegacyLocalstoreCache() async {
try {
final docs = await getApplicationDocumentsDirectory();
final legacy = Directory('${docs.path}/MarianumMobile');
if (legacy.existsSync()) await legacy.delete(recursive: true);
} on Object catch (e) {
debugPrint('Legacy cache cleanup failed: $e');
}
}
}
+17
View File
@@ -0,0 +1,17 @@
import '../../marianumconnect/queries/absence/absence_prefill_response.dart';
/// Demo fixtures for the absence-report form: the class dropdown and the
/// identity/phone prefill. Typed (compile-checked) so a field rename can't
/// silently drift the demo shape away from what the queries parse.
class DemoAbsence {
const DemoAbsence._();
static List<String> classes() => const ['5a', '6b', '7c', '9d', '10a', 'Q1'];
static AbsencePrefillResponse prefill() => AbsencePrefillResponse(
firstName: 'Max',
lastName: 'Mustermann',
className: '10a',
phone: '0123 456789',
);
}
+9
View File
@@ -0,0 +1,9 @@
import '../../marianumconnect/queries/get_breakers/get_breakers_response.dart';
/// Demo fixtures for the app-maintenance breaker.
class DemoBreaker {
const DemoBreaker._();
/// No active rules — the demo app is never blocked.
static GetBreakersResponse none() => GetBreakersResponse([]);
}
+39
View File
@@ -0,0 +1,39 @@
import '../../../state/app/modules/capabilities/bloc/capabilities_state.dart';
import '../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_state.dart';
/// Demo fixtures for the mobile capability flags — everything granted so the
/// demo persona sees every feature (incl. push) as available and the timetable
/// scroll range stays unlimited (null day counts).
class DemoCapabilities {
const DemoCapabilities._();
static CapabilitiesState state() => const CapabilitiesState(
viewForeignTimetables: true,
pushNotifications: true,
timetablePastDays: null,
timetableFutureDays: null,
// Die Demo-Persona ist explizit Schüler — null hieße "Backend kennt das
// Feld nicht" (siehe CapabilitiesResponse.userType).
userType: 'STUDENT',
loaded: true,
);
}
/// Demo fixtures for the Nextcloud `files_sharing` capabilities — a permissive
/// "everything enabled, nothing enforced" server profile.
class DemoNextcloudCapabilities {
const DemoNextcloudCapabilities._();
static NextcloudCapabilitiesState state() => const NextcloudCapabilitiesState(
apiEnabled: true,
publicEnabled: true,
publicMultipleLinks: true,
publicUploadEnabled: true,
publicPasswordEnforced: false,
publicExpireEnabled: false,
publicExpireEnforced: false,
groupEnabled: true,
resharing: true,
loaded: true,
);
}
+155
View File
@@ -0,0 +1,155 @@
import '../../marianumcloud/webdav/queries/list_files/cacheable_file.dart';
import '../../marianumcloud/webdav/queries/list_files/list_files_response.dart';
/// Curated WebDAV listings for the demo mode. Everything is built on
/// [CacheableFile] (the app-level model the file UI renders) so no `nextcloud`
/// DAV objects need to be faked and no network is ever touched.
///
/// `hasPreview`/`fileId` are left null on purpose: without them the file list
/// won't try to fetch server-side thumbnails for the fixtures.
class DemoFiles {
const DemoFiles._();
/// Returns the listing for [path] as passed to `FilesDataProvider.listFiles`
/// (`/` or empty for the root, otherwise the joined folder display names).
/// Unknown folders resolve to an empty listing rather than an error.
static ListFilesResponse listing(String path) {
final key = path.replaceAll('/', '').trim();
switch (key) {
case '':
return _root();
case 'Hausaufgaben':
return _hausaufgaben();
case 'Arbeitsblätter':
return _arbeitsblaetter();
case 'Klausuren':
return _klausuren();
case 'Schuljahr 2025-26':
return _schuljahr();
default:
return ListFilesResponse({});
}
}
static ListFilesResponse _root() => ListFilesResponse({
_folder('Hausaufgaben', daysAgo: 1),
_folder('Arbeitsblätter', daysAgo: 3),
_folder('Klausuren', daysAgo: 12),
_folder('Schuljahr 2025-26', daysAgo: 30),
_file('Deutsch Lektüre.pdf', size: 2_480_000, daysAgo: 2),
_file('Mathe Formelsammlung.pdf', size: 890_000, daysAgo: 9),
_file('Bio Referat.pptx', size: 5_120_000, daysAgo: 5),
_file('Stundenplan.pdf', size: 145_000, daysAgo: 14),
});
static ListFilesResponse _hausaufgaben() => ListFilesResponse({
_file(
'Deutsch Aufsatz.docx',
parent: 'Hausaufgaben',
size: 42_000,
daysAgo: 1,
),
_file(
'Mathe Übungen KW28.pdf',
parent: 'Hausaufgaben',
size: 310_000,
daysAgo: 2,
),
_file(
'Englisch Vokabeln.pdf',
parent: 'Hausaufgaben',
size: 96_000,
daysAgo: 4,
),
});
static ListFilesResponse _arbeitsblaetter() => ListFilesResponse({
_file(
'Physik Optik.pdf',
parent: 'Arbeitsblätter',
size: 720_000,
daysAgo: 6,
),
_file(
'Geschichte Weimar.pdf',
parent: 'Arbeitsblätter',
size: 540_000,
daysAgo: 8,
),
});
static ListFilesResponse _klausuren() => ListFilesResponse({
_file(
'Mathe Klausur 1.pdf',
parent: 'Klausuren',
size: 630_000,
daysAgo: 20,
),
_file(
'Deutsch Klausur 1.pdf',
parent: 'Klausuren',
size: 580_000,
daysAgo: 34,
),
});
static ListFilesResponse _schuljahr() => ListFilesResponse({
_folder('Halbjahr 1', parent: 'Schuljahr 2025-26', daysAgo: 40),
_file(
'Klassenliste 10b.pdf',
parent: 'Schuljahr 2025-26',
size: 78_000,
daysAgo: 60,
),
});
static CacheableFile _folder(
String name, {
String? parent,
required int daysAgo,
}) {
final base = parent == null ? '' : '/$parent';
return CacheableFile(
path: '$base/$name/',
isDirectory: true,
name: name,
modifiedAt: _relative(daysAgo),
createdAt: _relative(daysAgo + 30),
);
}
static CacheableFile _file(
String name, {
String? parent,
required int size,
required int daysAgo,
}) {
final base = parent == null ? '' : '/$parent';
return CacheableFile(
path: '$base/$name',
isDirectory: false,
name: name,
mimeType: _mimeFor(name),
size: size,
modifiedAt: _relative(daysAgo),
createdAt: _relative(daysAgo + 30),
);
}
static DateTime _relative(int daysAgo) =>
DateTime.now().subtract(Duration(days: daysAgo, hours: 3));
static String _mimeFor(String name) {
final ext = name.split('.').last.toLowerCase();
switch (ext) {
case 'pdf':
return 'application/pdf';
case 'pptx':
return 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
case 'docx':
return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
default:
return 'application/octet-stream';
}
}
}

Some files were not shown because too many files have changed in this diff Show More