Author SHA1 Message Date
MineTec 952f916231 bumped version to 1.6.1+67 2026-10-19 09:51:20 +02:00
MineTec 9ea0eae53c Merge remote-tracking branch 'origin/develop' into develop 2026-10-19 09:49:31 +02:00
MineTec 4a4e101532 share extension now accepts raw image data and falls back to a file copy, so sharing from the markup editor no longer closes immediately 2026-10-19 09:49:23 +02:00
MineTec 23e4510bf8 fixed client-reported crashes in the pdf viewer, image picker and prosemirror images, mapped webdav errors to user messages and hardened keychain, cache and push stores against io failures 2026-10-12 17:00:14 +02:00
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
330 changed files with 5624 additions and 4224 deletions

No files matched your search

+6
View File
@@ -362,3 +362,9 @@ android/fastlane/README.md.bak
# Screenshots unter materials/screenshots/. Nur die leeren Ordner (.gitkeep) # Screenshots unter materials/screenshots/. Nur die leeren Ordner (.gitkeep)
# bleiben eingecheckt, damit supply die Struktur vorfindet. # bleiben eingecheckt, damit supply die Struktur vorfindet.
android/fastlane/metadata/android/**/images/**/*.png 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
+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
+21 -2
View File
@@ -17,7 +17,8 @@ import 'package:marianum_mobile/view/pages/talk/widgets/chat_tile.dart';
/// Stundenplan (hell + dunkel), Talk-Liste, Talk-Chat, Dateien, „Mehr"-Bereich /// Stundenplan (hell + dunkel), Talk-Liste, Talk-Chat, Dateien, „Mehr"-Bereich
/// und Einstellungen. Ausgeführt über `flutter drive` mit /// und Einstellungen. Ausgeführt über `flutter drive` mit
/// test_driver/integration_test.dart, orchestriert von tool/screenshots.sh /// test_driver/integration_test.dart, orchestriert von tool/screenshots.sh
/// (Phone / 7″ / 10″). /// (Android: Phone / 7″ / 10″) bzw. tool/screenshots_ios.sh (iPhone 6,5″ /
/// iPad 13″).
/// ///
/// Kein Netzwerk: der Demo-Modus beantwortet allen Backend-Verkehr aus /// Kein Netzwerk: der Demo-Modus beantwortet allen Backend-Verkehr aus
/// Fixtures, daher ist der Lauf deterministisch. /// Fixtures, daher ist der Lauf deterministisch.
@@ -42,11 +43,13 @@ Future<void> main() async {
_log('Login'); _log('Login');
await _login(tester); 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'); _log('warte auf App-Shell');
await _pumpUntil( await _pumpUntil(
tester, tester,
find.byType(App), find.byType(App),
timeout: const Duration(seconds: 20), timeout: const Duration(seconds: 60),
); );
_log('warte auf Post-Login-Splash-Ende'); _log('warte auf Post-Login-Splash-Ende');
await _pumpUntilGone( await _pumpUntilGone(
@@ -64,6 +67,12 @@ Future<void> main() async {
await binding.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. // 1 + 2: Stundenplan in beiden Themes.
await _goTo(tester, Modules.timetable); await _goTo(tester, Modules.timetable);
_setTheme(tester, ThemeMode.light); _setTheme(tester, ThemeMode.light);
@@ -129,6 +138,16 @@ void _setTheme(WidgetTester tester, ThemeMode mode) {
context.read<SettingsCubit>().val(write: true).appTheme = mode; 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, /// 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. /// sonst als Vollbild-Push. Größenunabhängig, da die Tab-Anzahl je Gerät variiert.
Future<void> _goTo(WidgetTester tester, Modules module) async { Future<void> _goTo(WidgetTester tester, Modules module) async {
+2 -8
View File
@@ -12,8 +12,6 @@ PODS:
- PhoneNumberKit/PhoneNumberKitCore (3.7.11) - PhoneNumberKit/PhoneNumberKitCore (3.7.11)
- PhoneNumberKit/UIKit (3.7.11): - PhoneNumberKit/UIKit (3.7.11):
- PhoneNumberKit/PhoneNumberKitCore - PhoneNumberKit/PhoneNumberKitCore
- workmanager_apple (0.0.1):
- Flutter
DEPENDENCIES: DEPENDENCIES:
- eraser (from `.symlinks/plugins/eraser/ios`) - eraser (from `.symlinks/plugins/eraser/ios`)
@@ -21,7 +19,6 @@ DEPENDENCIES:
- flutter_app_badge (from `.symlinks/plugins/flutter_app_badge/ios`) - flutter_app_badge (from `.symlinks/plugins/flutter_app_badge/ios`)
- open_filex (from `.symlinks/plugins/open_filex/ios`) - open_filex (from `.symlinks/plugins/open_filex/ios`)
- PhoneNumberKit (~> 3.7.6) - PhoneNumberKit (~> 3.7.6)
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)
SPEC REPOS: SPEC REPOS:
trunk: trunk:
@@ -36,17 +33,14 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_app_badge/ios" :path: ".symlinks/plugins/flutter_app_badge/ios"
open_filex: open_filex:
:path: ".symlinks/plugins/open_filex/ios" :path: ".symlinks/plugins/open_filex/ios"
workmanager_apple:
:path: ".symlinks/plugins/workmanager_apple/ios"
SPEC CHECKSUMS: SPEC CHECKSUMS:
eraser: 83a4b06985f3702aa3d8dec816f9693266012937 eraser: 83a4b06985f3702aa3d8dec816f9693266012937
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
flutter_app_badge: ca742dd659a157c1090ef7cd881cb78f48f3bcdf flutter_app_badge: ca742dd659a157c1090ef7cd881cb78f48f3bcdf
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1 open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
PhoneNumberKit: 9ff0c5ae9fe4770193b68a3d3e6c938fe976788c PhoneNumberKit: 9ff0c5ae9fe4770193b68a3d3e6c938fe976788c
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
PODFILE CHECKSUM: 087d168982f24fb137e2d46f893b771b4b9955c6 PODFILE CHECKSUM: 087d168982f24fb137e2d46f893b771b4b9955c6
COCOAPODS: 1.16.2 COCOAPODS: 1.17.0
+5 -6
View File
@@ -3,7 +3,7 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 54; objectVersion = 70;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
@@ -77,19 +77,19 @@
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
12B96C78930441C73F123636 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = Info.plist; path = Info.plist; sourceTree = "<group>"; }; 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>"; }; 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; name = NotificationService.swift; path = NotificationService.swift; 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>"; }; 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; name = PEM.swift; path = PEM.swift; sourceTree = "<group>"; }; 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; name = NotificationServiceExtension.entitlements; path = NotificationServiceExtension.entitlements; 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>"; };
@@ -222,7 +222,6 @@
12B96C78930441C73F123636 /* Info.plist */, 12B96C78930441C73F123636 /* Info.plist */,
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */, 5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */,
); );
name = NotificationServiceExtension;
path = NotificationServiceExtension; path = NotificationServiceExtension;
sourceTree = SOURCE_ROOT; sourceTree = SOURCE_ROOT;
}; };
@@ -14,8 +14,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/app-check.git", "location" : "https://github.com/google/app-check.git",
"state" : { "state" : {
"revision" : "3e33dd27dd4c69bd81c7c81fe61d8ccf58846902", "revision" : "97f7d74dd0e8f3d0fe5fc75cc8957aa73bdc948a",
"version" : "11.3.1" "version" : "11.3.2"
} }
}, },
{ {
@@ -50,8 +50,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk", "location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : { "state" : {
"revision" : "42e81d245e30e49ea6a5830cf2842d44a1591270", "revision" : "27eaab3918e0bf78711cf1abf240577176326432",
"version" : "12.15.0" "version" : "12.19.0"
} }
}, },
{ {
@@ -59,8 +59,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
"state" : { "state" : {
"revision" : "dc39082d8881109d35b94b1c122164c0e8d08a55", "revision" : "0208e2681ec81f8a9c81084696f60fcce26cb7ee",
"version" : "3.6.1" "version" : "3.7.0"
} }
}, },
{ {
@@ -68,8 +68,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git", "location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : { "state" : {
"revision" : "144855f40d8668927f256a3045f7fdc4c3f4338b", "revision" : "b8455dd2ff55cc269fda7a4f9eac6c1e3588c53f",
"version" : "12.15.0" "version" : "12.19.2"
} }
}, },
{ {
@@ -77,8 +77,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleDataTransport.git", "location" : "https://github.com/google/GoogleDataTransport.git",
"state" : { "state" : {
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9", "revision" : "ba3358d3c3dbae8ef230b58a46b97ad65e84e974",
"version" : "10.1.0" "version" : "10.1.1"
} }
}, },
{ {
@@ -86,8 +86,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git", "location" : "https://github.com/google/GoogleUtilities.git",
"state" : { "state" : {
"revision" : "9f183ae842be978784f2963a343682e0c46d8fb3", "revision" : "92c8f6dc3ac375d6febdfcb3db68bc3d10633db3",
"version" : "8.1.2" "version" : "8.1.3"
} }
}, },
{ {
@@ -104,8 +104,8 @@
"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" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a", "revision" : "724a52eea6329b7e12d3ad8300d76ca9f3895fcc",
"version" : "5.3.0" "version" : "5.3.1"
} }
}, },
{ {
@@ -50,8 +50,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk", "location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : { "state" : {
"revision" : "42e81d245e30e49ea6a5830cf2842d44a1591270", "revision" : "27eaab3918e0bf78711cf1abf240577176326432",
"version" : "12.15.0" "version" : "12.19.0"
} }
}, },
{ {
@@ -59,8 +59,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
"state" : { "state" : {
"revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64", "revision" : "0208e2681ec81f8a9c81084696f60fcce26cb7ee",
"version" : "3.6.0" "version" : "3.7.0"
} }
}, },
{ {
@@ -68,8 +68,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git", "location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : { "state" : {
"revision" : "144855f40d8668927f256a3045f7fdc4c3f4338b", "revision" : "b8455dd2ff55cc269fda7a4f9eac6c1e3588c53f",
"version" : "12.15.0" "version" : "12.19.2"
} }
}, },
{ {
@@ -86,8 +86,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git", "location" : "https://github.com/google/GoogleUtilities.git",
"state" : { "state" : {
"revision" : "c46e5f8b7c23265f17c24ca7f9fa1b13ded7a822", "revision" : "92c8f6dc3ac375d6febdfcb3db68bc3d10633db3",
"version" : "8.1.1" "version" : "8.1.3"
} }
}, },
{ {
+86 -86
View File
@@ -1,94 +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>BGTaskSchedulerPermittedIdentifiers</key> <string>$(FLUTTER_BUILD_NUMBER)</string>
<array> <key>LSRequiresIPhoneOS</key>
<string>eu.mhsl.marianum.widget.refresh</string> <true/>
</array> <key>NSCameraUsageDescription</key>
<key>UIBackgroundModes</key> <string>Um Fotos direkt aus der App aufnehmen und teilen zu können wird Zugriff auf die Kamera benötigt.</string>
<array> <key>NSPhotoLibraryUsageDescription</key>
<string>fetch</string> <string>Um Medien mit anderen zu teilen wird Zugriff zu deine Dateien benötigt.</string>
<string>remote-notification</string> <key>UIApplicationSceneManifest</key>
</array> <dict>
<key>UILaunchStoryboardName</key> <key>UIApplicationSupportsMultipleScenes</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/> <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>
+90 -17
View File
@@ -107,20 +107,58 @@ final class ShareViewController: UIViewController {
(UTType.url.identifier, .url), (UTType.url.identifier, .url),
(UTType.data.identifier, .file), (UTType.data.identifier, .file),
] ]
for (typeId, kind) in order { for (typeId, kind) in order where provider.hasItemConformingToTypeIdentifier(typeId) {
if provider.hasItemConformingToTypeIdentifier(typeId) { // Konkreter Typ (z. B. public.png statt public.image) für Dateiendung
provider.loadItem(forTypeIdentifier: typeId) { [weak self] data, error in // und MIME-Type, falls die Quelle nur rohe Daten liefert.
guard let self else { completion(nil); return } let concrete = concreteType(of: provider, conformingTo: typeId)
if error != nil { completion(nil); return } provider.loadItem(forTypeIdentifier: typeId) { [weak self] data, error in
completion(self.toSharedFile(data: data, kind: kind)) guard let self else { completion(nil); return }
if error == nil,
let file = self.toSharedFile(data: data, kind: kind,
concrete: concrete, provider: provider) {
completion(file)
return
} }
return // Manche Quellen (z. B. der Screenshot-/Markup-Editor) liefern über
// loadItem nichts Verwertbares oder eine URL außerhalb unserer
// Sandbox. loadFileRepresentation stellt eine lesbare Kopie bereit.
self.loadFileFallback(provider: provider, kind: kind,
concrete: concrete, completion: completion)
} }
return
} }
completion(nil) completion(nil)
} }
private func toSharedFile(data: Any?, kind: SharedMediaType) -> SharedMediaFile? { private func concreteType(of provider: NSItemProvider, conformingTo typeId: String) -> UTType? {
guard let base = UTType(typeId) else { return nil }
return provider.registeredTypeIdentifiers
.compactMap { UTType($0) }
.first { $0.conforms(to: base) } ?? base
}
private func loadFileFallback(provider: NSItemProvider, kind: SharedMediaType,
concrete: UTType?,
completion: @escaping (SharedMediaFile?) -> Void) {
guard kind == .image || kind == .video || kind == .file, let concrete else {
completion(nil)
return
}
provider.loadFileRepresentation(forTypeIdentifier: concrete.identifier) { [weak self] url, _ in
// Die URL ist nur innerhalb dieses Callbacks gültig — sofort kopieren.
guard let self, let url, let dst = self.copyIntoAppGroup(src: url) else {
completion(nil)
return
}
let mime = self.mime(for: url) ?? concrete.preferredMIMEType
let duration = kind == .video ? self.videoDurationMs(url: dst) : nil
completion(SharedMediaFile(path: self.pathString(for: dst), mimeType: mime,
thumbnail: nil, duration: duration, message: nil, type: kind))
}
}
private func toSharedFile(data: Any?, kind: SharedMediaType, concrete: UTType?,
provider: NSItemProvider) -> SharedMediaFile? {
switch kind { switch kind {
case .text: case .text:
guard let s = data as? String else { return nil } guard let s = data as? String else { return nil }
@@ -139,6 +177,10 @@ final class ShareViewController: UIViewController {
return SharedMediaFile(path: pathString(for: dst), mimeType: "image/png", return SharedMediaFile(path: pathString(for: dst), mimeType: "image/png",
thumbnail: nil, duration: nil, message: nil, type: .image) thumbnail: nil, duration: nil, message: nil, type: .image)
} }
if let d = data as? Data, let dst = writeData(d, type: concrete, provider: provider) {
return SharedMediaFile(path: pathString(for: dst), mimeType: concrete?.preferredMIMEType,
thumbnail: nil, duration: nil, message: nil, type: .image)
}
return nil return nil
case .video: case .video:
guard let u = data as? URL, let dst = copyIntoAppGroup(src: u) else { return nil } guard let u = data as? URL, let dst = copyIntoAppGroup(src: u) else { return nil }
@@ -146,9 +188,15 @@ final class ShareViewController: UIViewController {
thumbnail: nil, duration: videoDurationMs(url: u), thumbnail: nil, duration: videoDurationMs(url: u),
message: nil, type: .video) message: nil, type: .video)
case .file: case .file:
guard let u = data as? URL, let dst = copyIntoAppGroup(src: u) else { return nil } if let u = data as? URL, let dst = copyIntoAppGroup(src: u) {
return SharedMediaFile(path: pathString(for: dst), mimeType: mime(for: u), return SharedMediaFile(path: pathString(for: dst), mimeType: mime(for: u),
thumbnail: nil, duration: nil, message: nil, type: .file) thumbnail: nil, duration: nil, message: nil, type: .file)
}
if let d = data as? Data, let dst = writeData(d, type: concrete, provider: provider) {
return SharedMediaFile(path: pathString(for: dst), mimeType: concrete?.preferredMIMEType,
thumbnail: nil, duration: nil, message: nil, type: .file)
}
return nil
} }
} }
@@ -192,13 +240,11 @@ final class ShareViewController: UIViewController {
} }
private func copyIntoShareDir(src: URL) -> URL? { private func copyIntoShareDir(src: URL) -> URL? {
guard let dir = shareDir else { return nil }
let name = src.lastPathComponent.isEmpty ? UUID().uuidString : src.lastPathComponent let name = src.lastPathComponent.isEmpty ? UUID().uuidString : src.lastPathComponent
var dst = dir.appendingPathComponent(name) guard let dst = uniqueDestination(name: name) else { return nil }
// Mehrfachauswahl mit gleichem Namen innerhalb eines Shares. // Security-scoped URLs (z. B. aus "Dateien") sind sonst nicht lesbar.
if FileManager.default.fileExists(atPath: dst.path) { let scoped = src.startAccessingSecurityScopedResource()
dst = dir.appendingPathComponent("\(UUID().uuidString.prefix(8))-\(name)") defer { if scoped { src.stopAccessingSecurityScopedResource() } }
}
do { do {
try FileManager.default.copyItem(at: src, to: dst) try FileManager.default.copyItem(at: src, to: dst)
return dst return dst
@@ -207,6 +253,33 @@ final class ShareViewController: UIViewController {
} }
} }
// Nur auf fileQueue aufrufen.
private func uniqueDestination(name: String) -> URL? {
guard let dir = shareDir else { return nil }
let dst = dir.appendingPathComponent(name)
// Mehrfachauswahl mit gleichem Namen innerhalb eines Shares.
if FileManager.default.fileExists(atPath: dst.path) {
return dir.appendingPathComponent("\(UUID().uuidString.prefix(8))-\(name)")
}
return dst
}
private func writeData(_ data: Data, type: UTType?, provider: NSItemProvider) -> URL? {
let ext = type?.preferredFilenameExtension ?? "bin"
var base = (provider.suggestedName ?? UUID().uuidString)
.replacingOccurrences(of: "/", with: "-")
if (base as NSString).pathExtension.lowercased() == ext.lowercased() {
base = (base as NSString).deletingPathExtension
}
// Namenswahl und Schreiben zusammen seriell, sonst können zwei Anhänge
// mit gleichem suggestedName denselben Pfad bekommen.
return fileQueue.sync {
guard let dst = uniqueDestination(name: "\(base).\(ext)"),
(try? data.write(to: dst)) != nil else { return nil }
return dst
}
}
private func writePng(image: UIImage) -> URL? { private func writePng(image: UIImage) -> URL? {
guard let data = image.pngData(), guard let data = image.pngData(),
let dir = fileQueue.sync(execute: { shareDir }) else { return nil } let dir = fileQueue.sync(execute: { shareDir }) else { return nil }
+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`).
Whitespace-only changes.
+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 [];
} }
+10 -4
View File
@@ -52,10 +52,16 @@ class CacheStore {
Future<Directory> _directory() { Future<Directory> _directory() {
final name = directoryName(SessionManager().activeAccount?.namespace); final name = directoryName(SessionManager().activeAccount?.namespace);
return _dirs[name] ??= () async { return _dirs[name] ??= () async {
final base = await getApplicationCacheDirectory(); try {
final dir = Directory('${base.path}/$name'); final base = await getApplicationCacheDirectory();
await dir.create(recursive: true); final dir = Directory('${base.path}/$name');
return dir; await dir.create(recursive: true);
return dir;
} on Object {
// Not memoized (disk full at launch): the next call tries again.
unawaited(_dirs.remove(name));
rethrow;
}
}(); }();
} }
+28
View File
@@ -0,0 +1,28 @@
import 'package:http/http.dart' as http;
import 'package:http/io_client.dart';
import '../../model/endpoint_data.dart';
import '../errors/network_exception.dart';
import 'demo_mode.dart';
/// Demo sessions carry made-up credentials. Every request they sent to the
/// real Nextcloud would count as a failed login there and, in bulk, trip the
/// server's brute-force throttle for the whole IP (HTTP 429 for everyone
/// behind it). Installed app-wide via [http.runWithClient].
class DemoHttpClient extends http.BaseClient {
final http.Client _inner = IOClient();
@override
Future<http.StreamedResponse> send(http.BaseRequest request) {
if (DemoMode.active && isNextcloudHost(request.url)) {
return Future.error(NetworkException.demoBlocked(request.url));
}
return _inner.send(request);
}
@override
void close() => _inner.close();
static bool isNextcloudHost(Uri url) =>
url.host == EndpointData().nextcloud().domain;
}
+2
View File
@@ -32,6 +32,8 @@ class DemoMarianumConnect {
return DemoTimetable.timegrid().result.map((e) => e.toJson()).toList(); return DemoTimetable.timegrid().result.map((e) => e.toJson()).toList();
case 'timetable/holidays': case 'timetable/holidays':
return DemoTimetable.holidays().result.map((e) => e.toJson()).toList(); return DemoTimetable.holidays().result.map((e) => e.toJson()).toList();
case 'timetable/custom-events':
return DemoTimetable.customEvents().toJson();
case 'holidays': case 'holidays':
return DemoHolidays.upcoming().map((e) => e.toJson()).toList(); return DemoHolidays.upcoming().map((e) => e.toJson()).toList();
case 'users/search': case 'users/search':
+58 -18
View File
@@ -10,9 +10,7 @@ import '../api_error.dart';
import '../http_errors.dart'; import '../http_errors.dart';
import '../marianumcloud/talk/talk_error.dart'; import '../marianumcloud/talk/talk_error.dart';
import 'app_exception.dart'; import 'app_exception.dart';
import 'auth_exception.dart';
import 'network_exception.dart'; import 'network_exception.dart';
import 'not_found_exception.dart';
import 'parse_exception.dart'; import 'parse_exception.dart';
import 'server_exception.dart'; import 'server_exception.dart';
import 'talk_exception.dart'; import 'talk_exception.dart';
@@ -56,32 +54,74 @@ AppException? _dioToAppException(DioException error) {
} }
} }
final RegExp _davException = RegExp(
r'<s:exception>\s*([^<]*?)\s*</s:exception>',
);
final RegExp _davMessage = RegExp(r'<s:message>\s*([^<]*?)\s*</s:message>');
/// The Sabre DAV error body of a failed WebDAV request: the exception class
/// (without its `Sabre\DAV\Exception\` namespace) and the server's message.
/// The class says what actually went wrong where the status is ambiguous —
/// MKCOL on an existing folder is a plain 405.
({String exception, String message})? parseDavError(String body) {
final exception = _davException.firstMatch(body)?.group(1);
if (exception == null || exception.isEmpty) return null;
return (
exception: exception.split('\\').last,
message: _davMessage.firstMatch(body)?.group(1) ?? '',
);
}
AppException? _davToAppException(
int status,
({String exception, String message}) dav,
String detail,
) {
final userMessage = switch (dav.exception) {
'MethodNotAllowed' when status == 405 =>
'Ein Ordner oder eine Datei mit diesem Namen existiert bereits.',
'PreconditionFailed' =>
'Am Zielort existiert bereits ein Eintrag mit diesem Namen.',
'Conflict' => 'Der übergeordnete Ordner existiert nicht mehr.',
'Locked' =>
'Der Eintrag wird gerade bearbeitet. Bitte versuche es gleich erneut.',
'InsufficientStorage' =>
'Der verfügbare Speicherplatz reicht dafür nicht aus.',
_ => null,
};
if (userMessage == null) return null;
return ServerException(
statusCode: status,
userMessage: userMessage,
technicalDetails: detail,
allowRetry: dav.exception == 'Locked',
);
}
/// The nextcloud/dynamite clients throw [DynamiteApiException] on non-2xx. /// The nextcloud/dynamite clients throw [DynamiteApiException] on non-2xx.
/// Its toString() dumps every response header, so the details keep only the /// Its toString() dumps every response header, so the details keep only the
/// status plus a trimmed body preview (same format as the Talk API errors). /// status plus a trimmed body preview (same format as the Talk API errors).
AppException _dynamiteToAppException(DynamiteApiException error) { AppException _dynamiteToAppException(DynamiteApiException error) {
final status = error.statusCode; final status = error.statusCode;
final dav = parseDavError(error.body);
if (dav != null) {
final detail = 'HTTP $status ${dav.exception}: ${dav.message}';
return _davToAppException(status, dav, detail) ??
exceptionForStatus(status, detail);
}
final preview = previewBody(error.body); final preview = previewBody(error.body);
final detail = preview.isEmpty final detail = preview.isEmpty
? 'HTTP $status' ? 'HTTP $status'
: 'HTTP $status body=$preview'; : 'HTTP $status body=$preview';
switch (status) { if (status == 429) {
case 401: return ServerException(
return AuthException.unauthorized(technicalDetails: detail); statusCode: status,
case 403: userMessage:
return AuthException.forbidden(technicalDetails: detail); 'Zu viele Anfragen. Bitte warte einen Moment, bevor du es erneut versuchst.',
case 404: technicalDetails: detail,
return NotFoundException(technicalDetails: detail); );
case 429:
return ServerException(
statusCode: status,
userMessage:
'Zu viele Anfragen. Bitte warte einen Moment, bevor du es erneut versuchst.',
technicalDetails: detail,
);
default:
return ServerException(statusCode: status, technicalDetails: detail);
} }
return exceptionForStatus(status, detail);
} }
String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) { String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
+6
View File
@@ -13,4 +13,10 @@ class NetworkException extends AppException {
'Der Server hat zu lange gebraucht. Bitte versuche es erneut.', 'Der Server hat zu lange gebraucht. Bitte versuche es erneut.',
technicalDetails: technicalDetails, technicalDetails: technicalDetails,
); );
/// A request a demo session must never send (see `DemoHttpClient`).
factory NetworkException.demoBlocked(Uri url) => NetworkException(
userMessage: 'Im Demo-Modus nicht verfügbar.',
technicalDetails: 'demo mode blocked $url',
);
} }
+1 -1
View File
@@ -7,6 +7,7 @@ class ServerException extends AppException {
required this.statusCode, required this.statusCode,
String? userMessage, String? userMessage,
super.technicalDetails, super.technicalDetails,
super.allowRetry,
}) : super( }) : super(
// Status code intentionally not in the user message — it lives in // Status code intentionally not in the user message — it lives in
// technicalDetails behind "Details anzeigen". // technicalDetails behind "Details anzeigen".
@@ -14,6 +15,5 @@ class ServerException extends AppException {
userMessage ?? userMessage ??
'Der Server konnte die Anfrage gerade nicht verarbeiten. ' 'Der Server konnte die Anfrage gerade nicht verarbeiten. '
'Bitte versuche es in einem Moment erneut.', 'Bitte versuche es in einem Moment erneut.',
allowRetry: true,
); );
} }
+20 -8
View File
@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'errors/app_exception.dart';
import 'errors/auth_exception.dart'; import 'errors/auth_exception.dart';
import 'errors/network_exception.dart'; import 'errors/network_exception.dart';
import 'errors/not_found_exception.dart'; import 'errors/not_found_exception.dart';
@@ -42,12 +43,23 @@ String httpErrorDetail(String label, String body, int status) {
: '$label -> HTTP $status body=$preview'; : '$label -> HTTP $status body=$preview';
} }
/// Throws the [AppException] matching a non-2xx HTTP [status], carrying /// The [AppException] matching a non-2xx HTTP [status], carrying [detail] as
/// [detail] as technical details: 401/403 map to auth errors, 404 to /// technical details: 401/403 map to auth errors, 404 to not-found, everything
/// not-found, everything else to a generic server error. /// else to a generic server error.
Never throwForStatus(int status, String detail) { AppException exceptionForStatus(int status, String detail) => switch (status) {
if (status == 401) throw AuthException.unauthorized(technicalDetails: detail); 401 => AuthException.unauthorized(technicalDetails: detail),
if (status == 403) throw AuthException.forbidden(technicalDetails: detail); 403 => AuthException.forbidden(technicalDetails: detail),
if (status == 404) throw NotFoundException(technicalDetails: detail); 404 => NotFoundException(technicalDetails: detail),
throw ServerException(statusCode: status, technicalDetails: detail); _ => ServerException(statusCode: status, technicalDetails: detail),
};
/// Throws [exceptionForStatus].
Never throwForStatus(int status, String detail) =>
throw exceptionForStatus(status, detail);
/// Throws [exceptionForStatus] unless [response] is 2xx.
void ensureSuccess(String label, http.Response response) {
final status = response.statusCode;
if (status >= 200 && status < 300) return;
throwForStatus(status, httpErrorDetail(label, response.body, status));
} }
+46
View File
@@ -0,0 +1,46 @@
import 'package:json_annotation/json_annotation.dart';
/// Decodes server lists element by element so one entry the model cannot map
/// (a field the server dropped, an enum value it added) is skipped and
/// recorded instead of failing the whole response and with it the screen.
///
/// Skipped entries are collected per isolate; the API layer drains them with
/// [drainDecodeWarnings] and reports them, so a drift in the server's format
/// still reaches us without taking the feature down for the user.
final List<String> _warnings = [];
List<T> decodeListLenient<T>(
Object? raw,
T Function(Map<String, dynamic>) fromJson, {
required String what,
}) {
final items = <T>[];
if (raw is! List<dynamic>) {
throw FormatException(
'expected a list of ${what}s, got ${raw.runtimeType}',
);
}
for (final entry in raw) {
try {
items.add(fromJson(entry as Map<String, dynamic>));
} on CheckedFromJsonException catch (e) {
_warnings.add('$what: $e');
} on TypeError catch (e) {
_warnings.add('$what: $e');
}
}
return items;
}
Set<T> decodeSetLenient<T>(
Object? raw,
T Function(Map<String, dynamic>) fromJson, {
required String what,
}) => decodeListLenient(raw, fromJson, what: what).toSet();
/// Returns and clears the entries skipped since the last call.
List<String> drainDecodeWarnings() {
final out = List<String>.of(_warnings);
_warnings.clear();
return out;
}
@@ -18,13 +18,7 @@ class DeleteAppPassword {
NextcloudOcs.uri('core/apppassword'), NextcloudOcs.uri('core/apppassword'),
// An explicit header may belong to an inactive account, so the active // An explicit header may belong to an inactive account, so the active
// session's headers must not be required then. // session's headers must not be required then.
headers: authorizationHeader == null headers: NextcloudOcs.headers(authorization: authorizationHeader),
? NextcloudOcs.headers()
: {
'Accept': 'application/json',
'OCS-APIRequest': 'true',
'Authorization': authorizationHeader,
},
); );
} }
} }
@@ -28,23 +28,17 @@ class GetAppPassword {
label, label,
() => _client.get( () => _client.get(
NextcloudOcs.uri('core/getapppassword'), NextcloudOcs.uri('core/getapppassword'),
headers: { // Deliberately NOT the shared Authorization value: that one prefers
...NextcloudOcs.headers(), // the app password, but an app password cannot mint another one —
// Deliberately NOT the shared Authorization value: that one prefers // this endpoint requires the real password.
// the app password, but an app password cannot mint another one — headers: NextcloudOcs.headers(
// this endpoint requires the real password. authorization: SessionManager()
'Authorization': SessionManager()
.requireNextcloud() .requireNextcloud()
.realPasswordBasicAuthHeader, .realPasswordBasicAuthHeader,
}, ),
), ),
))!; ))!;
if (response.statusCode < 200 || response.statusCode >= 300) { ensureSuccess(label, response);
throwForStatus(
response.statusCode,
httpErrorDetail(label, response.body, response.statusCode),
);
}
final json = jsonDecode(utf8.decode(response.bodyBytes)); final json = jsonDecode(utf8.decode(response.bodyBytes));
final data = (json as Map)['ocs']?['data']; final data = (json as Map)['ocs']?['data'];
final appPassword = data is Map ? data['apppassword'] as String? : null; final appPassword = data is Map ? data['apppassword'] as String? : null;
@@ -5,6 +5,8 @@ import 'package:http/http.dart' as http;
import '../../../model/endpoint_data.dart'; import '../../../model/endpoint_data.dart';
import '../../errors/server_exception.dart'; import '../../errors/server_exception.dart';
import '../../http_errors.dart'; import '../../http_errors.dart';
import '../../json_lenient.dart';
import '../../marianumconnect/queries/report_client_error/client_error_reporter.dart';
import '../nextcloud_ocs.dart'; import '../nextcloud_ocs.dart';
import 'autocomplete_response.dart'; import 'autocomplete_response.dart';
@@ -43,6 +45,13 @@ class AutocompleteApi {
technicalDetails: 'core/autocomplete/get: ${response.body}', technicalDetails: 'core/autocomplete/get: ${response.body}',
); );
} }
return AutocompleteResponse.fromJson(NextcloudOcs.decode(response.body)); final result = AutocompleteResponse.fromJson(
NextcloudOcs.decode(response.body),
);
ClientErrorReporter.reportDecodeWarnings(
'Autocomplete',
drainDecodeWarnings(),
);
return result;
} }
} }
@@ -1,5 +1,6 @@
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import '../../json_lenient.dart';
import '../files_sharing/queries/share/share.dart'; import '../files_sharing/queries/share/share.dart';
part 'autocomplete_response.g.dart'; part 'autocomplete_response.g.dart';
@@ -14,6 +15,7 @@ int shareTypeFromSource(String? source) {
@JsonSerializable(explicitToJson: true) @JsonSerializable(explicitToJson: true)
class AutocompleteResponse { class AutocompleteResponse {
@JsonKey(fromJson: _entriesFromJson)
List<AutocompleteResponseObject> data; List<AutocompleteResponseObject> data;
AutocompleteResponse(this.data); AutocompleteResponse(this.data);
@@ -23,15 +25,31 @@ class AutocompleteResponse {
Map<String, dynamic> toJson() => _$AutocompleteResponseToJson(this); Map<String, dynamic> toJson() => _$AutocompleteResponseToJson(this);
} }
List<AutocompleteResponseObject> _entriesFromJson(Object? raw) =>
decodeListLenient(
raw,
AutocompleteResponseObject.fromJson,
what: 'autocomplete entry',
);
/// The server sends a user-status object for users that have one and an empty
/// string otherwise; only the status string itself is kept.
String? _statusFromJson(Object? raw) => switch (raw) {
Map<String, dynamic> status => status['status']?.toString(),
String status when status.isNotEmpty => status,
_ => null,
};
@JsonSerializable() @JsonSerializable()
class AutocompleteResponseObject { class AutocompleteResponseObject {
String id; String id;
String label; String label;
String? icon; String? icon;
String? source; String? source;
@JsonKey(fromJson: _statusFromJson)
String? status; String? status;
String? subline; String? subline;
String? shareWithDisplayNameUniqe; String? shareWithDisplayNameUnique;
AutocompleteResponseObject( AutocompleteResponseObject(
this.id, this.id,
@@ -40,7 +58,7 @@ class AutocompleteResponseObject {
this.source, this.source,
this.status, this.status,
this.subline, this.subline,
this.shareWithDisplayNameUniqe, this.shareWithDisplayNameUnique,
); );
factory AutocompleteResponseObject.fromJson(Map<String, dynamic> json) => factory AutocompleteResponseObject.fromJson(Map<String, dynamic> json) =>
@@ -8,13 +8,12 @@ part of 'autocomplete_response.dart';
AutocompleteResponse _$AutocompleteResponseFromJson( AutocompleteResponse _$AutocompleteResponseFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => AutocompleteResponse( ) => $checkedCreate('AutocompleteResponse', json, ($checkedConvert) {
(json['data'] as List<dynamic>) final val = AutocompleteResponse(
.map( $checkedConvert('data', (v) => _entriesFromJson(v)),
(e) => AutocompleteResponseObject.fromJson(e as Map<String, dynamic>), );
) return val;
.toList(), });
);
Map<String, dynamic> _$AutocompleteResponseToJson( Map<String, dynamic> _$AutocompleteResponseToJson(
AutocompleteResponse instance, AutocompleteResponse instance,
@@ -22,15 +21,18 @@ Map<String, dynamic> _$AutocompleteResponseToJson(
AutocompleteResponseObject _$AutocompleteResponseObjectFromJson( AutocompleteResponseObject _$AutocompleteResponseObjectFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => AutocompleteResponseObject( ) => $checkedCreate('AutocompleteResponseObject', json, ($checkedConvert) {
json['id'] as String, final val = AutocompleteResponseObject(
json['label'] as String, $checkedConvert('id', (v) => v as String),
json['icon'] as String?, $checkedConvert('label', (v) => v as String),
json['source'] as String?, $checkedConvert('icon', (v) => v as String?),
json['status'] as String?, $checkedConvert('source', (v) => v as String?),
json['subline'] as String?, $checkedConvert('status', (v) => _statusFromJson(v)),
json['shareWithDisplayNameUniqe'] as String?, $checkedConvert('subline', (v) => v as String?),
); $checkedConvert('shareWithDisplayNameUnique', (v) => v as String?),
);
return val;
});
Map<String, dynamic> _$AutocompleteResponseObjectToJson( Map<String, dynamic> _$AutocompleteResponseObjectToJson(
AutocompleteResponseObject instance, AutocompleteResponseObject instance,
@@ -41,5 +43,5 @@ Map<String, dynamic> _$AutocompleteResponseObjectToJson(
'source': instance.source, 'source': instance.source,
'status': instance.status, 'status': instance.status,
'subline': instance.subline, 'subline': instance.subline,
'shareWithDisplayNameUniqe': instance.shareWithDisplayNameUniqe, 'shareWithDisplayNameUnique': instance.shareWithDisplayNameUnique,
}; };
@@ -1,4 +1,3 @@
import 'dart:convert';
import 'dart:developer'; import 'dart:developer';
import 'dart:typed_data'; import 'dart:typed_data';
@@ -96,9 +95,8 @@ class GetUserInfo {
uri, uri,
); );
try { try {
final root = jsonDecode(response.body) as Map<String, dynamic>;
final data = final data =
(root['ocs'] as Map<String, dynamic>)['data'] as Map<String, dynamic>; NextcloudOcs.decode(response.body)['data'] as Map<String, dynamic>;
return CloudUserInfo( return CloudUserInfo(
userId: data['id'] as String, userId: data['id'] as String,
displayName: (data['displayname'] as String?) ?? '', displayName: (data['displayname'] as String?) ?? '',
@@ -8,16 +8,19 @@ part of 'file_sharing_api_params.dart';
FileSharingApiParams _$FileSharingApiParamsFromJson( FileSharingApiParams _$FileSharingApiParamsFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => FileSharingApiParams( ) => $checkedCreate('FileSharingApiParams', json, ($checkedConvert) {
shareType: (json['shareType'] as num).toInt(), final val = FileSharingApiParams(
shareWith: json['shareWith'] as String, shareType: $checkedConvert('shareType', (v) => (v as num).toInt()),
path: json['path'] as String, shareWith: $checkedConvert('shareWith', (v) => v as String),
referenceId: json['referenceId'] as String?, path: $checkedConvert('path', (v) => v as String),
talkMetaData: json['talkMetaData'] as String?, referenceId: $checkedConvert('referenceId', (v) => v as String?),
permissions: (json['permissions'] as num?)?.toInt(), talkMetaData: $checkedConvert('talkMetaData', (v) => v as String?),
password: json['password'] as String?, permissions: $checkedConvert('permissions', (v) => (v as num?)?.toInt()),
expireDate: json['expireDate'] as String?, password: $checkedConvert('password', (v) => v as String?),
); expireDate: $checkedConvert('expireDate', (v) => v as String?),
);
return val;
});
Map<String, dynamic> _$FileSharingApiParamsToJson( Map<String, dynamic> _$FileSharingApiParamsToJson(
FileSharingApiParams instance, FileSharingApiParams instance,
@@ -29,12 +29,7 @@ class LoginFlowApi {
headers: {'Accept': 'application/json', 'User-Agent': userAgent}, headers: {'Accept': 'application/json', 'User-Agent': userAgent},
), ),
))!; ))!;
if (response.statusCode < 200 || response.statusCode >= 300) { ensureSuccess(label, response);
throwForStatus(
response.statusCode,
httpErrorDetail(label, response.body, response.statusCode),
);
}
return LoginFlowInit.fromJson( return LoginFlowInit.fromJson(
jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>, jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>,
); );
@@ -53,12 +48,7 @@ class LoginFlowApi {
), ),
))!; ))!;
if (response.statusCode == 404) return null; if (response.statusCode == 404) return null;
if (response.statusCode < 200 || response.statusCode >= 300) { ensureSuccess(label, response);
throwForStatus(
response.statusCode,
httpErrorDetail(label, response.body, response.statusCode),
);
}
return LoginFlowCredentials.fromJson( return LoginFlowCredentials.fromJson(
jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>, jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>,
); );
+5 -2
View File
@@ -13,10 +13,13 @@ class NextcloudOcs {
static Map<String, dynamic> decode(String raw) => static Map<String, dynamic> decode(String raw) =>
(jsonDecode(raw) as Map<String, dynamic>)['ocs'] as Map<String, dynamic>; (jsonDecode(raw) as Map<String, dynamic>)['ocs'] as Map<String, dynamic>;
static Map<String, String> headers() => { /// [authorization] overrides the active session's credentials, which are
/// then not required.
static Map<String, String> headers({String? authorization}) => {
'Accept': 'application/json', 'Accept': 'application/json',
'OCS-APIRequest': 'true', 'OCS-APIRequest': 'true',
'Authorization': SessionManager().requireNextcloud().basicAuthHeader, 'Authorization':
authorization ?? SessionManager().requireNextcloud().basicAuthHeader,
}; };
static Uri uri(String pathSuffix, {Map<String, dynamic>? queryParameters}) { static Uri uri(String pathSuffix, {Map<String, dynamic>? queryParameters}) {
@@ -7,14 +7,20 @@ part of 'search_files_response.dart';
// ************************************************************************** // **************************************************************************
SearchFilesResponse _$SearchFilesResponseFromJson(Map<String, dynamic> json) => SearchFilesResponse _$SearchFilesResponseFromJson(Map<String, dynamic> json) =>
SearchFilesResponse( $checkedCreate('SearchFilesResponse', json, ($checkedConvert) {
name: json['name'] as String, final val = SearchFilesResponse(
isPaginated: json['isPaginated'] as bool, name: $checkedConvert('name', (v) => v as String),
cursor: (json['cursor'] as num?)?.toInt(), isPaginated: $checkedConvert('isPaginated', (v) => v as bool),
entries: (json['entries'] as List<dynamic>) cursor: $checkedConvert('cursor', (v) => (v as num?)?.toInt()),
.map((e) => SearchFilesEntry.fromJson(e as Map<String, dynamic>)) entries: $checkedConvert(
.toList(), 'entries',
); (v) => (v as List<dynamic>)
.map((e) => SearchFilesEntry.fromJson(e as Map<String, dynamic>))
.toList(),
),
);
return val;
});
Map<String, dynamic> _$SearchFilesResponseToJson( Map<String, dynamic> _$SearchFilesResponseToJson(
SearchFilesResponse instance, SearchFilesResponse instance,
@@ -26,13 +32,19 @@ Map<String, dynamic> _$SearchFilesResponseToJson(
}; };
SearchFilesEntry _$SearchFilesEntryFromJson(Map<String, dynamic> json) => SearchFilesEntry _$SearchFilesEntryFromJson(Map<String, dynamic> json) =>
SearchFilesEntry( $checkedCreate('SearchFilesEntry', json, ($checkedConvert) {
title: json['title'] as String, final val = SearchFilesEntry(
subline: json['subline'] as String?, title: $checkedConvert('title', (v) => v as String),
icon: json['icon'] as String?, subline: $checkedConvert('subline', (v) => v as String?),
resourceUrl: json['resourceUrl'] as String?, icon: $checkedConvert('icon', (v) => v as String?),
attributes: json['attributes'] as Map<String, dynamic>?, resourceUrl: $checkedConvert('resourceUrl', (v) => v as String?),
); attributes: $checkedConvert(
'attributes',
(v) => v as Map<String, dynamic>?,
),
);
return val;
});
Map<String, dynamic> _$SearchFilesEntryToJson(SearchFilesEntry instance) => Map<String, dynamic> _$SearchFilesEntryToJson(SearchFilesEntry instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -0,0 +1,46 @@
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../../../errors/server_exception.dart';
import '../../../http_errors.dart';
import '../../nextcloud_ocs.dart';
import 'get_chat_params.dart';
import 'get_chat_response.dart';
/// GETs a chat page outside [TalkApi], which treats non-2xx as errors: here
/// HTTP 304 (nothing new / no older messages) is a normal outcome and yields
/// `null`.
Future<GetChatResponse?> fetchChatPage({
required String label,
required String chatToken,
required GetChatParams params,
Duration? timeout,
bool parseOffIsolate = false,
}) async {
final uri = NextcloudOcs.uri(
'apps/spreed/api/v1/chat/$chatToken',
queryParameters: params.toJson(),
);
final headers = NextcloudOcs.headers();
final response = (await sendGuarded('$label $uri', () {
final request = http.get(uri, headers: headers);
return timeout == null ? request : request.timeout(timeout);
}))!;
final status = response.statusCode;
if (status == 304) return null;
if (status >= 200 && status < 300) {
final parsed = parseOffIsolate
? await compute(_parseChatResponse, response.body)
: _parseChatResponse(response.body);
return parsed..headers = response.headers;
}
throw ServerException(
statusCode: status,
technicalDetails: '$label $uri: HTTP $status',
);
}
GetChatResponse _parseChatResponse(String body) =>
GetChatResponse.fromJson(NextcloudOcs.decode(body));
@@ -1,6 +1,3 @@
import 'package:http/http.dart' as http;
import 'package:http/http.dart';
import '../../nextcloud_ocs.dart'; import '../../nextcloud_ocs.dart';
import '../talk_api.dart'; import '../talk_api.dart';
import 'get_chat_params.dart'; import 'get_chat_params.dart';
@@ -16,11 +13,4 @@ class GetChat extends TalkApi<GetChatResponse> {
@override @override
GetChatResponse assemble(String raw) => GetChatResponse assemble(String raw) =>
GetChatResponse.fromJson(NextcloudOcs.decode(raw)); GetChatResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<Response> request(
Uri uri,
Object? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
} }
@@ -1,17 +1,12 @@
import 'package:flutter/foundation.dart'; import 'fetch_chat_page.dart';
import 'package:http/http.dart' as http;
import '../../../errors/server_exception.dart';
import '../../../http_errors.dart';
import '../../nextcloud_ocs.dart';
import 'get_chat_params.dart'; import 'get_chat_params.dart';
import 'get_chat_response.dart'; import 'get_chat_response.dart';
/// Backwards-paging variant of GetChat (`lookIntoFuture=0` + `lastKnownMessageId`) /// Backwards-paging variant of GetChat (`lookIntoFuture=0` + `lastKnownMessageId`)
/// that fetches the page of messages *older* than a given id. Bypasses [TalkApi] /// that fetches the page of messages *older* than a given id. The server
/// because that layer treats non-2xx as errors, and the server answers HTTP 304 /// answers HTTP 304 when there are no older messages left — a normal "start of
/// when there are no older messages left — a normal "start of chat" outcome here. /// chat" outcome (see [fetchChatPage]). `setReadMarker=off` so paging into
/// `setReadMarker=off` so paging into history never moves the read cursor. /// history never moves the read cursor.
class GetChatHistory { class GetChatHistory {
final String chatToken; final String chatToken;
final int lastKnownMessageId; final int lastKnownMessageId;
@@ -24,39 +19,18 @@ class GetChatHistory {
}); });
/// Returns the older page, or `null` on HTTP 304 (no older messages). /// Returns the older page, or `null` on HTTP 304 (no older messages).
Future<GetChatResponse?> run() async { Future<GetChatResponse?> run() => fetchChatPage(
final params = GetChatParams( label: 'GetChatHistory',
chatToken: chatToken,
params: GetChatParams(
lookIntoFuture: GetChatParamsSwitch.off, lookIntoFuture: GetChatParamsSwitch.off,
lastKnownMessageId: lastKnownMessageId, lastKnownMessageId: lastKnownMessageId,
includeLastKnown: GetChatParamsSwitch.off, includeLastKnown: GetChatParamsSwitch.off,
setReadMarker: GetChatParamsSwitch.off, setReadMarker: GetChatParamsSwitch.off,
limit: limit, limit: limit,
); ),
final uri = NextcloudOcs.uri( // A page holds up to 200 messages and lands while the user is scrolling;
'apps/spreed/api/v1/chat/$chatToken', // decoding it on the UI isolate stalls the fling.
queryParameters: params.toJson(), parseOffIsolate: true,
); );
final headers = NextcloudOcs.headers();
final response = (await sendGuarded(
'GetChatHistory $uri',
() => http.get(uri, headers: headers),
))!;
final status = response.statusCode;
if (status == 304) return null;
if (status >= 200 && status < 300) {
// A page holds up to 200 messages and lands while the user is scrolling;
// decoding it on the UI isolate stalls the fling.
final parsed = await compute(_parseChatResponse, response.body);
return parsed..headers = response.headers;
}
throw ServerException(
statusCode: status,
technicalDetails: 'GetChatHistory $uri: HTTP $status',
);
}
} }
GetChatResponse _parseChatResponse(String body) =>
GetChatResponse.fromJson(NextcloudOcs.decode(body));
@@ -7,24 +7,33 @@ part of 'get_chat_params.dart';
// ************************************************************************** // **************************************************************************
GetChatParams _$GetChatParamsFromJson(Map<String, dynamic> json) => GetChatParams _$GetChatParamsFromJson(Map<String, dynamic> json) =>
GetChatParams( $checkedCreate('GetChatParams', json, ($checkedConvert) {
lookIntoFuture: $enumDecode( final val = GetChatParams(
_$GetChatParamsSwitchEnumMap, lookIntoFuture: $checkedConvert(
json['lookIntoFuture'], 'lookIntoFuture',
), (v) => $enumDecode(_$GetChatParamsSwitchEnumMap, v),
limit: (json['limit'] as num?)?.toInt(), ),
lastKnownMessageId: (json['lastKnownMessageId'] as num?)?.toInt(), limit: $checkedConvert('limit', (v) => (v as num?)?.toInt()),
lastCommonReadId: (json['lastCommonReadId'] as num?)?.toInt(), lastKnownMessageId: $checkedConvert(
timeout: (json['timeout'] as num?)?.toInt(), 'lastKnownMessageId',
setReadMarker: $enumDecodeNullable( (v) => (v as num?)?.toInt(),
_$GetChatParamsSwitchEnumMap, ),
json['setReadMarker'], lastCommonReadId: $checkedConvert(
), 'lastCommonReadId',
includeLastKnown: $enumDecodeNullable( (v) => (v as num?)?.toInt(),
_$GetChatParamsSwitchEnumMap, ),
json['includeLastKnown'], timeout: $checkedConvert('timeout', (v) => (v as num?)?.toInt()),
), setReadMarker: $checkedConvert(
); 'setReadMarker',
(v) => $enumDecodeNullable(_$GetChatParamsSwitchEnumMap, v),
),
includeLastKnown: $checkedConvert(
'includeLastKnown',
(v) => $enumDecodeNullable(_$GetChatParamsSwitchEnumMap, v),
),
);
return val;
});
Map<String, dynamic> _$GetChatParamsToJson( Map<String, dynamic> _$GetChatParamsToJson(
GetChatParams instance, GetChatParams instance,
@@ -2,12 +2,14 @@ import 'package:json_annotation/json_annotation.dart';
import '../../../../extensions/date_time.dart'; import '../../../../extensions/date_time.dart';
import '../../../api_response.dart'; import '../../../api_response.dart';
import '../../../json_lenient.dart';
import '../room/get_room_response.dart'; import '../room/get_room_response.dart';
part 'get_chat_response.g.dart'; part 'get_chat_response.g.dart';
@JsonSerializable(explicitToJson: true) @JsonSerializable(explicitToJson: true)
class GetChatResponse extends ApiResponse { class GetChatResponse extends ApiResponse {
@JsonKey(fromJson: _messagesFromJson)
Set<GetChatResponseObject> data; Set<GetChatResponseObject> data;
GetChatResponse(this.data); GetChatResponse(this.data);
@@ -23,15 +25,20 @@ class GetChatResponse extends ApiResponse {
} }
} }
Set<GetChatResponseObject> _messagesFromJson(Object? raw) =>
decodeSetLenient(raw, GetChatResponseObject.fromJson, what: 'message');
@JsonSerializable(explicitToJson: true) @JsonSerializable(explicitToJson: true)
class GetChatResponseObject { class GetChatResponseObject {
int id; int id;
String token; String token;
@JsonKey(unknownEnumValue: GetRoomResponseObjectMessageActorType.unknown)
GetRoomResponseObjectMessageActorType actorType; GetRoomResponseObjectMessageActorType actorType;
String actorId; String actorId;
String actorDisplayName; String actorDisplayName;
int timestamp; int timestamp;
String systemMessage; String systemMessage;
@JsonKey(unknownEnumValue: GetRoomResponseObjectMessageType.unknown)
GetRoomResponseObjectMessageType messageType; GetRoomResponseObjectMessageType messageType;
bool isReplyable; bool isReplyable;
String referenceId; String referenceId;
@@ -40,6 +47,11 @@ class GetChatResponseObject {
List<String>? reactionsSelf; List<String>? reactionsSelf;
@JsonKey(fromJson: _fromJson) @JsonKey(fromJson: _fromJson)
Map<String, RichObjectString>? messageParameters; Map<String, RichObjectString>? messageParameters;
/// The quoted message. When the original is no longer available (expired,
/// purged) the server only sends `{id, deleted: true}`; that becomes a
/// deleted-comment placeholder so the quote still renders.
@JsonKey(fromJson: _parentFromJson)
GetChatResponseObject? parent; GetChatResponseObject? parent;
/// Whether the server flagged this message to be rendered as Markdown /// Whether the server flagged this message to be rendered as Markdown
@@ -70,10 +82,33 @@ class GetChatResponseObject {
_$GetChatResponseObjectFromJson(json); _$GetChatResponseObjectFromJson(json);
Map<String, dynamic> toJson() => _$GetChatResponseObjectToJson(this); Map<String, dynamic> toJson() => _$GetChatResponseObjectToJson(this);
static GetChatResponseObject getDateDummy(int timestamp) { /// [timestamp] is in seconds.
var elementDate = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000); DateTime get sentAt => DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
return getTextDummy(elementDate.formatDateRelativeShort());
} static GetChatResponseObject getDateDummy(int timestamp) => getTextDummy(
DateTime.fromMillisecondsSinceEpoch(
timestamp * 1000,
).formatDateRelativeShort(),
);
static GetChatResponseObject deletedPlaceholder(int id) =>
GetChatResponseObject(
id,
'',
GetRoomResponseObjectMessageActorType.user,
'',
'',
0,
'',
GetRoomResponseObjectMessageType.deletedComment,
false,
'',
'Nachricht wurde gelöscht',
null,
null,
null,
null,
);
static GetChatResponseObject getTextDummy(String text) => static GetChatResponseObject getTextDummy(String text) =>
GetChatResponseObject( GetChatResponseObject(
@@ -95,14 +130,24 @@ class GetChatResponseObject {
); );
} }
GetChatResponseObject? _parentFromJson(Object? json) {
if (json is! Map<String, dynamic>) return null;
if (json['deleted'] == true) {
return GetChatResponseObject.deletedPlaceholder(
(json['id'] as num?)?.toInt() ?? 0,
);
}
return GetChatResponseObject.fromJson(json);
}
Map<String, RichObjectString>? _fromJson(dynamic json) { Map<String, RichObjectString>? _fromJson(dynamic json) {
if (json is Map<String, dynamic>) { if (json is Map<String, dynamic>) {
final data = <String, RichObjectString>{}; final data = <String, RichObjectString>{};
for (final element in json.keys) { for (final entry in json.entries) {
data.putIfAbsent( final value = entry.value;
element, if (value is Map<String, dynamic>) {
() => RichObjectString.fromJson(json[element] as Map<String, dynamic>), data[entry.key] = RichObjectString.fromJson(value);
); }
} }
return data; return data;
} }
@@ -112,6 +157,7 @@ Map<String, RichObjectString>? _fromJson(dynamic json) {
@JsonSerializable(explicitToJson: true) @JsonSerializable(explicitToJson: true)
class RichObjectString { class RichObjectString {
@JsonKey(unknownEnumValue: RichObjectStringObjectType.unknown)
RichObjectStringObjectType type; RichObjectStringObjectType type;
String id; String id;
String name; String name;
@@ -143,4 +189,14 @@ enum RichObjectStringObjectType {
geoLocation, geoLocation,
@JsonValue('call') @JsonValue('call')
call, call,
@JsonValue('user-group')
userGroup,
@JsonValue('circle')
circle,
@JsonValue('email')
email,
/// Anything else (deck cards, contacts, calendar events, ...); the
/// placeholder still resolves to the object's name.
unknown,
} }
@@ -7,16 +7,18 @@ part of 'get_chat_response.dart';
// ************************************************************************** // **************************************************************************
GetChatResponse _$GetChatResponseFromJson(Map<String, dynamic> json) => GetChatResponse _$GetChatResponseFromJson(Map<String, dynamic> json) =>
GetChatResponse( $checkedCreate('GetChatResponse', json, ($checkedConvert) {
(json['data'] as List<dynamic>) final val = GetChatResponse(
.map( $checkedConvert('data', (v) => _messagesFromJson(v)),
(e) => GetChatResponseObject.fromJson(e as Map<String, dynamic>),
)
.toSet(),
)
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
); );
$checkedConvert(
'headers',
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
),
);
return val;
});
Map<String, dynamic> _$GetChatResponseToJson(GetChatResponse instance) => Map<String, dynamic> _$GetChatResponseToJson(GetChatResponse instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -26,31 +28,49 @@ Map<String, dynamic> _$GetChatResponseToJson(GetChatResponse instance) =>
GetChatResponseObject _$GetChatResponseObjectFromJson( GetChatResponseObject _$GetChatResponseObjectFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => GetChatResponseObject( ) => $checkedCreate('GetChatResponseObject', json, ($checkedConvert) {
(json['id'] as num).toInt(), final val = GetChatResponseObject(
json['token'] as String, $checkedConvert('id', (v) => (v as num).toInt()),
$enumDecode( $checkedConvert('token', (v) => v as String),
_$GetRoomResponseObjectMessageActorTypeEnumMap, $checkedConvert(
json['actorType'], 'actorType',
), (v) => $enumDecode(
json['actorId'] as String, _$GetRoomResponseObjectMessageActorTypeEnumMap,
json['actorDisplayName'] as String, v,
(json['timestamp'] as num).toInt(), unknownValue: GetRoomResponseObjectMessageActorType.unknown,
json['systemMessage'] as String, ),
$enumDecode(_$GetRoomResponseObjectMessageTypeEnumMap, json['messageType']), ),
json['isReplyable'] as bool, $checkedConvert('actorId', (v) => v as String),
json['referenceId'] as String, $checkedConvert('actorDisplayName', (v) => v as String),
json['message'] as String, $checkedConvert('timestamp', (v) => (v as num).toInt()),
_fromJson(json['messageParameters']), $checkedConvert('systemMessage', (v) => v as String),
(json['reactions'] as Map<String, dynamic>?)?.map( $checkedConvert(
(k, e) => MapEntry(k, (e as num).toInt()), 'messageType',
), (v) => $enumDecode(
(json['reactionsSelf'] as List<dynamic>?)?.map((e) => e as String).toList(), _$GetRoomResponseObjectMessageTypeEnumMap,
json['parent'] == null v,
? null unknownValue: GetRoomResponseObjectMessageType.unknown,
: GetChatResponseObject.fromJson(json['parent'] as Map<String, dynamic>), ),
json['markdown'] as bool? ?? false, ),
); $checkedConvert('isReplyable', (v) => v as bool),
$checkedConvert('referenceId', (v) => v as String),
$checkedConvert('message', (v) => v as String),
$checkedConvert('messageParameters', (v) => _fromJson(v)),
$checkedConvert(
'reactions',
(v) => (v as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, (e as num).toInt()),
),
),
$checkedConvert(
'reactionsSelf',
(v) => (v as List<dynamic>?)?.map((e) => e as String).toList(),
),
$checkedConvert('parent', (v) => _parentFromJson(v)),
$checkedConvert('markdown', (v) => v as bool? ?? false),
);
return val;
});
Map<String, dynamic> _$GetChatResponseObjectToJson( Map<String, dynamic> _$GetChatResponseObjectToJson(
GetChatResponseObject instance, GetChatResponseObject instance,
@@ -83,6 +103,12 @@ const _$GetRoomResponseObjectMessageActorTypeEnumMap = {
GetRoomResponseObjectMessageActorType.guest: 'guests', GetRoomResponseObjectMessageActorType.guest: 'guests',
GetRoomResponseObjectMessageActorType.bot: 'bots', GetRoomResponseObjectMessageActorType.bot: 'bots',
GetRoomResponseObjectMessageActorType.bridge: 'bridged', GetRoomResponseObjectMessageActorType.bridge: 'bridged',
GetRoomResponseObjectMessageActorType.group: 'groups',
GetRoomResponseObjectMessageActorType.email: 'emails',
GetRoomResponseObjectMessageActorType.circle: 'circles',
GetRoomResponseObjectMessageActorType.federatedUser: 'federated_users',
GetRoomResponseObjectMessageActorType.phone: 'phones',
GetRoomResponseObjectMessageActorType.unknown: 'unknown',
}; };
const _$GetRoomResponseObjectMessageTypeEnumMap = { const _$GetRoomResponseObjectMessageTypeEnumMap = {
@@ -91,16 +117,27 @@ const _$GetRoomResponseObjectMessageTypeEnumMap = {
GetRoomResponseObjectMessageType.deletedComment: 'comment_deleted', GetRoomResponseObjectMessageType.deletedComment: 'comment_deleted',
GetRoomResponseObjectMessageType.system: 'system', GetRoomResponseObjectMessageType.system: 'system',
GetRoomResponseObjectMessageType.command: 'command', GetRoomResponseObjectMessageType.command: 'command',
GetRoomResponseObjectMessageType.unknown: 'unknown',
}; };
RichObjectString _$RichObjectStringFromJson(Map<String, dynamic> json) => RichObjectString _$RichObjectStringFromJson(Map<String, dynamic> json) =>
RichObjectString( $checkedCreate('RichObjectString', json, ($checkedConvert) {
$enumDecode(_$RichObjectStringObjectTypeEnumMap, json['type']), final val = RichObjectString(
json['id'] as String, $checkedConvert(
json['name'] as String, 'type',
json['path'] as String?, (v) => $enumDecode(
json['link'] as String?, _$RichObjectStringObjectTypeEnumMap,
); v,
unknownValue: RichObjectStringObjectType.unknown,
),
),
$checkedConvert('id', (v) => v as String),
$checkedConvert('name', (v) => v as String),
$checkedConvert('path', (v) => v as String?),
$checkedConvert('link', (v) => v as String?),
);
return val;
});
Map<String, dynamic> _$RichObjectStringToJson(RichObjectString instance) => Map<String, dynamic> _$RichObjectStringToJson(RichObjectString instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -120,4 +157,8 @@ const _$RichObjectStringObjectTypeEnumMap = {
RichObjectStringObjectType.talkPoll: 'talk-poll', RichObjectStringObjectType.talkPoll: 'talk-poll',
RichObjectStringObjectType.geoLocation: 'geo-location', RichObjectStringObjectType.geoLocation: 'geo-location',
RichObjectStringObjectType.call: 'call', RichObjectStringObjectType.call: 'call',
RichObjectStringObjectType.userGroup: 'user-group',
RichObjectStringObjectType.circle: 'circle',
RichObjectStringObjectType.email: 'email',
RichObjectStringObjectType.unknown: 'unknown',
}; };
@@ -1,15 +1,10 @@
import 'package:http/http.dart' as http; import 'fetch_chat_page.dart';
import '../../../errors/server_exception.dart';
import '../../../http_errors.dart';
import '../../nextcloud_ocs.dart';
import 'get_chat_params.dart'; import 'get_chat_params.dart';
import 'get_chat_response.dart'; import 'get_chat_response.dart';
/// Long-poll variant of GetChat (`lookIntoFuture=1`). Bypasses [TalkApi] /// Long-poll variant of GetChat (`lookIntoFuture=1`); HTTP 304 is the normal
/// because that layer treats non-2xx as errors, and we need 304 to be a /// "no new messages" outcome (see [fetchChatPage]). `setReadMarker=on` lets
/// normal "no new messages" outcome. `setReadMarker=on` lets the server /// the server move the read cursor whenever the call returns messages.
/// move the read cursor whenever the call returns messages.
class LongPollChat { class LongPollChat {
final String chatToken; final String chatToken;
final int lastKnownMessageId; final int lastKnownMessageId;
@@ -22,37 +17,17 @@ class LongPollChat {
}); });
/// Returns the response, or `null` on HTTP 304 (server timeout, nothing new). /// Returns the response, or `null` on HTTP 304 (server timeout, nothing new).
Future<GetChatResponse?> run() async { Future<GetChatResponse?> run() => fetchChatPage(
final params = GetChatParams( label: 'LongPollChat',
chatToken: chatToken,
params: GetChatParams(
lookIntoFuture: GetChatParamsSwitch.on, lookIntoFuture: GetChatParamsSwitch.on,
timeout: timeoutSeconds, timeout: timeoutSeconds,
lastKnownMessageId: lastKnownMessageId, lastKnownMessageId: lastKnownMessageId,
includeLastKnown: GetChatParamsSwitch.off, includeLastKnown: GetChatParamsSwitch.off,
setReadMarker: GetChatParamsSwitch.on, setReadMarker: GetChatParamsSwitch.on,
limit: 100, limit: 100,
); ),
final uri = NextcloudOcs.uri( timeout: Duration(seconds: timeoutSeconds + 15),
'apps/spreed/api/v1/chat/$chatToken', );
queryParameters: params.toJson(),
);
final headers = NextcloudOcs.headers();
final response = (await sendGuarded(
'LongPollChat $uri',
() => http
.get(uri, headers: headers)
.timeout(Duration(seconds: timeoutSeconds + 15)),
))!;
final status = response.statusCode;
if (status == 304) return null;
if (status >= 200 && status < 300) {
return GetChatResponse.fromJson(NextcloudOcs.decode(response.body))
..headers = response.headers;
}
throw ServerException(
statusCode: status,
technicalDetails: 'LongPollChat $uri: HTTP $status',
);
}
} }
@@ -7,14 +7,18 @@ part of 'create_poll_params.dart';
// ************************************************************************** // **************************************************************************
CreatePollParams _$CreatePollParamsFromJson(Map<String, dynamic> json) => CreatePollParams _$CreatePollParamsFromJson(Map<String, dynamic> json) =>
CreatePollParams( $checkedCreate('CreatePollParams', json, ($checkedConvert) {
question: json['question'] as String, final val = CreatePollParams(
options: (json['options'] as List<dynamic>) question: $checkedConvert('question', (v) => v as String),
.map((e) => e as String) options: $checkedConvert(
.toList(), 'options',
resultMode: (json['resultMode'] as num).toInt(), (v) => (v as List<dynamic>).map((e) => e as String).toList(),
maxVotes: (json['maxVotes'] as num).toInt(), ),
); resultMode: $checkedConvert('resultMode', (v) => (v as num).toInt()),
maxVotes: $checkedConvert('maxVotes', (v) => (v as num).toInt()),
);
return val;
});
Map<String, dynamic> _$CreatePollParamsToJson(CreatePollParams instance) => Map<String, dynamic> _$CreatePollParamsToJson(CreatePollParams instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -7,14 +7,17 @@ part of 'create_room_params.dart';
// ************************************************************************** // **************************************************************************
CreateRoomParams _$CreateRoomParamsFromJson(Map<String, dynamic> json) => CreateRoomParams _$CreateRoomParamsFromJson(Map<String, dynamic> json) =>
CreateRoomParams( $checkedCreate('CreateRoomParams', json, ($checkedConvert) {
roomType: (json['roomType'] as num).toInt(), final val = CreateRoomParams(
invite: json['invite'] as String, roomType: $checkedConvert('roomType', (v) => (v as num).toInt()),
source: json['source'] as String?, invite: $checkedConvert('invite', (v) => v as String),
roomName: json['roomName'] as String?, source: $checkedConvert('source', (v) => v as String?),
objectType: json['objectType'] as String?, roomName: $checkedConvert('roomName', (v) => v as String?),
objectId: json['objectId'] as String?, objectType: $checkedConvert('objectType', (v) => v as String?),
); objectId: $checkedConvert('objectId', (v) => v as String?),
);
return val;
});
Map<String, dynamic> _$CreateRoomParamsToJson(CreateRoomParams instance) => Map<String, dynamic> _$CreateRoomParamsToJson(CreateRoomParams instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -7,12 +7,21 @@ part of 'create_room_response.dart';
// ************************************************************************** // **************************************************************************
CreateRoomResponse _$CreateRoomResponseFromJson(Map<String, dynamic> json) => CreateRoomResponse _$CreateRoomResponseFromJson(Map<String, dynamic> json) =>
CreateRoomResponse( $checkedCreate('CreateRoomResponse', json, ($checkedConvert) {
CreateRoomResponseData.fromJson(json['data'] as Map<String, dynamic>), final val = CreateRoomResponse(
) $checkedConvert(
..headers = (json['headers'] as Map<String, dynamic>?)?.map( 'data',
(k, e) => MapEntry(k, e as String), (v) => CreateRoomResponseData.fromJson(v as Map<String, dynamic>),
),
); );
$checkedConvert(
'headers',
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
),
);
return val;
});
Map<String, dynamic> _$CreateRoomResponseToJson(CreateRoomResponse instance) => Map<String, dynamic> _$CreateRoomResponseToJson(CreateRoomResponse instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -22,7 +31,12 @@ Map<String, dynamic> _$CreateRoomResponseToJson(CreateRoomResponse instance) =>
CreateRoomResponseData _$CreateRoomResponseDataFromJson( CreateRoomResponseData _$CreateRoomResponseDataFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => CreateRoomResponseData(json['token'] as String); ) => $checkedCreate('CreateRoomResponseData', json, ($checkedConvert) {
final val = CreateRoomResponseData(
$checkedConvert('token', (v) => v as String),
);
return val;
});
Map<String, dynamic> _$CreateRoomResponseDataToJson( Map<String, dynamic> _$CreateRoomResponseDataToJson(
CreateRoomResponseData instance, CreateRoomResponseData instance,
@@ -8,7 +8,12 @@ part of 'delete_react_message_params.dart';
DeleteReactMessageParams _$DeleteReactMessageParamsFromJson( DeleteReactMessageParams _$DeleteReactMessageParamsFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => DeleteReactMessageParams(json['reaction'] as String); ) => $checkedCreate('DeleteReactMessageParams', json, ($checkedConvert) {
final val = DeleteReactMessageParams(
$checkedConvert('reaction', (v) => v as String),
);
return val;
});
Map<String, dynamic> _$DeleteReactMessageParamsToJson( Map<String, dynamic> _$DeleteReactMessageParamsToJson(
DeleteReactMessageParams instance, DeleteReactMessageParams instance,
@@ -1,5 +1,3 @@
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart'; import '../../nextcloud_ocs.dart';
import '../talk_api.dart'; import '../talk_api.dart';
import 'get_participants_response.dart'; import 'get_participants_response.dart';
@@ -11,11 +9,4 @@ class GetParticipants extends TalkApi<GetParticipantsResponse> {
@override @override
GetParticipantsResponse assemble(String raw) => GetParticipantsResponse assemble(String raw) =>
GetParticipantsResponse.fromJson(NextcloudOcs.decode(raw)); GetParticipantsResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
Uri uri,
Object? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
} }
@@ -1,11 +1,13 @@
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import '../../../api_response.dart'; import '../../../api_response.dart';
import '../../../json_lenient.dart';
part 'get_participants_response.g.dart'; part 'get_participants_response.g.dart';
@JsonSerializable(explicitToJson: true) @JsonSerializable(explicitToJson: true)
class GetParticipantsResponse extends ApiResponse { class GetParticipantsResponse extends ApiResponse {
@JsonKey(fromJson: _participantsFromJson)
Set<GetParticipantsResponseObject> data; Set<GetParticipantsResponseObject> data;
GetParticipantsResponse(this.data); GetParticipantsResponse(this.data);
@@ -15,15 +17,25 @@ class GetParticipantsResponse extends ApiResponse {
Map<String, dynamic> toJson() => _$GetParticipantsResponseToJson(this); Map<String, dynamic> toJson() => _$GetParticipantsResponseToJson(this);
} }
Set<GetParticipantsResponseObject> _participantsFromJson(Object? raw) =>
decodeSetLenient(
raw,
GetParticipantsResponseObject.fromJson,
what: 'participant',
);
@JsonSerializable(explicitToJson: true) @JsonSerializable(explicitToJson: true)
class GetParticipantsResponseObject { class GetParticipantsResponseObject {
int attendeeId; int attendeeId;
String actorType; String actorType;
String actorId; String actorId;
String displayName; String displayName;
@JsonKey(unknownEnumValue: GetParticipantsResponseObjectParticipantType.user)
GetParticipantsResponseObjectParticipantType participantType; GetParticipantsResponseObjectParticipantType participantType;
int lastPing; int lastPing;
GetParticipantsResponseObjectParticipantsInCallFlags inCall;
/// Bitmask: 1 = in call, 2 = with audio, 4 = with video, 8 = via phone.
int inCall;
int permissions; int permissions;
int attendeePermissions; int attendeePermissions;
String? sessionId; String? sessionId;
@@ -54,6 +66,11 @@ class GetParticipantsResponseObject {
factory GetParticipantsResponseObject.fromJson(Map<String, dynamic> json) => factory GetParticipantsResponseObject.fromJson(Map<String, dynamic> json) =>
_$GetParticipantsResponseObjectFromJson(json); _$GetParticipantsResponseObjectFromJson(json);
Map<String, dynamic> toJson() => _$GetParticipantsResponseObjectToJson(this); Map<String, dynamic> toJson() => _$GetParticipantsResponseObjectToJson(this);
bool get isInCall => inCall & 1 != 0;
bool get inCallWithAudio => inCall & 2 != 0;
bool get inCallWithVideo => inCall & 4 != 0;
bool get inCallViaPhone => inCall & 8 != 0;
} }
enum GetParticipantsResponseObjectParticipantType { enum GetParticipantsResponseObjectParticipantType {
@@ -73,16 +90,3 @@ enum GetParticipantsResponseObjectParticipantType {
const GetParticipantsResponseObjectParticipantType(this.prettyName); const GetParticipantsResponseObjectParticipantType(this.prettyName);
final String prettyName; final String prettyName;
} }
enum GetParticipantsResponseObjectParticipantsInCallFlags {
@JsonValue(0)
disconnected,
@JsonValue(1)
inCall,
@JsonValue(2)
providesAudio,
@JsonValue(3)
providesVideo,
@JsonValue(4)
usesSipDialIn,
}
@@ -8,19 +8,18 @@ part of 'get_participants_response.dart';
GetParticipantsResponse _$GetParticipantsResponseFromJson( GetParticipantsResponse _$GetParticipantsResponseFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => ) => $checkedCreate('GetParticipantsResponse', json, ($checkedConvert) {
GetParticipantsResponse( final val = GetParticipantsResponse(
(json['data'] as List<dynamic>) $checkedConvert('data', (v) => _participantsFromJson(v)),
.map( );
(e) => GetParticipantsResponseObject.fromJson( $checkedConvert(
e as Map<String, dynamic>, 'headers',
), (v) => val.headers = (v as Map<String, dynamic>?)?.map(
) (k, e) => MapEntry(k, e as String),
.toSet(), ),
) );
..headers = (json['headers'] as Map<String, dynamic>?)?.map( return val;
(k, e) => MapEntry(k, e as String), });
);
Map<String, dynamic> _$GetParticipantsResponseToJson( Map<String, dynamic> _$GetParticipantsResponseToJson(
GetParticipantsResponse instance, GetParticipantsResponse instance,
@@ -31,29 +30,36 @@ Map<String, dynamic> _$GetParticipantsResponseToJson(
GetParticipantsResponseObject _$GetParticipantsResponseObjectFromJson( GetParticipantsResponseObject _$GetParticipantsResponseObjectFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => GetParticipantsResponseObject( ) => $checkedCreate('GetParticipantsResponseObject', json, ($checkedConvert) {
(json['attendeeId'] as num).toInt(), final val = GetParticipantsResponseObject(
json['actorType'] as String, $checkedConvert('attendeeId', (v) => (v as num).toInt()),
json['actorId'] as String, $checkedConvert('actorType', (v) => v as String),
json['displayName'] as String, $checkedConvert('actorId', (v) => v as String),
$enumDecode( $checkedConvert('displayName', (v) => v as String),
_$GetParticipantsResponseObjectParticipantTypeEnumMap, $checkedConvert(
json['participantType'], 'participantType',
), (v) => $enumDecode(
(json['lastPing'] as num).toInt(), _$GetParticipantsResponseObjectParticipantTypeEnumMap,
$enumDecode( v,
_$GetParticipantsResponseObjectParticipantsInCallFlagsEnumMap, unknownValue: GetParticipantsResponseObjectParticipantType.user,
json['inCall'], ),
), ),
(json['permissions'] as num).toInt(), $checkedConvert('lastPing', (v) => (v as num).toInt()),
(json['attendeePermissions'] as num).toInt(), $checkedConvert('inCall', (v) => (v as num).toInt()),
json['sessionId'] as String?, $checkedConvert('permissions', (v) => (v as num).toInt()),
(json['sessionIds'] as List<dynamic>).map((e) => e as String).toList(), $checkedConvert('attendeePermissions', (v) => (v as num).toInt()),
json['status'] as String?, $checkedConvert('sessionId', (v) => v as String?),
json['statusIcon'] as String?, $checkedConvert(
json['statusMessage'] as String?, 'sessionIds',
json['roomToken'] as String?, (v) => (v as List<dynamic>).map((e) => e as String).toList(),
); ),
$checkedConvert('status', (v) => v as String?),
$checkedConvert('statusIcon', (v) => v as String?),
$checkedConvert('statusMessage', (v) => v as String?),
$checkedConvert('roomToken', (v) => v as String?),
);
return val;
});
Map<String, dynamic> _$GetParticipantsResponseObjectToJson( Map<String, dynamic> _$GetParticipantsResponseObjectToJson(
GetParticipantsResponseObject instance, GetParticipantsResponseObject instance,
@@ -66,9 +72,7 @@ Map<String, dynamic> _$GetParticipantsResponseObjectToJson(
_$GetParticipantsResponseObjectParticipantTypeEnumMap[instance _$GetParticipantsResponseObjectParticipantTypeEnumMap[instance
.participantType]!, .participantType]!,
'lastPing': instance.lastPing, 'lastPing': instance.lastPing,
'inCall': 'inCall': instance.inCall,
_$GetParticipantsResponseObjectParticipantsInCallFlagsEnumMap[instance
.inCall]!,
'permissions': instance.permissions, 'permissions': instance.permissions,
'attendeePermissions': instance.attendeePermissions, 'attendeePermissions': instance.attendeePermissions,
'sessionId': instance.sessionId, 'sessionId': instance.sessionId,
@@ -87,11 +91,3 @@ const _$GetParticipantsResponseObjectParticipantTypeEnumMap = {
GetParticipantsResponseObjectParticipantType.userFollowingPublicLink: 5, GetParticipantsResponseObjectParticipantType.userFollowingPublicLink: 5,
GetParticipantsResponseObjectParticipantType.guestWithModeratorPermissions: 6, GetParticipantsResponseObjectParticipantType.guestWithModeratorPermissions: 6,
}; };
const _$GetParticipantsResponseObjectParticipantsInCallFlagsEnumMap = {
GetParticipantsResponseObjectParticipantsInCallFlags.disconnected: 0,
GetParticipantsResponseObjectParticipantsInCallFlags.inCall: 1,
GetParticipantsResponseObjectParticipantsInCallFlags.providesAudio: 2,
GetParticipantsResponseObjectParticipantsInCallFlags.providesVideo: 3,
GetParticipantsResponseObjectParticipantsInCallFlags.usesSipDialIn: 4,
};
@@ -1,5 +1,3 @@
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart'; import '../../nextcloud_ocs.dart';
import '../talk_api.dart'; import '../talk_api.dart';
import 'get_poll_state_response.dart'; import 'get_poll_state_response.dart';
@@ -13,11 +11,4 @@ class GetPollState extends TalkApi<GetPollStateResponse> {
@override @override
GetPollStateResponse assemble(String raw) => GetPollStateResponse assemble(String raw) =>
GetPollStateResponse.fromJson(NextcloudOcs.decode(raw)); GetPollStateResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
Uri uri,
Object? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
} }
@@ -8,15 +8,21 @@ part of 'get_poll_state_response.dart';
GetPollStateResponse _$GetPollStateResponseFromJson( GetPollStateResponse _$GetPollStateResponseFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => ) => $checkedCreate('GetPollStateResponse', json, ($checkedConvert) {
GetPollStateResponse( final val = GetPollStateResponse(
GetPollStateResponseObject.fromJson( $checkedConvert(
json['data'] as Map<String, dynamic>, 'data',
), (v) => GetPollStateResponseObject.fromJson(v as Map<String, dynamic>),
) ),
..headers = (json['headers'] as Map<String, dynamic>?)?.map( );
(k, e) => MapEntry(k, e as String), $checkedConvert(
); 'headers',
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
),
);
return val;
});
Map<String, dynamic> _$GetPollStateResponseToJson( Map<String, dynamic> _$GetPollStateResponseToJson(
GetPollStateResponse instance, GetPollStateResponse instance,
@@ -27,21 +33,30 @@ Map<String, dynamic> _$GetPollStateResponseToJson(
GetPollStateResponseObject _$GetPollStateResponseObjectFromJson( GetPollStateResponseObject _$GetPollStateResponseObjectFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => GetPollStateResponseObject( ) => $checkedCreate('GetPollStateResponseObject', json, ($checkedConvert) {
(json['id'] as num).toInt(), final val = GetPollStateResponseObject(
json['question'] as String, $checkedConvert('id', (v) => (v as num).toInt()),
(json['options'] as List<dynamic>).map((e) => e as String).toList(), $checkedConvert('question', (v) => v as String),
json['votes'], $checkedConvert(
json['actorType'] as String, 'options',
json['actorId'] as String, (v) => (v as List<dynamic>).map((e) => e as String).toList(),
json['actorDisplayName'] as String, ),
(json['status'] as num).toInt(), $checkedConvert('votes', (v) => v),
(json['resultMode'] as num).toInt(), $checkedConvert('actorType', (v) => v as String),
(json['maxVotes'] as num).toInt(), $checkedConvert('actorId', (v) => v as String),
(json['votedSelf'] as List<dynamic>).map((e) => (e as num).toInt()).toList(), $checkedConvert('actorDisplayName', (v) => v as String),
(json['numVoters'] as num?)?.toInt(), $checkedConvert('status', (v) => (v as num).toInt()),
json['details'] as List<dynamic>?, $checkedConvert('resultMode', (v) => (v as num).toInt()),
); $checkedConvert('maxVotes', (v) => (v as num).toInt()),
$checkedConvert(
'votedSelf',
(v) => (v as List<dynamic>).map((e) => (e as num).toInt()).toList(),
),
$checkedConvert('numVoters', (v) => (v as num?)?.toInt()),
$checkedConvert('details', (v) => v as List<dynamic>?),
);
return val;
});
Map<String, dynamic> _$GetPollStateResponseObjectToJson( Map<String, dynamic> _$GetPollStateResponseObjectToJson(
GetPollStateResponseObject instance, GetPollStateResponseObject instance,
@@ -1,7 +1,3 @@
import 'package:http/http.dart' as http;
import 'package:http/http.dart';
import '../../../api_params.dart';
import '../../nextcloud_ocs.dart'; import '../../nextcloud_ocs.dart';
import '../talk_api.dart'; import '../talk_api.dart';
import 'get_reactions_response.dart'; import 'get_reactions_response.dart';
@@ -15,11 +11,4 @@ class GetReactions extends TalkApi<GetReactionsResponse> {
@override @override
GetReactionsResponse assemble(String raw) => GetReactionsResponse assemble(String raw) =>
GetReactionsResponse.fromJson(NextcloudOcs.decode(raw)); GetReactionsResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<Response>? request(
Uri uri,
ApiParams? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
} }
@@ -17,6 +17,7 @@ class GetReactionsResponse extends ApiResponse {
@JsonSerializable() @JsonSerializable()
class GetReactionsResponseObject { class GetReactionsResponseObject {
@JsonKey(unknownEnumValue: GetReactionsResponseObjectActorType.unknown)
GetReactionsResponseObjectActorType actorType; GetReactionsResponseObjectActorType actorType;
String actorId; String actorId;
String actorDisplayName; String actorDisplayName;
@@ -39,4 +40,11 @@ enum GetReactionsResponseObjectActorType {
guests, guests,
@JsonValue('users') @JsonValue('users')
users, users,
@JsonValue('emails')
emails,
@JsonValue('bots')
bots,
@JsonValue('federated_users')
federatedUsers,
unknown,
} }
@@ -8,24 +8,32 @@ part of 'get_reactions_response.dart';
GetReactionsResponse _$GetReactionsResponseFromJson( GetReactionsResponse _$GetReactionsResponseFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => ) => $checkedCreate('GetReactionsResponse', json, ($checkedConvert) {
GetReactionsResponse( final val = GetReactionsResponse(
(json['data'] as Map<String, dynamic>).map( $checkedConvert(
(k, e) => MapEntry( 'data',
k, (v) => (v as Map<String, dynamic>).map(
(e as List<dynamic>) (k, e) => MapEntry(
.map( k,
(e) => GetReactionsResponseObject.fromJson( (e as List<dynamic>)
e as Map<String, dynamic>, .map(
), (e) => GetReactionsResponseObject.fromJson(
) e as Map<String, dynamic>,
.toList(), ),
), )
.toList(),
), ),
) ),
..headers = (json['headers'] as Map<String, dynamic>?)?.map( ),
(k, e) => MapEntry(k, e as String), );
); $checkedConvert(
'headers',
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
),
);
return val;
});
Map<String, dynamic> _$GetReactionsResponseToJson( Map<String, dynamic> _$GetReactionsResponseToJson(
GetReactionsResponse instance, GetReactionsResponse instance,
@@ -38,12 +46,22 @@ Map<String, dynamic> _$GetReactionsResponseToJson(
GetReactionsResponseObject _$GetReactionsResponseObjectFromJson( GetReactionsResponseObject _$GetReactionsResponseObjectFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => GetReactionsResponseObject( ) => $checkedCreate('GetReactionsResponseObject', json, ($checkedConvert) {
$enumDecode(_$GetReactionsResponseObjectActorTypeEnumMap, json['actorType']), final val = GetReactionsResponseObject(
json['actorId'] as String, $checkedConvert(
json['actorDisplayName'] as String, 'actorType',
(json['timestamp'] as num).toInt(), (v) => $enumDecode(
); _$GetReactionsResponseObjectActorTypeEnumMap,
v,
unknownValue: GetReactionsResponseObjectActorType.unknown,
),
),
$checkedConvert('actorId', (v) => v as String),
$checkedConvert('actorDisplayName', (v) => v as String),
$checkedConvert('timestamp', (v) => (v as num).toInt()),
);
return val;
});
Map<String, dynamic> _$GetReactionsResponseObjectToJson( Map<String, dynamic> _$GetReactionsResponseObjectToJson(
GetReactionsResponseObject instance, GetReactionsResponseObject instance,
@@ -58,4 +76,8 @@ Map<String, dynamic> _$GetReactionsResponseObjectToJson(
const _$GetReactionsResponseObjectActorTypeEnumMap = { const _$GetReactionsResponseObjectActorTypeEnumMap = {
GetReactionsResponseObjectActorType.guests: 'guests', GetReactionsResponseObjectActorType.guests: 'guests',
GetReactionsResponseObjectActorType.users: 'users', GetReactionsResponseObjectActorType.users: 'users',
GetReactionsResponseObjectActorType.emails: 'emails',
GetReactionsResponseObjectActorType.bots: 'bots',
GetReactionsResponseObjectActorType.federatedUsers: 'federated_users',
GetReactionsResponseObjectActorType.unknown: 'unknown',
}; };
@@ -1,5 +1,3 @@
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart'; import '../../nextcloud_ocs.dart';
import '../talk_api.dart'; import '../talk_api.dart';
import 'get_shared_items_response.dart'; import 'get_shared_items_response.dart';
@@ -30,11 +28,4 @@ class GetSharedItems extends TalkApi<GetSharedItemsResponse> {
@override @override
GetSharedItemsResponse assemble(String raw) => GetSharedItemsResponse assemble(String raw) =>
GetSharedItemsResponse.fromOcs(NextcloudOcs.decode(raw)); GetSharedItemsResponse.fromOcs(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
Uri uri,
Object? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
} }
@@ -1,5 +1,3 @@
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart'; import '../../nextcloud_ocs.dart';
import '../talk_api.dart'; import '../talk_api.dart';
import 'get_shared_items_overview_response.dart'; import 'get_shared_items_overview_response.dart';
@@ -18,11 +16,4 @@ class GetSharedItemsOverview extends TalkApi<GetSharedItemsOverviewResponse> {
@override @override
GetSharedItemsOverviewResponse assemble(String raw) => GetSharedItemsOverviewResponse assemble(String raw) =>
GetSharedItemsOverviewResponse.fromOcs(NextcloudOcs.decode(raw)); GetSharedItemsOverviewResponse.fromOcs(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
Uri uri,
Object? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
} }
@@ -7,7 +7,12 @@ part of 'react_message_params.dart';
// ************************************************************************** // **************************************************************************
ReactMessageParams _$ReactMessageParamsFromJson(Map<String, dynamic> json) => ReactMessageParams _$ReactMessageParamsFromJson(Map<String, dynamic> json) =>
ReactMessageParams(json['reaction'] as String); $checkedCreate('ReactMessageParams', json, ($checkedConvert) {
final val = ReactMessageParams(
$checkedConvert('reaction', (v) => v as String),
);
return val;
});
Map<String, dynamic> _$ReactMessageParamsToJson(ReactMessageParams instance) => Map<String, dynamic> _$ReactMessageParamsToJson(ReactMessageParams instance) =>
<String, dynamic>{'reaction': instance.reaction}; <String, dynamic>{'reaction': instance.reaction};
@@ -1,6 +1,6 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../../../json_lenient.dart';
import '../../nextcloud_ocs.dart'; import '../../nextcloud_ocs.dart';
import '../talk_api.dart'; import '../talk_api.dart';
import 'get_room_params.dart'; import 'get_room_params.dart';
@@ -17,14 +17,8 @@ class GetRoom extends TalkApi<GetRoomResponse> {
@override @override
Future<GetRoomResponse> assembleAsync(String raw) => Future<GetRoomResponse> assembleAsync(String raw) =>
compute(_parseRooms, raw); compute(_parseRooms, raw);
@override
Future<http.Response> request(
Uri uri,
Object? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
} }
GetRoomResponse _parseRooms(String raw) => GetRoomResponse _parseRooms(String raw) =>
GetRoomResponse.fromJson(NextcloudOcs.decode(raw)); GetRoomResponse.fromJson(NextcloudOcs.decode(raw))
..parseWarnings = drainDecodeWarnings();
@@ -4,7 +4,7 @@ import '../../../api_params.dart';
part 'get_room_params.g.dart'; part 'get_room_params.g.dart';
@JsonSerializable(explicitToJson: true) @JsonSerializable(explicitToJson: true, includeIfNull: false)
class GetRoomParams extends ApiParams { class GetRoomParams extends ApiParams {
GetRoomParamsStatusUpdate? noStatusUpdate; GetRoomParamsStatusUpdate? noStatusUpdate;
@JsonKey(toJson: _format) @JsonKey(toJson: _format)
@@ -7,22 +7,28 @@ part of 'get_room_params.dart';
// ************************************************************************** // **************************************************************************
GetRoomParams _$GetRoomParamsFromJson(Map<String, dynamic> json) => GetRoomParams _$GetRoomParamsFromJson(Map<String, dynamic> json) =>
GetRoomParams( $checkedCreate('GetRoomParams', json, ($checkedConvert) {
noStatusUpdate: $enumDecodeNullable( final val = GetRoomParams(
_$GetRoomParamsStatusUpdateEnumMap, noStatusUpdate: $checkedConvert(
json['noStatusUpdate'], 'noStatusUpdate',
), (v) => $enumDecodeNullable(_$GetRoomParamsStatusUpdateEnumMap, v),
includeStatus: json['includeStatus'] as bool?, ),
modifiedSince: (json['modifiedSince'] as num?)?.toInt(), includeStatus: $checkedConvert('includeStatus', (v) => v as bool?),
); modifiedSince: $checkedConvert(
'modifiedSince',
(v) => (v as num?)?.toInt(),
),
);
return val;
});
Map<String, dynamic> _$GetRoomParamsToJson( Map<String, dynamic> _$GetRoomParamsToJson(GetRoomParams instance) =>
GetRoomParams instance, <String, dynamic>{
) => <String, dynamic>{ 'noStatusUpdate':
'noStatusUpdate': _$GetRoomParamsStatusUpdateEnumMap[instance.noStatusUpdate], ?_$GetRoomParamsStatusUpdateEnumMap[instance.noStatusUpdate],
'includeStatus': GetRoomParams._format(instance.includeStatus), 'includeStatus': GetRoomParams._format(instance.includeStatus),
'modifiedSince': instance.modifiedSince, 'modifiedSince': ?instance.modifiedSince,
}; };
const _$GetRoomParamsStatusUpdateEnumMap = { const _$GetRoomParamsStatusUpdateEnumMap = {
GetRoomParamsStatusUpdate.defaults: 0, GetRoomParamsStatusUpdate.defaults: 0,
@@ -1,12 +1,14 @@
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import '../../../api_response.dart'; import '../../../api_response.dart';
import '../../../json_lenient.dart';
import '../chat/get_chat_response.dart'; import '../chat/get_chat_response.dart';
part 'get_room_response.g.dart'; part 'get_room_response.g.dart';
@JsonSerializable(explicitToJson: true) @JsonSerializable(explicitToJson: true)
class GetRoomResponse extends ApiResponse { class GetRoomResponse extends ApiResponse {
@JsonKey(fromJson: _roomsFromJson)
Set<GetRoomResponseObject> data; Set<GetRoomResponseObject> data;
GetRoomResponse(this.data); GetRoomResponse(this.data);
@@ -33,10 +35,14 @@ class GetRoomResponse extends ApiResponse {
} }
} }
Set<GetRoomResponseObject> _roomsFromJson(Object? raw) =>
decodeSetLenient(raw, GetRoomResponseObject.fromJson, what: 'room');
@JsonSerializable(explicitToJson: true) @JsonSerializable(explicitToJson: true)
class GetRoomResponseObject { class GetRoomResponseObject {
int id; int id;
String token; String token;
@JsonKey(unknownEnumValue: GetRoomResponseObjectConversationType.unknown)
GetRoomResponseObjectConversationType type; GetRoomResponseObjectConversationType type;
String name; String name;
String displayName; String displayName;
@@ -55,13 +61,20 @@ class GetRoomResponseObject {
bool canLeaveConversation; bool canLeaveConversation;
int lastActivity; int lastActivity;
bool isFavorite; bool isFavorite;
@JsonKey(
unknownEnumValue:
GetRoomResponseObjectParticipantNotificationLevel.defaultLevel,
)
GetRoomResponseObjectParticipantNotificationLevel notificationLevel; GetRoomResponseObjectParticipantNotificationLevel notificationLevel;
int unreadMessages; int unreadMessages;
bool unreadMention; bool unreadMention;
bool unreadMentionDirect; bool unreadMentionDirect;
int lastReadMessage; int lastReadMessage;
int lastCommonReadMessage; int lastCommonReadMessage;
GetChatResponseObject lastMessage;
/// Omitted by the server for rooms without a visible message (empty room,
/// last message expired or hidden from this user, federated conversation).
GetChatResponseObject? lastMessage;
String? status; String? status;
String? statusIcon; String? statusIcon;
String? statusMessage; String? statusMessage;
@@ -117,6 +130,9 @@ enum GetRoomResponseObjectConversationType {
deleted, deleted,
@JsonValue(6) @JsonValue(6)
noteToSelf, noteToSelf,
/// Fallback for types this app does not know yet; treated like a group.
unknown,
} }
enum GetRoomResponseObjectParticipantNotificationLevel { enum GetRoomResponseObjectParticipantNotificationLevel {
@@ -147,6 +163,17 @@ enum GetRoomResponseObjectMessageActorType {
bot, bot,
@JsonValue('bridged') @JsonValue('bridged')
bridge, bridge,
@JsonValue('groups')
group,
@JsonValue('emails')
email,
@JsonValue('circles')
circle,
@JsonValue('federated_users')
federatedUser,
@JsonValue('phones')
phone,
unknown,
} }
enum GetRoomResponseObjectMessageType { enum GetRoomResponseObjectMessageType {
@@ -160,4 +187,8 @@ enum GetRoomResponseObjectMessageType {
system, system,
@JsonValue('command') @JsonValue('command')
command, command,
/// Fallback for types this app does not know yet (e.g. call recordings);
/// rendered like a plain comment.
unknown,
} }
@@ -7,16 +7,18 @@ part of 'get_room_response.dart';
// ************************************************************************** // **************************************************************************
GetRoomResponse _$GetRoomResponseFromJson(Map<String, dynamic> json) => GetRoomResponse _$GetRoomResponseFromJson(Map<String, dynamic> json) =>
GetRoomResponse( $checkedCreate('GetRoomResponse', json, ($checkedConvert) {
(json['data'] as List<dynamic>) final val = GetRoomResponse(
.map( $checkedConvert('data', (v) => _roomsFromJson(v)),
(e) => GetRoomResponseObject.fromJson(e as Map<String, dynamic>),
)
.toSet(),
)
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
); );
$checkedConvert(
'headers',
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
),
);
return val;
});
Map<String, dynamic> _$GetRoomResponseToJson(GetRoomResponse instance) => Map<String, dynamic> _$GetRoomResponseToJson(GetRoomResponse instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -26,41 +28,61 @@ Map<String, dynamic> _$GetRoomResponseToJson(GetRoomResponse instance) =>
GetRoomResponseObject _$GetRoomResponseObjectFromJson( GetRoomResponseObject _$GetRoomResponseObjectFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => GetRoomResponseObject( ) => $checkedCreate('GetRoomResponseObject', json, ($checkedConvert) {
(json['id'] as num).toInt(), final val = GetRoomResponseObject(
json['token'] as String, $checkedConvert('id', (v) => (v as num).toInt()),
$enumDecode(_$GetRoomResponseObjectConversationTypeEnumMap, json['type']), $checkedConvert('token', (v) => v as String),
json['name'] as String, $checkedConvert(
json['displayName'] as String, 'type',
json['description'] as String, (v) => $enumDecode(
(json['participantType'] as num).toInt(), _$GetRoomResponseObjectConversationTypeEnumMap,
(json['participantFlags'] as num).toInt(), v,
(json['readOnly'] as num).toInt(), unknownValue: GetRoomResponseObjectConversationType.unknown,
(json['listable'] as num).toInt(), ),
(json['lastPing'] as num).toInt(), ),
json['sessionId'] as String, $checkedConvert('name', (v) => v as String),
json['hasPassword'] as bool, $checkedConvert('displayName', (v) => v as String),
json['hasCall'] as bool, $checkedConvert('description', (v) => v as String),
(json['callFlag'] as num).toInt(), $checkedConvert('participantType', (v) => (v as num).toInt()),
json['canStartCall'] as bool, $checkedConvert('participantFlags', (v) => (v as num).toInt()),
json['canDeleteConversation'] as bool, $checkedConvert('readOnly', (v) => (v as num).toInt()),
json['canLeaveConversation'] as bool, $checkedConvert('listable', (v) => (v as num).toInt()),
(json['lastActivity'] as num).toInt(), $checkedConvert('lastPing', (v) => (v as num).toInt()),
json['isFavorite'] as bool, $checkedConvert('sessionId', (v) => v as String),
$enumDecode( $checkedConvert('hasPassword', (v) => v as bool),
_$GetRoomResponseObjectParticipantNotificationLevelEnumMap, $checkedConvert('hasCall', (v) => v as bool),
json['notificationLevel'], $checkedConvert('callFlag', (v) => (v as num).toInt()),
), $checkedConvert('canStartCall', (v) => v as bool),
(json['unreadMessages'] as num).toInt(), $checkedConvert('canDeleteConversation', (v) => v as bool),
json['unreadMention'] as bool, $checkedConvert('canLeaveConversation', (v) => v as bool),
json['unreadMentionDirect'] as bool, $checkedConvert('lastActivity', (v) => (v as num).toInt()),
(json['lastReadMessage'] as num).toInt(), $checkedConvert('isFavorite', (v) => v as bool),
(json['lastCommonReadMessage'] as num).toInt(), $checkedConvert(
GetChatResponseObject.fromJson(json['lastMessage'] as Map<String, dynamic>), 'notificationLevel',
json['status'] as String?, (v) => $enumDecode(
json['statusIcon'] as String?, _$GetRoomResponseObjectParticipantNotificationLevelEnumMap,
json['statusMessage'] as String?, v,
); unknownValue:
GetRoomResponseObjectParticipantNotificationLevel.defaultLevel,
),
),
$checkedConvert('unreadMessages', (v) => (v as num).toInt()),
$checkedConvert('unreadMention', (v) => v as bool),
$checkedConvert('unreadMentionDirect', (v) => v as bool),
$checkedConvert('lastReadMessage', (v) => (v as num).toInt()),
$checkedConvert('lastCommonReadMessage', (v) => (v as num).toInt()),
$checkedConvert(
'lastMessage',
(v) => v == null
? null
: GetChatResponseObject.fromJson(v as Map<String, dynamic>),
),
$checkedConvert('status', (v) => v as String?),
$checkedConvert('statusIcon', (v) => v as String?),
$checkedConvert('statusMessage', (v) => v as String?),
);
return val;
});
Map<String, dynamic> _$GetRoomResponseObjectToJson( Map<String, dynamic> _$GetRoomResponseObjectToJson(
GetRoomResponseObject instance, GetRoomResponseObject instance,
@@ -93,7 +115,7 @@ Map<String, dynamic> _$GetRoomResponseObjectToJson(
'unreadMentionDirect': instance.unreadMentionDirect, 'unreadMentionDirect': instance.unreadMentionDirect,
'lastReadMessage': instance.lastReadMessage, 'lastReadMessage': instance.lastReadMessage,
'lastCommonReadMessage': instance.lastCommonReadMessage, 'lastCommonReadMessage': instance.lastCommonReadMessage,
'lastMessage': instance.lastMessage.toJson(), 'lastMessage': instance.lastMessage?.toJson(),
'status': instance.status, 'status': instance.status,
'statusIcon': instance.statusIcon, 'statusIcon': instance.statusIcon,
'statusMessage': instance.statusMessage, 'statusMessage': instance.statusMessage,
@@ -106,6 +128,7 @@ const _$GetRoomResponseObjectConversationTypeEnumMap = {
GetRoomResponseObjectConversationType.changelog: 4, GetRoomResponseObjectConversationType.changelog: 4,
GetRoomResponseObjectConversationType.deleted: 5, GetRoomResponseObjectConversationType.deleted: 5,
GetRoomResponseObjectConversationType.noteToSelf: 6, GetRoomResponseObjectConversationType.noteToSelf: 6,
GetRoomResponseObjectConversationType.unknown: 'unknown',
}; };
const _$GetRoomResponseObjectParticipantNotificationLevelEnumMap = { const _$GetRoomResponseObjectParticipantNotificationLevelEnumMap = {
@@ -7,10 +7,13 @@ part of 'send_message_params.dart';
// ************************************************************************** // **************************************************************************
SendMessageParams _$SendMessageParamsFromJson(Map<String, dynamic> json) => SendMessageParams _$SendMessageParamsFromJson(Map<String, dynamic> json) =>
SendMessageParams( $checkedCreate('SendMessageParams', json, ($checkedConvert) {
json['message'] as String, final val = SendMessageParams(
replyTo: json['replyTo'] as String?, $checkedConvert('message', (v) => v as String),
); replyTo: $checkedConvert('replyTo', (v) => v as String?),
);
return val;
});
Map<String, dynamic> _$SendMessageParamsToJson(SendMessageParams instance) => Map<String, dynamic> _$SendMessageParamsToJson(SendMessageParams instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -7,9 +7,15 @@ part of 'set_read_marker_params.dart';
// ************************************************************************** // **************************************************************************
SetReadMarkerParams _$SetReadMarkerParamsFromJson(Map<String, dynamic> json) => SetReadMarkerParams _$SetReadMarkerParamsFromJson(Map<String, dynamic> json) =>
SetReadMarkerParams( $checkedCreate('SetReadMarkerParams', json, ($checkedConvert) {
lastReadMessage: (json['lastReadMessage'] as num?)?.toInt(), final val = SetReadMarkerParams(
); lastReadMessage: $checkedConvert(
'lastReadMessage',
(v) => (v as num?)?.toInt(),
),
);
return val;
});
Map<String, dynamic> _$SetReadMarkerParamsToJson( Map<String, dynamic> _$SetReadMarkerParamsToJson(
SetReadMarkerParams instance, SetReadMarkerParams instance,
+12 -4
View File
@@ -8,6 +8,8 @@ import '../../errors/network_exception.dart';
import '../../errors/parse_exception.dart'; import '../../errors/parse_exception.dart';
import '../../errors/talk_exception.dart'; import '../../errors/talk_exception.dart';
import '../../http_errors.dart'; import '../../http_errors.dart';
import '../../json_lenient.dart';
import '../../marianumconnect/queries/report_client_error/client_error_reporter.dart';
import '../nextcloud_ocs.dart'; import '../nextcloud_ocs.dart';
import 'talk_error.dart'; import 'talk_error.dart';
@@ -23,11 +25,12 @@ abstract class TalkApi<T extends ApiResponse?> {
TalkApi(this.path, this.body, {this.headers, this.getParameters}); TalkApi(this.path, this.body, {this.headers, this.getParameters});
/// Defaults to a plain GET; endpoints with a body or another verb override it.
Future<http.Response>? request( Future<http.Response>? request(
Uri uri, Uri uri,
ApiParams? body, ApiParams? body,
Map<String, String>? headers, Map<String, String>? headers,
); ) => http.get(uri, headers: headers);
T assemble(String raw); T assemble(String raw);
/// Override to parse large payloads off the UI isolate (e.g. via `compute` /// Override to parse large payloads off the UI isolate (e.g. via `compute`
@@ -72,12 +75,17 @@ abstract class TalkApi<T extends ApiResponse?> {
throwForStatus(status, detail); throwForStatus(status, detail);
} }
final T assembled;
try { try {
final assembled = await assembleAsync(data.body); assembled = await assembleAsync(data.body);
assembled?.headers = data.headers;
return assembled;
} catch (e) { } catch (e) {
throw ParseException(technicalDetails: 'Talk $endpoint assemble: $e'); throw ParseException(technicalDetails: 'Talk $endpoint assemble: $e');
} }
assembled?.headers = data.headers;
ClientErrorReporter.reportDecodeWarnings('Talk $path', [
...?assembled?.parseWarnings,
...drainDecodeWarnings(),
]);
return assembled;
} }
} }
@@ -7,11 +7,15 @@ part of 'vote_poll_params.dart';
// ************************************************************************** // **************************************************************************
VotePollParams _$VotePollParamsFromJson(Map<String, dynamic> json) => VotePollParams _$VotePollParamsFromJson(Map<String, dynamic> json) =>
VotePollParams( $checkedCreate('VotePollParams', json, ($checkedConvert) {
optionIds: (json['optionIds'] as List<dynamic>) final val = VotePollParams(
.map((e) => (e as num).toInt()) optionIds: $checkedConvert(
.toList(), 'optionIds',
); (v) => (v as List<dynamic>).map((e) => (e as num).toInt()).toList(),
),
);
return val;
});
Map<String, dynamic> _$VotePollParamsToJson(VotePollParams instance) => Map<String, dynamic> _$VotePollParamsToJson(VotePollParams instance) =>
<String, dynamic>{'optionIds': instance.optionIds}; <String, dynamic>{'optionIds': instance.optionIds};
@@ -7,23 +7,28 @@ part of 'cacheable_file.dart';
// ************************************************************************** // **************************************************************************
CacheableFile _$CacheableFileFromJson(Map<String, dynamic> json) => CacheableFile _$CacheableFileFromJson(Map<String, dynamic> json) =>
CacheableFile( $checkedCreate('CacheableFile', json, ($checkedConvert) {
path: json['path'] as String, final val = CacheableFile(
isDirectory: json['isDirectory'] as bool, path: $checkedConvert('path', (v) => v as String),
name: json['name'] as String, isDirectory: $checkedConvert('isDirectory', (v) => v as bool),
mimeType: json['mimeType'] as String?, name: $checkedConvert('name', (v) => v as String),
size: (json['size'] as num?)?.toInt(), mimeType: $checkedConvert('mimeType', (v) => v as String?),
eTag: json['eTag'] as String?, size: $checkedConvert('size', (v) => (v as num?)?.toInt()),
createdAt: json['createdAt'] == null eTag: $checkedConvert('eTag', (v) => v as String?),
? null createdAt: $checkedConvert(
: DateTime.parse(json['createdAt'] as String), 'createdAt',
modifiedAt: json['modifiedAt'] == null (v) => v == null ? null : DateTime.parse(v as String),
? null ),
: DateTime.parse(json['modifiedAt'] as String), modifiedAt: $checkedConvert(
fileId: (json['fileId'] as num?)?.toInt(), 'modifiedAt',
hasPreview: json['hasPreview'] as bool?, (v) => v == null ? null : DateTime.parse(v as String),
isSharedWithMe: json['isSharedWithMe'] as bool?, ),
); fileId: $checkedConvert('fileId', (v) => (v as num?)?.toInt()),
hasPreview: $checkedConvert('hasPreview', (v) => v as bool?),
isSharedWithMe: $checkedConvert('isSharedWithMe', (v) => v as bool?),
);
return val;
});
Map<String, dynamic> _$CacheableFileToJson(CacheableFile instance) => Map<String, dynamic> _$CacheableFileToJson(CacheableFile instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -7,7 +7,10 @@ part of 'list_files_params.dart';
// ************************************************************************** // **************************************************************************
ListFilesParams _$ListFilesParamsFromJson(Map<String, dynamic> json) => ListFilesParams _$ListFilesParamsFromJson(Map<String, dynamic> json) =>
ListFilesParams(json['path'] as String); $checkedCreate('ListFilesParams', json, ($checkedConvert) {
final val = ListFilesParams($checkedConvert('path', (v) => v as String));
return val;
});
Map<String, dynamic> _$ListFilesParamsToJson(ListFilesParams instance) => Map<String, dynamic> _$ListFilesParamsToJson(ListFilesParams instance) =>
<String, dynamic>{'path': instance.path}; <String, dynamic>{'path': instance.path};
@@ -7,14 +7,23 @@ part of 'list_files_response.dart';
// ************************************************************************** // **************************************************************************
ListFilesResponse _$ListFilesResponseFromJson(Map<String, dynamic> json) => ListFilesResponse _$ListFilesResponseFromJson(Map<String, dynamic> json) =>
ListFilesResponse( $checkedCreate('ListFilesResponse', json, ($checkedConvert) {
(json['files'] as List<dynamic>) final val = ListFilesResponse(
.map((e) => CacheableFile.fromJson(e as Map<String, dynamic>)) $checkedConvert(
.toSet(), 'files',
) (v) => (v as List<dynamic>)
..headers = (json['headers'] as Map<String, dynamic>?)?.map( .map((e) => CacheableFile.fromJson(e as Map<String, dynamic>))
(k, e) => MapEntry(k, e as String), .toSet(),
),
); );
$checkedConvert(
'headers',
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
),
);
return val;
});
Map<String, dynamic> _$ListFilesResponseToJson(ListFilesResponse instance) => Map<String, dynamic> _$ListFilesResponseToJson(ListFilesResponse instance) =>
<String, dynamic>{ <String, dynamic>{
+12 -2
View File
@@ -3,6 +3,8 @@ import 'package:nextcloud/nextcloud.dart';
import '../../../model/endpoint_data.dart'; import '../../../model/endpoint_data.dart';
import '../../../session/session_manager.dart'; import '../../../session/session_manager.dart';
import '../../api_response.dart'; import '../../api_response.dart';
import '../../demo/demo_mode.dart';
import '../../errors/network_exception.dart';
abstract class WebdavApi<T> { abstract class WebdavApi<T> {
T genericParams; T genericParams;
@@ -18,6 +20,14 @@ abstract class WebdavApi<T> {
/// changes (app password minted/renewed, account switch) so it never keeps /// changes (app password minted/renewed, account switch) so it never keeps
/// authenticating with stale credentials. /// authenticating with stale credentials.
static Future<WebDavClient> get webdav { static Future<WebDavClient> get webdav {
// The WebDAV client talks dart:io directly, past DemoHttpClient.
if (DemoMode.active) {
return Future.error(
NetworkException.demoBlocked(
Uri.parse(EndpointData().nextcloud().origin()),
),
);
}
// Keyed by user too: two accounts may share a password (no app password // Keyed by user too: two accounts may share a password (no app password
// minted), and the client would keep the previous login name. // minted), and the client would keep the previous login name.
final nextcloud = SessionManager().requireNextcloud(); final nextcloud = SessionManager().requireNextcloud();
@@ -31,7 +41,7 @@ abstract class WebdavApi<T> {
static Future<WebDavClient> establishWebdavConnection() async => static Future<WebDavClient> establishWebdavConnection() async =>
NextcloudClient( NextcloudClient(
Uri.parse('https://${EndpointData().nextcloud().full()}'), Uri.parse(EndpointData().nextcloud().origin()),
// App password preferred — with 2FA the real password is not accepted // App password preferred — with 2FA the real password is not accepted
// by Nextcloud at all (see NextcloudCredentials.usesLoginFlow). // by Nextcloud at all (see NextcloudCredentials.usesLoginFlow).
password: SessionManager().requireNextcloud().secret, password: SessionManager().requireNextcloud().secret,
@@ -41,5 +51,5 @@ abstract class WebdavApi<T> {
/// Builds the WebDAV download URL without embedded credentials. Callers must /// Builds the WebDAV download URL without embedded credentials. Callers must
/// authenticate via the [NextcloudCredentials.authHeaders] header instead. /// authenticate via the [NextcloudCredentials.authHeaders] header instead.
static String buildWebdavUrl() => static String buildWebdavUrl() =>
'https://${EndpointData().nextcloud().full()}/remote.php/dav/files/${SessionManager().requireNextcloud().username}/'; '${EndpointData().nextcloud().origin()}/remote.php/dav/files/${SessionManager().requireNextcloud().username}/';
} }
@@ -1,6 +1,7 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../../storage/keychain_write.dart';
import '../../errors/auth_exception.dart'; import '../../errors/auth_exception.dart';
/// `first_unlock` accessibility so the token can be read during background /// `first_unlock` accessibility so the token can be read during background
@@ -52,9 +53,9 @@ class MarianumConnectTokenStorage {
required String tokenId, required String tokenId,
required DateTime? expiresAt, required DateTime? expiresAt,
}) async { }) async {
await _storage.write(key: _tokenKey, value: token); await _storage.writeRetrying(key: _tokenKey, value: token);
await _storage.write(key: _tokenIdKey, value: tokenId); await _storage.writeRetrying(key: _tokenIdKey, value: tokenId);
await _storage.write( await _storage.writeRetrying(
key: _expiresAtKey, key: _expiresAtKey,
value: expiresAt?.toIso8601String() ?? '', value: expiresAt?.toIso8601String() ?? '',
); );
@@ -75,14 +76,14 @@ class MarianumConnectTokenStorage {
if (value == null) { if (value == null) {
await _storage.delete(key: key); await _storage.delete(key: key);
} else { } else {
await _storage.write(key: key, value: value); await _storage.writeRetrying(key: key, value: value);
} }
} }
} }
Future<void> clear() async { Future<void> clear() async {
await _storage.delete(key: _tokenKey); for (final key in fieldKeys) {
await _storage.delete(key: _tokenIdKey); await _storage.delete(key: key);
await _storage.delete(key: _expiresAtKey); }
} }
} }
@@ -0,0 +1,8 @@
import 'dart:io';
/// Platform wire value MarianumConnect expects (`android`/`ios`), null elsewhere.
String? clientPlatform() {
if (Platform.isAndroid) return 'android';
if (Platform.isIOS) return 'ios';
return null;
}
@@ -6,12 +6,22 @@ part of 'mc_holiday.dart';
// JsonSerializableGenerator // JsonSerializableGenerator
// ************************************************************************** // **************************************************************************
McHoliday _$McHolidayFromJson(Map<String, dynamic> json) => McHoliday( McHoliday _$McHolidayFromJson(Map<String, dynamic> json) =>
shortName: json['shortName'] as String, $checkedCreate('McHoliday', json, ($checkedConvert) {
longName: json['longName'] as String, final val = McHoliday(
startDate: McHoliday._dateFromJson(json['startDate'] as String), shortName: $checkedConvert('shortName', (v) => v as String),
endDate: McHoliday._dateFromJson(json['endDate'] as String), longName: $checkedConvert('longName', (v) => v as String),
); startDate: $checkedConvert(
'startDate',
(v) => McHoliday._dateFromJson(v as String),
),
endDate: $checkedConvert(
'endDate',
(v) => McHoliday._dateFromJson(v as String),
),
);
return val;
});
Map<String, dynamic> _$McHolidayToJson(McHoliday instance) => <String, dynamic>{ Map<String, dynamic> _$McHolidayToJson(McHoliday instance) => <String, dynamic>{
'shortName': instance.shortName, 'shortName': instance.shortName,
@@ -8,12 +8,15 @@ part of 'absence_prefill_response.dart';
AbsencePrefillResponse _$AbsencePrefillResponseFromJson( AbsencePrefillResponse _$AbsencePrefillResponseFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => AbsencePrefillResponse( ) => $checkedCreate('AbsencePrefillResponse', json, ($checkedConvert) {
firstName: json['firstName'] as String? ?? '', final val = AbsencePrefillResponse(
lastName: json['lastName'] as String? ?? '', firstName: $checkedConvert('firstName', (v) => v as String? ?? ''),
className: json['className'] as String? ?? '', lastName: $checkedConvert('lastName', (v) => v as String? ?? ''),
phone: json['phone'] as String? ?? '', className: $checkedConvert('className', (v) => v as String? ?? ''),
); phone: $checkedConvert('phone', (v) => v as String? ?? ''),
);
return val;
});
Map<String, dynamic> _$AbsencePrefillResponseToJson( Map<String, dynamic> _$AbsencePrefillResponseToJson(
AbsencePrefillResponse instance, AbsencePrefillResponse instance,
@@ -7,14 +7,17 @@ part of 'auth_login_response.dart';
// ************************************************************************** // **************************************************************************
AuthLoginUser _$AuthLoginUserFromJson(Map<String, dynamic> json) => AuthLoginUser _$AuthLoginUserFromJson(Map<String, dynamic> json) =>
AuthLoginUser( $checkedCreate('AuthLoginUser', json, ($checkedConvert) {
id: json['id'] as String, final val = AuthLoginUser(
username: json['username'] as String, id: $checkedConvert('id', (v) => v as String),
firstName: json['firstName'] as String, username: $checkedConvert('username', (v) => v as String),
lastName: json['lastName'] as String, firstName: $checkedConvert('firstName', (v) => v as String),
userType: json['userType'] as String?, lastName: $checkedConvert('lastName', (v) => v as String),
className: json['className'] as String?, userType: $checkedConvert('userType', (v) => v as String?),
); className: $checkedConvert('className', (v) => v as String?),
);
return val;
});
Map<String, dynamic> _$AuthLoginUserToJson(AuthLoginUser instance) => Map<String, dynamic> _$AuthLoginUserToJson(AuthLoginUser instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -27,12 +30,21 @@ Map<String, dynamic> _$AuthLoginUserToJson(AuthLoginUser instance) =>
}; };
AuthLoginResponse _$AuthLoginResponseFromJson(Map<String, dynamic> json) => AuthLoginResponse _$AuthLoginResponseFromJson(Map<String, dynamic> json) =>
AuthLoginResponse( $checkedCreate('AuthLoginResponse', json, ($checkedConvert) {
token: json['token'] as String, final val = AuthLoginResponse(
tokenId: json['tokenId'] as String, token: $checkedConvert('token', (v) => v as String),
expiresAt: AuthLoginResponse._expiresFromJson(json['expiresAt']), tokenId: $checkedConvert('tokenId', (v) => v as String),
user: AuthLoginUser.fromJson(json['user'] as Map<String, dynamic>), expiresAt: $checkedConvert(
); 'expiresAt',
(v) => AuthLoginResponse._expiresFromJson(v),
),
user: $checkedConvert(
'user',
(v) => AuthLoginUser.fromJson(v as Map<String, dynamic>),
),
);
return val;
});
Map<String, dynamic> _$AuthLoginResponseToJson(AuthLoginResponse instance) => Map<String, dynamic> _$AuthLoginResponseToJson(AuthLoginResponse instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -25,6 +25,7 @@ class GetBreakersResponse extends ApiResponse {
@JsonSerializable() @JsonSerializable()
class BreakerRule { class BreakerRule {
@JsonKey(unknownEnumValue: BreakerArea.unknown)
List<BreakerArea> areas; List<BreakerArea> areas;
String message; String message;
@@ -69,4 +70,7 @@ enum BreakerArea {
feedback, feedback,
@JsonValue('MORE') @JsonValue('MORE')
more, more,
/// An area the backend added after this build; no module maps to it.
unknown,
} }
@@ -7,15 +7,25 @@ part of 'get_breakers_response.dart';
// ************************************************************************** // **************************************************************************
GetBreakersResponse _$GetBreakersResponseFromJson(Map<String, dynamic> json) => GetBreakersResponse _$GetBreakersResponseFromJson(Map<String, dynamic> json) =>
GetBreakersResponse( $checkedCreate('GetBreakersResponse', json, ($checkedConvert) {
(json['rules'] as List<dynamic>?) final val = GetBreakersResponse(
?.map((e) => BreakerRule.fromJson(e as Map<String, dynamic>)) $checkedConvert(
.toList() ?? 'rules',
[], (v) =>
) (v as List<dynamic>?)
..headers = (json['headers'] as Map<String, dynamic>?)?.map( ?.map((e) => BreakerRule.fromJson(e as Map<String, dynamic>))
(k, e) => MapEntry(k, e as String), .toList() ??
[],
),
); );
$checkedConvert(
'headers',
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
),
);
return val;
});
Map<String, dynamic> _$GetBreakersResponseToJson( Map<String, dynamic> _$GetBreakersResponseToJson(
GetBreakersResponse instance, GetBreakersResponse instance,
@@ -24,13 +34,26 @@ Map<String, dynamic> _$GetBreakersResponseToJson(
'rules': instance.rules.map((e) => e.toJson()).toList(), 'rules': instance.rules.map((e) => e.toJson()).toList(),
}; };
BreakerRule _$BreakerRuleFromJson(Map<String, dynamic> json) => BreakerRule( BreakerRule _$BreakerRuleFromJson(Map<String, dynamic> json) =>
(json['areas'] as List<dynamic>) $checkedCreate('BreakerRule', json, ($checkedConvert) {
.map((e) => $enumDecode(_$BreakerAreaEnumMap, e)) final val = BreakerRule(
.toList(), $checkedConvert(
json['message'] as String, 'areas',
(json['maxBuild'] as num?)?.toInt(), (v) => (v as List<dynamic>)
); .map(
(e) => $enumDecode(
_$BreakerAreaEnumMap,
e,
unknownValue: BreakerArea.unknown,
),
)
.toList(),
),
$checkedConvert('message', (v) => v as String),
$checkedConvert('maxBuild', (v) => (v as num?)?.toInt()),
);
return val;
});
Map<String, dynamic> _$BreakerRuleToJson(BreakerRule instance) => Map<String, dynamic> _$BreakerRuleToJson(BreakerRule instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -52,4 +75,5 @@ const _$BreakerAreaEnumMap = {
BreakerArea.dates: 'DATES', BreakerArea.dates: 'DATES',
BreakerArea.feedback: 'FEEDBACK', BreakerArea.feedback: 'FEEDBACK',
BreakerArea.more: 'MORE', BreakerArea.more: 'MORE',
BreakerArea.unknown: 'unknown',
}; };
@@ -8,13 +8,28 @@ part of 'get_capabilities_response.dart';
CapabilitiesResponse _$CapabilitiesResponseFromJson( CapabilitiesResponse _$CapabilitiesResponseFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => CapabilitiesResponse( ) => $checkedCreate('CapabilitiesResponse', json, ($checkedConvert) {
viewForeignTimetables: json['viewForeignTimetables'] as bool? ?? false, final val = CapabilitiesResponse(
pushNotifications: json['pushNotifications'] as bool? ?? false, viewForeignTimetables: $checkedConvert(
timetablePastDays: (json['timetablePastDays'] as num?)?.toInt(), 'viewForeignTimetables',
timetableFutureDays: (json['timetableFutureDays'] as num?)?.toInt(), (v) => v as bool? ?? false,
userType: json['userType'] as String?, ),
); pushNotifications: $checkedConvert(
'pushNotifications',
(v) => v as bool? ?? false,
),
timetablePastDays: $checkedConvert(
'timetablePastDays',
(v) => (v as num?)?.toInt(),
),
timetableFutureDays: $checkedConvert(
'timetableFutureDays',
(v) => (v as num?)?.toInt(),
),
userType: $checkedConvert('userType', (v) => v as String?),
);
return val;
});
Map<String, dynamic> _$CapabilitiesResponseToJson( Map<String, dynamic> _$CapabilitiesResponseToJson(
CapabilitiesResponse instance, CapabilitiesResponse instance,
@@ -7,14 +7,20 @@ part of 'get_ticker_response.dart';
// ************************************************************************** // **************************************************************************
TickerResponse _$TickerResponseFromJson(Map<String, dynamic> json) => TickerResponse _$TickerResponseFromJson(Map<String, dynamic> json) =>
TickerResponse( $checkedCreate('TickerResponse', json, ($checkedConvert) {
schemaVersion: (json['schemaVersion'] as num?)?.toInt() ?? 1, final val = TickerResponse(
available: json['available'] as bool? ?? false, schemaVersion: $checkedConvert(
hash: json['hash'] as String?, 'schemaVersion',
publishedAt: json['publishedAt'] as String?, (v) => (v as num?)?.toInt() ?? 1,
webUrl: json['webUrl'] as String? ?? '/ticker', ),
content: json['content'] as Map<String, dynamic>?, available: $checkedConvert('available', (v) => v as bool? ?? false),
); hash: $checkedConvert('hash', (v) => v as String?),
publishedAt: $checkedConvert('publishedAt', (v) => v as String?),
webUrl: $checkedConvert('webUrl', (v) => v as String? ?? '/ticker'),
content: $checkedConvert('content', (v) => v as Map<String, dynamic>?),
);
return val;
});
Map<String, dynamic> _$TickerResponseToJson(TickerResponse instance) => Map<String, dynamic> _$TickerResponseToJson(TickerResponse instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -7,15 +7,26 @@ part of 'get_ticker_nav_response.dart';
// ************************************************************************** // **************************************************************************
TickerNavResponse _$TickerNavResponseFromJson(Map<String, dynamic> json) => TickerNavResponse _$TickerNavResponseFromJson(Map<String, dynamic> json) =>
TickerNavResponse( $checkedCreate('TickerNavResponse', json, ($checkedConvert) {
schemaVersion: (json['schemaVersion'] as num?)?.toInt() ?? 1, final val = TickerNavResponse(
navHash: json['navHash'] as String? ?? '', schemaVersion: $checkedConvert(
sections: 'schemaVersion',
(json['sections'] as List<dynamic>?) (v) => (v as num?)?.toInt() ?? 1,
?.map((e) => TickerNavSection.fromJson(e as Map<String, dynamic>)) ),
.toList() ?? navHash: $checkedConvert('navHash', (v) => v as String? ?? ''),
[], sections: $checkedConvert(
); 'sections',
(v) =>
(v as List<dynamic>?)
?.map(
(e) => TickerNavSection.fromJson(e as Map<String, dynamic>),
)
.toList() ??
[],
),
);
return val;
});
Map<String, dynamic> _$TickerNavResponseToJson(TickerNavResponse instance) => Map<String, dynamic> _$TickerNavResponseToJson(TickerNavResponse instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -25,14 +36,22 @@ Map<String, dynamic> _$TickerNavResponseToJson(TickerNavResponse instance) =>
}; };
TickerNavSection _$TickerNavSectionFromJson(Map<String, dynamic> json) => TickerNavSection _$TickerNavSectionFromJson(Map<String, dynamic> json) =>
TickerNavSection( $checkedCreate('TickerNavSection', json, ($checkedConvert) {
title: json['title'] as String? ?? '', final val = TickerNavSection(
pages: title: $checkedConvert('title', (v) => v as String? ?? ''),
(json['pages'] as List<dynamic>?) pages: $checkedConvert(
?.map((e) => TickerNavPage.fromJson(e as Map<String, dynamic>)) 'pages',
.toList() ?? (v) =>
[], (v as List<dynamic>?)
); ?.map(
(e) => TickerNavPage.fromJson(e as Map<String, dynamic>),
)
.toList() ??
[],
),
);
return val;
});
Map<String, dynamic> _$TickerNavSectionToJson(TickerNavSection instance) => Map<String, dynamic> _$TickerNavSectionToJson(TickerNavSection instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -41,15 +60,21 @@ Map<String, dynamic> _$TickerNavSectionToJson(TickerNavSection instance) =>
}; };
TickerNavPage _$TickerNavPageFromJson(Map<String, dynamic> json) => TickerNavPage _$TickerNavPageFromJson(Map<String, dynamic> json) =>
TickerNavPage( $checkedCreate('TickerNavPage', json, ($checkedConvert) {
title: json['title'] as String? ?? '', final val = TickerNavPage(
slug: json['slug'] as String? ?? '', title: $checkedConvert('title', (v) => v as String? ?? ''),
kind: json['kind'] as String? ?? 'CONTENT', slug: $checkedConvert('slug', (v) => v as String? ?? ''),
hash: json['hash'] as String?, kind: $checkedConvert('kind', (v) => v as String? ?? 'CONTENT'),
externalUrl: json['externalUrl'] as String?, hash: $checkedConvert('hash', (v) => v as String?),
externalUrlNewTab: json['externalUrlNewTab'] as bool?, externalUrl: $checkedConvert('externalUrl', (v) => v as String?),
fileUrl: json['fileUrl'] as String?, externalUrlNewTab: $checkedConvert(
); 'externalUrlNewTab',
(v) => v as bool?,
),
fileUrl: $checkedConvert('fileUrl', (v) => v as String?),
);
return val;
});
Map<String, dynamic> _$TickerNavPageToJson(TickerNavPage instance) => Map<String, dynamic> _$TickerNavPageToJson(TickerNavPage instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -2,8 +2,8 @@ import 'dart:convert';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../../../errors/app_exception.dart';
import '../../../errors/ticker_content_unavailable_exception.dart'; import '../../../errors/ticker_content_unavailable_exception.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_query.dart'; import '../../marianumconnect_query.dart';
import 'get_ticker_page_response.dart'; import 'get_ticker_page_response.dart';
@@ -14,26 +14,20 @@ import 'get_ticker_page_response.dart';
/// 404 `{ "error": "CONTENT_UNAVAILABLE", "webUrl": ... }`; that case is mapped /// 404 `{ "error": "CONTENT_UNAVAILABLE", "webUrl": ... }`; that case is mapped
/// to a dedicated [TickerContentUnavailableException] carrying the browser /// to a dedicated [TickerContentUnavailableException] carrying the browser
/// fallback URL, so the detail screen can offer "open in browser" instead of a /// fallback URL, so the detail screen can offer "open in browser" instead of a
/// generic error. The bespoke 404 handling is why this keeps its own try/catch /// generic error.
/// instead of the base [guard].
class GetTickerPage extends MarianumConnectQuery { class GetTickerPage extends MarianumConnectQuery {
final String slug; final String slug;
GetTickerPage(this.slug, {super.dio}); GetTickerPage(this.slug, {super.dio});
Future<TickerPageResponse> run() async { Future<TickerPageResponse> run() => getObject(
try { 'ticker/pages/${Uri.encodeComponent(slug)}',
final response = await dio.get<Map<String, dynamic>>( TickerPageResponse.fromJson,
endpoint('ticker/pages/${Uri.encodeComponent(slug)}'), );
);
return TickerPageResponse.fromJson(response.data!);
} on DioException catch (e) {
throw _mapError(e);
}
}
Object _mapError(DioException e) { @override
final response = e.response; AppException mapError(DioException error) {
final response = error.response;
if (response?.statusCode == 404) { if (response?.statusCode == 404) {
final body = _asMap(response?.data); final body = _asMap(response?.data);
if (body != null && body['error'] == 'CONTENT_UNAVAILABLE') { if (body != null && body['error'] == 'CONTENT_UNAVAILABLE') {
@@ -44,7 +38,7 @@ class GetTickerPage extends MarianumConnectQuery {
); );
} }
} }
return mapMarianumConnectError(e); return super.mapError(error);
} }
Map<String, dynamic>? _asMap(dynamic data) { Map<String, dynamic>? _asMap(dynamic data) {
@@ -7,22 +7,31 @@ part of 'get_ticker_page_response.dart';
// ************************************************************************** // **************************************************************************
TickerPageResponse _$TickerPageResponseFromJson(Map<String, dynamic> json) => TickerPageResponse _$TickerPageResponseFromJson(Map<String, dynamic> json) =>
TickerPageResponse( $checkedCreate('TickerPageResponse', json, ($checkedConvert) {
schemaVersion: (json['schemaVersion'] as num?)?.toInt() ?? 1, final val = TickerPageResponse(
slug: json['slug'] as String?, schemaVersion: $checkedConvert(
title: json['title'] as String?, 'schemaVersion',
kind: json['kind'] as String? ?? 'CONTENT', (v) => (v as num?)?.toInt() ?? 1,
content: json['content'] as Map<String, dynamic>?, ),
externalUrl: json['externalUrl'] as String?, slug: $checkedConvert('slug', (v) => v as String?),
externalUrlNewTab: json['externalUrlNewTab'] as bool?, title: $checkedConvert('title', (v) => v as String?),
fileUrl: json['fileUrl'] as String?, kind: $checkedConvert('kind', (v) => v as String? ?? 'CONTENT'),
contentType: json['contentType'] as String?, content: $checkedConvert('content', (v) => v as Map<String, dynamic>?),
filename: json['filename'] as String?, externalUrl: $checkedConvert('externalUrl', (v) => v as String?),
hash: json['hash'] as String?, externalUrlNewTab: $checkedConvert(
webUrl: json['webUrl'] as String?, 'externalUrlNewTab',
publishedAt: json['publishedAt'] as String?, (v) => v as bool?,
fileFetchedAt: json['fileFetchedAt'] as String?, ),
); fileUrl: $checkedConvert('fileUrl', (v) => v as String?),
contentType: $checkedConvert('contentType', (v) => v as String?),
filename: $checkedConvert('filename', (v) => v as String?),
hash: $checkedConvert('hash', (v) => v as String?),
webUrl: $checkedConvert('webUrl', (v) => v as String?),
publishedAt: $checkedConvert('publishedAt', (v) => v as String?),
fileFetchedAt: $checkedConvert('fileFetchedAt', (v) => v as String?),
);
return val;
});
Map<String, dynamic> _$TickerPageResponseToJson(TickerPageResponse instance) => Map<String, dynamic> _$TickerPageResponseToJson(TickerPageResponse instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -1,10 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import '../../client_platform.dart';
import 'report_client_error.dart'; import 'report_client_error.dart';
/// Fire-and-forget bridge from the app's global error handlers to the /// Fire-and-forget bridge from the app's global error handlers to the
@@ -36,6 +36,15 @@ class ClientErrorReporter {
); );
} }
/// Server entries the decoder could not map (see `decodeListLenient`): the
/// app keeps working without them, the report says which field drifted.
static void reportDecodeWarnings(String context, Iterable<String> warnings) {
for (final warning in warnings) {
log('$context: skipped entry: $warning');
_report(errorType: 'decode', message: warning, context: context);
}
}
static void _report({ static void _report({
required String errorType, required String errorType,
required String message, required String message,
@@ -61,7 +70,7 @@ class ClientErrorReporter {
message: message, message: message,
stacktrace: stacktrace, stacktrace: stacktrace,
context: context, context: context,
platform: _platform(), platform: clientPlatform(),
appVersion: _appVersion, appVersion: _appVersion,
); );
} catch (e) { } catch (e) {
@@ -73,10 +82,4 @@ class ClientErrorReporter {
final normalized = message.toLowerCase().replaceAll(RegExp(r'\d+'), '#'); final normalized = message.toLowerCase().replaceAll(RegExp(r'\d+'), '#');
return '$errorType|$normalized|${context ?? ''}'; return '$errorType|$normalized|${context ?? ''}';
} }
static String? _platform() {
if (Platform.isAndroid) return 'android';
if (Platform.isIOS) return 'ios';
return null;
}
} }
@@ -5,6 +5,7 @@ import 'dart:typed_data';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import '../../client_platform.dart';
import '../../marianumconnect_query.dart'; import '../../marianumconnect_query.dart';
/// Submits user feedback to MarianumConnect (`POST me/feedback`, bearer-auth). /// Submits user feedback to MarianumConnect (`POST me/feedback`, bearer-auth).
@@ -27,7 +28,7 @@ class SubmitFeedback extends MarianumConnectQuery {
'message': message, 'message': message,
'screenshot': ?screenshotBase64, 'screenshot': ?screenshotBase64,
'screenshotContentType': screenshot != null ? screenshotContentType : null, 'screenshotContentType': screenshot != null ? screenshotContentType : null,
'platform': _platform(), 'platform': clientPlatform(),
'appVersion': package.version, 'appVersion': package.version,
'appBuild': int.tryParse(package.buildNumber), 'appBuild': int.tryParse(package.buildNumber),
'deviceModel': await _deviceModel(), 'deviceModel': await _deviceModel(),
@@ -35,12 +36,6 @@ class SubmitFeedback extends MarianumConnectQuery {
); );
}); });
static String? _platform() {
if (Platform.isAndroid) return 'android';
if (Platform.isIOS) return 'ios';
return null;
}
static Future<String?> _deviceModel() async { static Future<String?> _deviceModel() async {
try { try {
final info = DeviceInfoPlugin(); final info = DeviceInfoPlugin();
@@ -1,5 +1,6 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../../../storage/keychain_write.dart';
import '../../../../utils/random_id.dart'; import '../../../../utils/random_id.dart';
/// A stable, anonymous per-install identifier for telemetry. Generated once on /// A stable, anonymous per-install identifier for telemetry. Generated once on
@@ -22,7 +23,7 @@ class TelemetryDeviceId {
return existing; return existing;
} }
final generated = randomHexId(); final generated = randomHexId();
await _storage.write(key: _key, value: generated); await _storage.writeRetrying(key: _key, value: generated);
_cached = generated; _cached = generated;
return generated; return generated;
} }
@@ -7,11 +7,14 @@ part of 'timetable_get_classes_response.dart';
// ************************************************************************** // **************************************************************************
McTimetableClass _$McTimetableClassFromJson(Map<String, dynamic> json) => McTimetableClass _$McTimetableClassFromJson(Map<String, dynamic> json) =>
McTimetableClass( $checkedCreate('McTimetableClass', json, ($checkedConvert) {
id: (json['id'] as num).toInt(), final val = McTimetableClass(
shortName: json['shortName'] as String, id: $checkedConvert('id', (v) => (v as num).toInt()),
longName: json['longName'] as String, shortName: $checkedConvert('shortName', (v) => v as String),
); longName: $checkedConvert('longName', (v) => v as String),
);
return val;
});
Map<String, dynamic> _$McTimetableClassToJson(McTimetableClass instance) => Map<String, dynamic> _$McTimetableClassToJson(McTimetableClass instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -22,15 +25,23 @@ Map<String, dynamic> _$McTimetableClassToJson(McTimetableClass instance) =>
TimetableGetClassesResponse _$TimetableGetClassesResponseFromJson( TimetableGetClassesResponse _$TimetableGetClassesResponseFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => ) => $checkedCreate('TimetableGetClassesResponse', json, ($checkedConvert) {
TimetableGetClassesResponse( final val = TimetableGetClassesResponse(
result: (json['result'] as List<dynamic>) result: $checkedConvert(
.map((e) => McTimetableClass.fromJson(e as Map<String, dynamic>)) 'result',
.toList(), (v) => (v as List<dynamic>)
) .map((e) => McTimetableClass.fromJson(e as Map<String, dynamic>))
..headers = (json['headers'] as Map<String, dynamic>?)?.map( .toList(),
(k, e) => MapEntry(k, e as String), ),
); );
$checkedConvert(
'headers',
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
),
);
return val;
});
Map<String, dynamic> _$TimetableGetClassesResponseToJson( Map<String, dynamic> _$TimetableGetClassesResponseToJson(
TimetableGetClassesResponse instance, TimetableGetClassesResponse instance,
@@ -8,15 +8,23 @@ part of 'timetable_get_holidays_response.dart';
TimetableGetHolidaysResponse _$TimetableGetHolidaysResponseFromJson( TimetableGetHolidaysResponse _$TimetableGetHolidaysResponseFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => ) => $checkedCreate('TimetableGetHolidaysResponse', json, ($checkedConvert) {
TimetableGetHolidaysResponse( final val = TimetableGetHolidaysResponse(
result: (json['result'] as List<dynamic>) result: $checkedConvert(
.map((e) => McHoliday.fromJson(e as Map<String, dynamic>)) 'result',
.toList(), (v) => (v as List<dynamic>)
) .map((e) => McHoliday.fromJson(e as Map<String, dynamic>))
..headers = (json['headers'] as Map<String, dynamic>?)?.map( .toList(),
(k, e) => MapEntry(k, e as String), ),
); );
$checkedConvert(
'headers',
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
),
);
return val;
});
Map<String, dynamic> _$TimetableGetHolidaysResponseToJson( Map<String, dynamic> _$TimetableGetHolidaysResponseToJson(
TimetableGetHolidaysResponse instance, TimetableGetHolidaysResponse instance,
@@ -6,11 +6,15 @@ part of 'timetable_get_rooms_response.dart';
// JsonSerializableGenerator // JsonSerializableGenerator
// ************************************************************************** // **************************************************************************
McRoom _$McRoomFromJson(Map<String, dynamic> json) => McRoom( McRoom _$McRoomFromJson(Map<String, dynamic> json) =>
id: (json['id'] as num).toInt(), $checkedCreate('McRoom', json, ($checkedConvert) {
shortName: json['shortName'] as String, final val = McRoom(
longName: json['longName'] as String, id: $checkedConvert('id', (v) => (v as num).toInt()),
); shortName: $checkedConvert('shortName', (v) => v as String),
longName: $checkedConvert('longName', (v) => v as String),
);
return val;
});
Map<String, dynamic> _$McRoomToJson(McRoom instance) => <String, dynamic>{ Map<String, dynamic> _$McRoomToJson(McRoom instance) => <String, dynamic>{
'id': instance.id, 'id': instance.id,
@@ -20,15 +24,23 @@ Map<String, dynamic> _$McRoomToJson(McRoom instance) => <String, dynamic>{
TimetableGetRoomsResponse _$TimetableGetRoomsResponseFromJson( TimetableGetRoomsResponse _$TimetableGetRoomsResponseFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => ) => $checkedCreate('TimetableGetRoomsResponse', json, ($checkedConvert) {
TimetableGetRoomsResponse( final val = TimetableGetRoomsResponse(
result: (json['result'] as List<dynamic>) result: $checkedConvert(
.map((e) => McRoom.fromJson(e as Map<String, dynamic>)) 'result',
.toList(), (v) => (v as List<dynamic>)
) .map((e) => McRoom.fromJson(e as Map<String, dynamic>))
..headers = (json['headers'] as Map<String, dynamic>?)?.map( .toList(),
(k, e) => MapEntry(k, e as String), ),
); );
$checkedConvert(
'headers',
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
),
);
return val;
});
Map<String, dynamic> _$TimetableGetRoomsResponseToJson( Map<String, dynamic> _$TimetableGetRoomsResponseToJson(
TimetableGetRoomsResponse instance, TimetableGetRoomsResponse instance,
@@ -8,20 +8,27 @@ part of 'timetable_get_schoolyear_response.dart';
TimetableGetSchoolyearResponse _$TimetableGetSchoolyearResponseFromJson( TimetableGetSchoolyearResponse _$TimetableGetSchoolyearResponseFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => ) => $checkedCreate('TimetableGetSchoolyearResponse', json, ($checkedConvert) {
TimetableGetSchoolyearResponse( final val = TimetableGetSchoolyearResponse(
id: (json['id'] as num).toInt(), id: $checkedConvert('id', (v) => (v as num).toInt()),
name: json['name'] as String, name: $checkedConvert('name', (v) => v as String),
startDate: TimetableGetSchoolyearResponse._dateFromJson( startDate: $checkedConvert(
json['startDate'] as String, 'startDate',
), (v) => TimetableGetSchoolyearResponse._dateFromJson(v as String),
endDate: TimetableGetSchoolyearResponse._dateFromJson( ),
json['endDate'] as String, endDate: $checkedConvert(
), 'endDate',
) (v) => TimetableGetSchoolyearResponse._dateFromJson(v as String),
..headers = (json['headers'] as Map<String, dynamic>?)?.map( ),
(k, e) => MapEntry(k, e as String), );
); $checkedConvert(
'headers',
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
),
);
return val;
});
Map<String, dynamic> _$TimetableGetSchoolyearResponseToJson( Map<String, dynamic> _$TimetableGetSchoolyearResponseToJson(
TimetableGetSchoolyearResponse instance, TimetableGetSchoolyearResponse instance,
@@ -7,12 +7,15 @@ part of 'timetable_get_students_response.dart';
// ************************************************************************** // **************************************************************************
McTimetableStudent _$McTimetableStudentFromJson(Map<String, dynamic> json) => McTimetableStudent _$McTimetableStudentFromJson(Map<String, dynamic> json) =>
McTimetableStudent( $checkedCreate('McTimetableStudent', json, ($checkedConvert) {
id: (json['id'] as num).toInt(), final val = McTimetableStudent(
firstName: json['firstName'] as String, id: $checkedConvert('id', (v) => (v as num).toInt()),
lastName: json['lastName'] as String, firstName: $checkedConvert('firstName', (v) => v as String),
displayName: json['displayName'] as String, lastName: $checkedConvert('lastName', (v) => v as String),
); displayName: $checkedConvert('displayName', (v) => v as String),
);
return val;
});
Map<String, dynamic> _$McTimetableStudentToJson(McTimetableStudent instance) => Map<String, dynamic> _$McTimetableStudentToJson(McTimetableStudent instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -24,15 +27,23 @@ Map<String, dynamic> _$McTimetableStudentToJson(McTimetableStudent instance) =>
TimetableGetStudentsResponse _$TimetableGetStudentsResponseFromJson( TimetableGetStudentsResponse _$TimetableGetStudentsResponseFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => ) => $checkedCreate('TimetableGetStudentsResponse', json, ($checkedConvert) {
TimetableGetStudentsResponse( final val = TimetableGetStudentsResponse(
result: (json['result'] as List<dynamic>) result: $checkedConvert(
.map((e) => McTimetableStudent.fromJson(e as Map<String, dynamic>)) 'result',
.toList(), (v) => (v as List<dynamic>)
) .map((e) => McTimetableStudent.fromJson(e as Map<String, dynamic>))
..headers = (json['headers'] as Map<String, dynamic>?)?.map( .toList(),
(k, e) => MapEntry(k, e as String), ),
); );
$checkedConvert(
'headers',
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
),
);
return val;
});
Map<String, dynamic> _$TimetableGetStudentsResponseToJson( Map<String, dynamic> _$TimetableGetStudentsResponseToJson(
TimetableGetStudentsResponse instance, TimetableGetStudentsResponse instance,
@@ -6,12 +6,16 @@ part of 'timetable_get_subjects_response.dart';
// JsonSerializableGenerator // JsonSerializableGenerator
// ************************************************************************** // **************************************************************************
McSubject _$McSubjectFromJson(Map<String, dynamic> json) => McSubject( McSubject _$McSubjectFromJson(Map<String, dynamic> json) =>
id: (json['id'] as num).toInt(), $checkedCreate('McSubject', json, ($checkedConvert) {
shortName: json['shortName'] as String, final val = McSubject(
longName: json['longName'] as String, id: $checkedConvert('id', (v) => (v as num).toInt()),
color: json['color'] as String?, shortName: $checkedConvert('shortName', (v) => v as String),
); longName: $checkedConvert('longName', (v) => v as String),
color: $checkedConvert('color', (v) => v as String?),
);
return val;
});
Map<String, dynamic> _$McSubjectToJson(McSubject instance) => <String, dynamic>{ Map<String, dynamic> _$McSubjectToJson(McSubject instance) => <String, dynamic>{
'id': instance.id, 'id': instance.id,
@@ -22,15 +26,23 @@ Map<String, dynamic> _$McSubjectToJson(McSubject instance) => <String, dynamic>{
TimetableGetSubjectsResponse _$TimetableGetSubjectsResponseFromJson( TimetableGetSubjectsResponse _$TimetableGetSubjectsResponseFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => ) => $checkedCreate('TimetableGetSubjectsResponse', json, ($checkedConvert) {
TimetableGetSubjectsResponse( final val = TimetableGetSubjectsResponse(
result: (json['result'] as List<dynamic>) result: $checkedConvert(
.map((e) => McSubject.fromJson(e as Map<String, dynamic>)) 'result',
.toList(), (v) => (v as List<dynamic>)
) .map((e) => McSubject.fromJson(e as Map<String, dynamic>))
..headers = (json['headers'] as Map<String, dynamic>?)?.map( .toList(),
(k, e) => MapEntry(k, e as String), ),
); );
$checkedConvert(
'headers',
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
),
);
return val;
});
Map<String, dynamic> _$TimetableGetSubjectsResponseToJson( Map<String, dynamic> _$TimetableGetSubjectsResponseToJson(
TimetableGetSubjectsResponse instance, TimetableGetSubjectsResponse instance,
@@ -8,11 +8,14 @@ part of 'timetable_get_teachers_response.dart';
McTimetableTeacherElement _$McTimetableTeacherElementFromJson( McTimetableTeacherElement _$McTimetableTeacherElementFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => McTimetableTeacherElement( ) => $checkedCreate('McTimetableTeacherElement', json, ($checkedConvert) {
id: (json['id'] as num).toInt(), final val = McTimetableTeacherElement(
shortName: json['shortName'] as String, id: $checkedConvert('id', (v) => (v as num).toInt()),
displayName: json['displayName'] as String, shortName: $checkedConvert('shortName', (v) => v as String),
); displayName: $checkedConvert('displayName', (v) => v as String),
);
return val;
});
Map<String, dynamic> _$McTimetableTeacherElementToJson( Map<String, dynamic> _$McTimetableTeacherElementToJson(
McTimetableTeacherElement instance, McTimetableTeacherElement instance,
@@ -24,18 +27,26 @@ Map<String, dynamic> _$McTimetableTeacherElementToJson(
TimetableGetTeachersResponse _$TimetableGetTeachersResponseFromJson( TimetableGetTeachersResponse _$TimetableGetTeachersResponseFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => ) => $checkedCreate('TimetableGetTeachersResponse', json, ($checkedConvert) {
TimetableGetTeachersResponse( final val = TimetableGetTeachersResponse(
result: (json['result'] as List<dynamic>) result: $checkedConvert(
.map( 'result',
(e) => (v) => (v as List<dynamic>)
McTimetableTeacherElement.fromJson(e as Map<String, dynamic>), .map(
) (e) =>
.toList(), McTimetableTeacherElement.fromJson(e as Map<String, dynamic>),
) )
..headers = (json['headers'] as Map<String, dynamic>?)?.map( .toList(),
(k, e) => MapEntry(k, e as String), ),
); );
$checkedConvert(
'headers',
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
),
);
return val;
});
Map<String, dynamic> _$TimetableGetTeachersResponseToJson( Map<String, dynamic> _$TimetableGetTeachersResponseToJson(
TimetableGetTeachersResponse instance, TimetableGetTeachersResponse instance,
@@ -7,12 +7,24 @@ part of 'timetable_get_timegrid_response.dart';
// ************************************************************************** // **************************************************************************
McTimegridUnit _$McTimegridUnitFromJson(Map<String, dynamic> json) => McTimegridUnit _$McTimegridUnitFromJson(Map<String, dynamic> json) =>
McTimegridUnit( $checkedCreate('McTimegridUnit', json, ($checkedConvert) {
dayOfWeek: _dayFromJson(json['dayOfWeek'] as String), final val = McTimegridUnit(
label: json['label'] as String, dayOfWeek: $checkedConvert(
startTime: McTimegridUnit._timeFromJson(json['startTime'] as String), 'dayOfWeek',
endTime: McTimegridUnit._timeFromJson(json['endTime'] as String), (v) => _dayFromJson(v as String),
); ),
label: $checkedConvert('label', (v) => v as String),
startTime: $checkedConvert(
'startTime',
(v) => McTimegridUnit._timeFromJson(v as String),
),
endTime: $checkedConvert(
'endTime',
(v) => McTimegridUnit._timeFromJson(v as String),
),
);
return val;
});
Map<String, dynamic> _$McTimegridUnitToJson(McTimegridUnit instance) => Map<String, dynamic> _$McTimegridUnitToJson(McTimegridUnit instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -24,15 +36,23 @@ Map<String, dynamic> _$McTimegridUnitToJson(McTimegridUnit instance) =>
TimetableGetTimegridResponse _$TimetableGetTimegridResponseFromJson( TimetableGetTimegridResponse _$TimetableGetTimegridResponseFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => ) => $checkedCreate('TimetableGetTimegridResponse', json, ($checkedConvert) {
TimetableGetTimegridResponse( final val = TimetableGetTimegridResponse(
result: (json['result'] as List<dynamic>) result: $checkedConvert(
.map((e) => McTimegridUnit.fromJson(e as Map<String, dynamic>)) 'result',
.toList(), (v) => (v as List<dynamic>)
) .map((e) => McTimegridUnit.fromJson(e as Map<String, dynamic>))
..headers = (json['headers'] as Map<String, dynamic>?)?.map( .toList(),
(k, e) => MapEntry(k, e as String), ),
); );
$checkedConvert(
'headers',
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
),
);
return val;
});
Map<String, dynamic> _$TimetableGetTimegridResponseToJson( Map<String, dynamic> _$TimetableGetTimegridResponseToJson(
TimetableGetTimegridResponse instance, TimetableGetTimegridResponse instance,
@@ -7,14 +7,23 @@ part of 'timetable_get_week_response.dart';
// ************************************************************************** // **************************************************************************
McTimetableTeacher _$McTimetableTeacherFromJson(Map<String, dynamic> json) => McTimetableTeacher _$McTimetableTeacherFromJson(Map<String, dynamic> json) =>
McTimetableTeacher( $checkedCreate('McTimetableTeacher', json, ($checkedConvert) {
shortName: json['shortName'] as String, final val = McTimetableTeacher(
displayName: json['displayName'] as String, shortName: $checkedConvert('shortName', (v) => v as String),
originalShortName: json['originalShortName'] as String?, displayName: $checkedConvert('displayName', (v) => v as String),
originalDisplayName: json['originalDisplayName'] as String?, originalShortName: $checkedConvert(
id: (json['id'] as num?)?.toInt(), 'originalShortName',
originalId: (json['originalId'] as num?)?.toInt(), (v) => v as String?,
); ),
originalDisplayName: $checkedConvert(
'originalDisplayName',
(v) => v as String?,
),
id: $checkedConvert('id', (v) => (v as num?)?.toInt()),
originalId: $checkedConvert('originalId', (v) => (v as num?)?.toInt()),
);
return val;
});
Map<String, dynamic> _$McTimetableTeacherToJson(McTimetableTeacher instance) => Map<String, dynamic> _$McTimetableTeacherToJson(McTimetableTeacher instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -26,31 +35,53 @@ Map<String, dynamic> _$McTimetableTeacherToJson(McTimetableTeacher instance) =>
'originalId': instance.originalId, 'originalId': instance.originalId,
}; };
McTimetableEntry _$McTimetableEntryFromJson(Map<String, dynamic> json) => McTimetableEntry _$McTimetableEntryFromJson(
McTimetableEntry( Map<String, dynamic> json,
id: (json['id'] as num).toInt(), ) => $checkedCreate('McTimetableEntry', json, ($checkedConvert) {
date: McTimetableEntry._dateFromJson(json['date'] as String), final val = McTimetableEntry(
startTime: McTimetableEntry._timeFromJson(json['startTime'] as String), id: $checkedConvert('id', (v) => (v as num).toInt()),
endTime: McTimetableEntry._timeFromJson(json['endTime'] as String), date: $checkedConvert(
subjects: (json['subjects'] as List<dynamic>) 'date',
.map((e) => e as String) (v) => McTimetableEntry._dateFromJson(v as String),
.toList(), ),
teachers: (json['teachers'] as List<dynamic>) startTime: $checkedConvert(
'startTime',
(v) => McTimetableEntry._timeFromJson(v as String),
),
endTime: $checkedConvert(
'endTime',
(v) => McTimetableEntry._timeFromJson(v as String),
),
subjects: $checkedConvert(
'subjects',
(v) => (v as List<dynamic>).map((e) => e as String).toList(),
),
teachers: $checkedConvert(
'teachers',
(v) => (v as List<dynamic>)
.map((e) => McTimetableTeacher.fromJson(e as Map<String, dynamic>)) .map((e) => McTimetableTeacher.fromJson(e as Map<String, dynamic>))
.toList(), .toList(),
rooms: (json['rooms'] as List<dynamic>).map((e) => e as String).toList(), ),
classNames: (json['classNames'] as List<dynamic>) rooms: $checkedConvert(
.map((e) => e as String) 'rooms',
.toList(), (v) => (v as List<dynamic>).map((e) => e as String).toList(),
lessonType: json['lessonType'] as String, ),
status: json['status'] as String, classNames: $checkedConvert(
substitutionText: json['substitutionText'] as String?, 'classNames',
lessonText: json['lessonText'] as String?, (v) => (v as List<dynamic>).map((e) => e as String).toList(),
infoText: json['infoText'] as String?, ),
classIds: (json['classIds'] as List<dynamic>?) lessonType: $checkedConvert('lessonType', (v) => v as String),
?.map((e) => (e as num).toInt()) status: $checkedConvert('status', (v) => v as String),
.toList(), substitutionText: $checkedConvert('substitutionText', (v) => v as String?),
); lessonText: $checkedConvert('lessonText', (v) => v as String?),
infoText: $checkedConvert('infoText', (v) => v as String?),
classIds: $checkedConvert(
'classIds',
(v) => (v as List<dynamic>?)?.map((e) => (e as num).toInt()).toList(),
),
);
return val;
});
Map<String, dynamic> _$McTimetableEntryToJson(McTimetableEntry instance) => Map<String, dynamic> _$McTimetableEntryToJson(McTimetableEntry instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -72,17 +103,31 @@ Map<String, dynamic> _$McTimetableEntryToJson(McTimetableEntry instance) =>
TimetableGetWeekResponse _$TimetableGetWeekResponseFromJson( TimetableGetWeekResponse _$TimetableGetWeekResponseFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => ) => $checkedCreate('TimetableGetWeekResponse', json, ($checkedConvert) {
TimetableGetWeekResponse( final val = TimetableGetWeekResponse(
from: McTimetableEntry._dateFromJson(json['from'] as String), from: $checkedConvert(
until: McTimetableEntry._dateFromJson(json['until'] as String), 'from',
entries: (json['entries'] as List<dynamic>) (v) => McTimetableEntry._dateFromJson(v as String),
.map((e) => McTimetableEntry.fromJson(e as Map<String, dynamic>)) ),
.toList(), until: $checkedConvert(
) 'until',
..headers = (json['headers'] as Map<String, dynamic>?)?.map( (v) => McTimetableEntry._dateFromJson(v as String),
(k, e) => MapEntry(k, e as String), ),
); entries: $checkedConvert(
'entries',
(v) => (v as List<dynamic>)
.map((e) => McTimetableEntry.fromJson(e as Map<String, dynamic>))
.toList(),
),
);
$checkedConvert(
'headers',
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
),
);
return val;
});
Map<String, dynamic> _$TimetableGetWeekResponseToJson( Map<String, dynamic> _$TimetableGetWeekResponseToJson(
TimetableGetWeekResponse instance, TimetableGetWeekResponse instance,
@@ -7,13 +7,16 @@ part of 'user_search_response.dart';
// ************************************************************************** // **************************************************************************
McUserSearchResult _$McUserSearchResultFromJson(Map<String, dynamic> json) => McUserSearchResult _$McUserSearchResultFromJson(Map<String, dynamic> json) =>
McUserSearchResult( $checkedCreate('McUserSearchResult', json, ($checkedConvert) {
username: json['username'] as String, final val = McUserSearchResult(
firstName: json['firstName'] as String, username: $checkedConvert('username', (v) => v as String),
lastName: json['lastName'] as String, firstName: $checkedConvert('firstName', (v) => v as String),
userType: json['userType'] as String, lastName: $checkedConvert('lastName', (v) => v as String),
className: json['className'] as String?, userType: $checkedConvert('userType', (v) => v as String),
); className: $checkedConvert('className', (v) => v as String?),
);
return val;
});
Map<String, dynamic> _$McUserSearchResultToJson(McUserSearchResult instance) => Map<String, dynamic> _$McUserSearchResultToJson(McUserSearchResult instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -25,14 +28,25 @@ Map<String, dynamic> _$McUserSearchResultToJson(McUserSearchResult instance) =>
}; };
UserSearchResponse _$UserSearchResponseFromJson(Map<String, dynamic> json) => UserSearchResponse _$UserSearchResponseFromJson(Map<String, dynamic> json) =>
UserSearchResponse( $checkedCreate('UserSearchResponse', json, ($checkedConvert) {
result: (json['result'] as List<dynamic>) final val = UserSearchResponse(
.map((e) => McUserSearchResult.fromJson(e as Map<String, dynamic>)) result: $checkedConvert(
.toList(), 'result',
) (v) => (v as List<dynamic>)
..headers = (json['headers'] as Map<String, dynamic>?)?.map( .map(
(k, e) => MapEntry(k, e as String), (e) => McUserSearchResult.fromJson(e as Map<String, dynamic>),
)
.toList(),
),
); );
$checkedConvert(
'headers',
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
),
);
return val;
});
Map<String, dynamic> _$UserSearchResponseToJson(UserSearchResponse instance) => Map<String, dynamic> _$UserSearchResponseToJson(UserSearchResponse instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -8,18 +8,33 @@ part of 'custom_timetable_event.dart';
CustomTimetableEvent _$CustomTimetableEventFromJson( CustomTimetableEvent _$CustomTimetableEventFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => CustomTimetableEvent( ) => $checkedCreate('CustomTimetableEvent', json, ($checkedConvert) {
id: json['id'] as String, final val = CustomTimetableEvent(
title: json['title'] as String, id: $checkedConvert('id', (v) => v as String),
description: json['description'] as String, title: $checkedConvert('title', (v) => v as String),
startDate: CustomTimetableEvent._dateFromJson(json['startDate'] as String), description: $checkedConvert('description', (v) => v as String),
endDate: CustomTimetableEvent._dateFromJson(json['endDate'] as String), startDate: $checkedConvert(
color: json['color'] as String?, 'startDate',
rrule: json['rrule'] as String, (v) => CustomTimetableEvent._dateFromJson(v as String),
skipHolidays: json['skipHolidays'] as bool? ?? false, ),
createdAt: CustomTimetableEvent._dateFromJson(json['createdAt'] as String), endDate: $checkedConvert(
updatedAt: CustomTimetableEvent._dateFromJson(json['updatedAt'] as String), 'endDate',
); (v) => CustomTimetableEvent._dateFromJson(v as String),
),
color: $checkedConvert('color', (v) => v as String?),
rrule: $checkedConvert('rrule', (v) => v as String),
skipHolidays: $checkedConvert('skipHolidays', (v) => v as bool? ?? false),
createdAt: $checkedConvert(
'createdAt',
(v) => CustomTimetableEvent._dateFromJson(v as String),
),
updatedAt: $checkedConvert(
'updatedAt',
(v) => CustomTimetableEvent._dateFromJson(v as String),
),
);
return val;
});
Map<String, dynamic> _$CustomTimetableEventToJson( Map<String, dynamic> _$CustomTimetableEventToJson(
CustomTimetableEvent instance, CustomTimetableEvent instance,
@@ -8,7 +8,12 @@ part of 'get_custom_timetable_event_params.dart';
GetCustomTimetableEventParams _$GetCustomTimetableEventParamsFromJson( GetCustomTimetableEventParams _$GetCustomTimetableEventParamsFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => GetCustomTimetableEventParams(json['user'] as String); ) => $checkedCreate('GetCustomTimetableEventParams', json, ($checkedConvert) {
final val = GetCustomTimetableEventParams(
$checkedConvert('user', (v) => v as String),
);
return val;
});
Map<String, dynamic> _$GetCustomTimetableEventParamsToJson( Map<String, dynamic> _$GetCustomTimetableEventParamsToJson(
GetCustomTimetableEventParams instance, GetCustomTimetableEventParams instance,
@@ -8,17 +8,23 @@ part of 'get_custom_timetable_event_response.dart';
GetCustomTimetableEventResponse _$GetCustomTimetableEventResponseFromJson( GetCustomTimetableEventResponse _$GetCustomTimetableEventResponseFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => ) => $checkedCreate('GetCustomTimetableEventResponse', json, ($checkedConvert) {
GetCustomTimetableEventResponse( final val = GetCustomTimetableEventResponse(
(json['events'] as List<dynamic>) $checkedConvert(
.map( 'events',
(e) => CustomTimetableEvent.fromJson(e as Map<String, dynamic>), (v) => (v as List<dynamic>)
) .map((e) => CustomTimetableEvent.fromJson(e as Map<String, dynamic>))
.toList(), .toList(),
) ),
..headers = (json['headers'] as Map<String, dynamic>?)?.map( );
(k, e) => MapEntry(k, e as String), $checkedConvert(
); 'headers',
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
),
);
return val;
});
Map<String, dynamic> _$GetCustomTimetableEventResponseToJson( Map<String, dynamic> _$GetCustomTimetableEventResponseToJson(
GetCustomTimetableEventResponse instance, GetCustomTimetableEventResponse instance,
@@ -8,7 +8,13 @@ part of 'remove_custom_timetable_event_params.dart';
RemoveCustomTimetableEventParams _$RemoveCustomTimetableEventParamsFromJson( RemoveCustomTimetableEventParams _$RemoveCustomTimetableEventParamsFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => RemoveCustomTimetableEventParams(json['id'] as String); ) =>
$checkedCreate('RemoveCustomTimetableEventParams', json, ($checkedConvert) {
final val = RemoveCustomTimetableEventParams(
$checkedConvert('id', (v) => v as String),
);
return val;
});
Map<String, dynamic> _$RemoveCustomTimetableEventParamsToJson( Map<String, dynamic> _$RemoveCustomTimetableEventParamsToJson(
RemoveCustomTimetableEventParams instance, RemoveCustomTimetableEventParams instance,
+30 -35
View File
@@ -80,6 +80,25 @@ class _AppState extends State<App> with WidgetsBindingObserver {
} }
} }
/// Shares one debounce key, so a later emit supersedes a pending publish.
void _scheduleWidgetPublish(TimetableState data, Duration delay) {
final settingsCubit = context.read<SettingsCubit>();
final capabilitiesCubit = context.read<CapabilitiesCubit>();
final epoch = SessionManager().sessionEpoch;
Debouncer.debounce(
'widgetPublish',
delay,
() => unawaited(
WidgetPublisher.publishFromBlocState(
data,
settings: settingsCubit.val(),
isTeacher: capabilitiesCubit.isTeacher,
epoch: epoch,
),
),
);
}
void _reportTelemetry() { void _reportTelemetry() {
if (!mounted) return; if (!mounted) return;
TelemetryHeartbeat.report( TelemetryHeartbeat.report(
@@ -125,13 +144,18 @@ class _AppState extends State<App> with WidgetsBindingObserver {
final pending = await WidgetNavigation.consumePendingTimetableTap(); final pending = await WidgetNavigation.consumePendingTimetableTap();
if (!pending || !mounted) return; if (!pending || !mounted) return;
// `withNavBar: false` routes sit on the root navigator above the // `withNavBar: false` routes sit on the root navigator above the
// bottom-nav; pop them so jumpToTab is actually visible. Stop at // bottom-nav; pop them so jumpToTab is actually visible.
// popups so open dialogs/sheets stay alive. _popToShell();
AppRoutes.goToTab(context, Modules.timetable);
}
/// Pops full-page routes back to the tab shell, stopping at popups so open
/// dialogs/sheets stay alive.
void _popToShell() {
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
if (navigator.canPop()) { if (navigator.canPop()) {
navigator.popUntil((route) => route.isFirst || route is PopupRoute); navigator.popUntil((route) => route.isFirst || route is PopupRoute);
} }
AppRoutes.goToTab(context, Modules.timetable);
} }
void _handlePendingShare() { void _handlePendingShare() {
@@ -140,10 +164,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
if (share == null || ShareIntentListener.instance.isShown(share)) return; if (share == null || ShareIntentListener.instance.isShown(share)) return;
// A second share would otherwise leave the previous share-flow page // A second share would otherwise leave the previous share-flow page
// on top with stale (already-cleared) file paths. // on top with stale (already-cleared) file paths.
final navigator = Navigator.of(context); _popToShell();
if (navigator.canPop()) {
navigator.popUntil((route) => route.isFirst || route is PopupRoute);
}
ShareIntentListener.instance.markShown(share); ShareIntentListener.instance.markShown(share);
AppRoutes.openShareTarget(context, share); AppRoutes.openShareTarget(context, share);
} }
@@ -166,27 +187,13 @@ class _AppState extends State<App> with WidgetsBindingObserver {
timetable.refresh(); timetable.refresh();
// Mirror BLoC updates into the home-screen widget without waiting // Mirror BLoC updates into the home-screen widget without waiting
// for the periodic background refresh. // for the periodic background refresh.
final settingsCubit = context.read<SettingsCubit>();
final capabilitiesCubit = context.read<CapabilitiesCubit>();
_timetableWidgetSync?.cancel(); _timetableWidgetSync?.cancel();
// Debounced: a refresh or week swipe emits several times in a row, and // Debounced: a refresh or week swipe emits several times in a row, and
// only the settled state is worth mirroring. // only the settled state is worth mirroring.
_timetableWidgetSync = timetable.stream.listen((state) { _timetableWidgetSync = timetable.stream.listen((state) {
final data = state.data; final data = state.data;
if (data is TimetableState && !state.isLoading) { if (data is TimetableState && !state.isLoading) {
final epoch = SessionManager().sessionEpoch; _scheduleWidgetPublish(data, const Duration(seconds: 1));
Debouncer.debounce(
'widgetPublish',
const Duration(seconds: 1),
() => unawaited(
WidgetPublisher.publishFromBlocState(
data,
settings: settingsCubit.val(),
isTeacher: capabilitiesCubit.isTeacher,
epoch: epoch,
),
),
);
} }
}); });
// Initial publish in case hydrated storage already has data. The widget // Initial publish in case hydrated storage already has data. The widget
@@ -194,19 +201,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
// frames are done (a fresh bloc emit in the meantime supersedes it). // frames are done (a fresh bloc emit in the meantime supersedes it).
final initialData = timetable.state.data; final initialData = timetable.state.data;
if (initialData is TimetableState) { if (initialData is TimetableState) {
final epoch = SessionManager().sessionEpoch; _scheduleWidgetPublish(initialData, const Duration(seconds: 3));
Debouncer.debounce(
'widgetPublish',
const Duration(seconds: 3),
() => unawaited(
WidgetPublisher.publishFromBlocState(
initialData,
settings: settingsCubit.val(),
isTeacher: capabilitiesCubit.isTeacher,
epoch: epoch,
),
),
);
} }
unawaited(_handlePendingWidgetNavigation()); unawaited(_handlePendingWidgetNavigation());
ShareIntentListener.pending.addListener(_handlePendingShare); ShareIntentListener.pending.addListener(_handlePendingShare);
+12 -42
View File
@@ -9,15 +9,13 @@ import '../api/marianumconnect/marianumconnect_endpoint.dart';
import '../api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_get.dart'; import '../api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_get.dart';
import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays.dart'; import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays.dart';
import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart'; import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart';
import '../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart';
import '../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart';
import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects.dart'; import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects.dart';
import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart'; import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart';
import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid.dart'; import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid.dart';
import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart'; import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart';
import '../api/marianumconnect/queries/timetable_get_week/timetable_get_week.dart'; import '../api/marianumconnect/queries/timetable_get_week/timetable_get_week.dart';
import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
import '../session/session.dart'; import '../extensions/date_time.dart';
import '../session/session_manager.dart'; import '../session/session_manager.dart';
import '../widget_data/widget_data_mapper.dart'; import '../widget_data/widget_data_mapper.dart';
import '../widget_data/widget_publisher.dart'; import '../widget_data/widget_publisher.dart';
@@ -81,16 +79,12 @@ class WidgetBackgroundTask {
/// Throws on fetch failure so the worker path can signal a retry. /// Throws on fetch failure so the worker path can signal a retry.
static Future<void> runRefreshNow({bool force = false}) async { static Future<void> runRefreshNow({bool force = false}) async {
await WidgetSync.ensureInitialized(); await WidgetSync.ensureInitialized();
Session? session; // Bounded: a hanging keystore read must not stall the caller's budget
try { // (FCM handler ~25s on iOS) forever.
// Bounded: a hanging keystore read must not stall the caller's budget final session = await SessionManager().waitForLoad().timeout(
// (FCM handler ~25s on iOS) forever. const Duration(seconds: 10),
session = await SessionManager().waitForLoad().timeout( onTimeout: () => null,
const Duration(seconds: 10), );
);
} on TimeoutException {
session = null;
}
if (session == null) { if (session == null) {
// Deliberately does NOT flip the widget to logged-out: a failed or slow // Deliberately does NOT flip the widget to logged-out: a failed or slow
// keychain read (locked iOS device during the 06:00 silent push) is // keychain read (locked iOS device during the 06:00 silent push) is
@@ -143,7 +137,6 @@ void _callbackDispatcher() {
} }
Future<void> _refresh() async { Future<void> _refresh() async {
await WidgetSync.ensureInitialized();
// The background isolate doesn't go through main.dart's BlocBuilder, so we // The background isolate doesn't go through main.dart's BlocBuilder, so we
// re-apply the endpoint the foreground last persisted. Without this the // re-apply the endpoint the foreground last persisted. Without this the
// dio singleton would fall back to its hardcoded live default even when // dio singleton would fall back to its hardcoded live default even when
@@ -157,9 +150,7 @@ Future<void> _refresh() async {
// Fetch window matches the week payload's window so the widget can roll // Fetch window matches the week payload's window so the widget can roll
// forward into next week's lessons without fresh data. // forward into next week's lessons without fresh data.
final weekStart = WidgetDataMapper.startOfCalendarWeek(now); final weekStart = WidgetDataMapper.startOfCalendarWeek(now);
final weekEndExclusive = weekStart.add( final weekEndExclusive = weekStart.addDays(WidgetDataMapper.weekWindowDays);
const Duration(days: WidgetDataMapper.weekWindowDays),
);
// All six requests are independent — run them concurrently so the total // All six requests are independent — run them concurrently so the total
// latency is the slowest request, not the sum (matters for the push path's // latency is the slowest request, not the sum (matters for the push path's
@@ -167,14 +158,11 @@ Future<void> _refresh() async {
// mapper rather than aborting the whole refresh. // mapper rather than aborting the whole refresh.
final timetableFuture = TimetableGetWeek().run( final timetableFuture = TimetableGetWeek().run(
from: weekStart, from: weekStart,
until: weekEndExclusive.subtract(const Duration(days: 1)), until: weekEndExclusive.subtractDays(1),
); );
final subjectsFuture = _runOrNull<TimetableGetSubjectsResponse>( final subjectsFuture = _runOrNull<TimetableGetSubjectsResponse>(
() => TimetableGetSubjects().run(), () => TimetableGetSubjects().run(),
); );
final roomsFuture = _runOrNull<TimetableGetRoomsResponse>(
() => TimetableGetRooms().run(),
);
final holidaysFuture = _runOrNull<TimetableGetHolidaysResponse>( final holidaysFuture = _runOrNull<TimetableGetHolidaysResponse>(
() => TimetableGetHolidays().run(), () => TimetableGetHolidays().run(),
); );
@@ -186,33 +174,18 @@ Future<void> _refresh() async {
); );
final timetable = await timetableFuture; final timetable = await timetableFuture;
final subjects = await subjectsFuture; final subjects = await subjectsFuture;
final rooms = await roomsFuture;
final holidays = await holidaysFuture; final holidays = await holidaysFuture;
final timegrid = await timegridFuture; final timegrid = await timegridFuture;
final customEvents = await customEventsFuture; final customEvents = await customEventsFuture;
final lessons = timetable.entries;
final [connectDouble, isTeacher] = await Future.wait([ final [connectDouble, isTeacher] = await Future.wait([
WidgetSync.getConnectDoubleLessons(), WidgetSync.getConnectDoubleLessons(),
WidgetSync.getIsTeacher(), WidgetSync.getIsTeacher(),
]); ]);
final dayData = WidgetDataMapper.buildDayData( final (day: dayData, week: weekData) = WidgetDataMapper.buildSnapshot(
now: now, now: now,
lessons: lessons, lessons: timetable.entries,
subjects: subjects, subjects: subjects,
rooms: rooms,
holidays: holidays,
timegrid: timegrid,
customEvents: customEvents,
connectDoubleLessons: connectDouble,
showClassInsteadOfTeacher: isTeacher,
);
final weekData = WidgetDataMapper.buildWeekData(
now: now,
lessons: lessons,
subjects: subjects,
rooms: rooms,
holidays: holidays, holidays: holidays,
timegrid: timegrid, timegrid: timegrid,
customEvents: customEvents, customEvents: customEvents,
@@ -220,10 +193,7 @@ Future<void> _refresh() async {
showClassInsteadOfTeacher: isTeacher, showClassInsteadOfTeacher: isTeacher,
); );
await WidgetSync.writeDayData(dayData); await WidgetSync.publishSnapshot(day: dayData, week: weekData);
await WidgetSync.writeWeekData(weekData);
await WidgetSync.setLoggedIn(true);
await WidgetSync.triggerUpdate();
log( log(
'[widget-bg] refreshed: day=${dayData.lessons.length} ' '[widget-bg] refreshed: day=${dayData.lessons.length} '
'week=${weekData.lessons.length}', 'week=${weekData.lessons.length}',
+1 -3
View File
@@ -72,9 +72,7 @@ extension DateTimeFormatting on DateTime {
String formatDateRelativeShort({DateTime? now}) { String formatDateRelativeShort({DateTime? now}) {
final reference = now ?? DateTime.now(); final reference = now ?? DateTime.now();
final today = DateTime(reference.year, reference.month, reference.day); final diff = reference.dateOnly.difference(dateOnly).inDays;
final self = DateTime(year, month, day);
final diff = today.difference(self).inDays;
if (diff == 0) return 'Heute'; if (diff == 0) return 'Heute';
if (diff == 1) return 'Gestern'; if (diff == 1) return 'Gestern';
+7 -1
View File
@@ -11,11 +11,13 @@ import 'package:flutter/scheduler.dart' show timeDilation;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:http/http.dart' as http;
import 'package:jiffy/jiffy.dart'; import 'package:jiffy/jiffy.dart';
import 'package:loader_overlay/loader_overlay.dart'; import 'package:loader_overlay/loader_overlay.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
import 'api/demo/demo_http_client.dart';
import 'api/marianumcloud/webdav/queries/list_files/list_files_cache.dart'; import 'api/marianumcloud/webdav/queries/list_files/list_files_cache.dart';
import 'api/marianumconnect/auth/session_validator.dart'; import 'api/marianumconnect/auth/session_validator.dart';
import 'api/marianumconnect/marianumconnect_endpoint.dart'; import 'api/marianumconnect/marianumconnect_endpoint.dart';
@@ -117,7 +119,11 @@ void _installErrorHandlers() {
}; };
} }
Future<void> main() async { // Everything runs inside the zone so every package:http client created by the
// app (Talk, avatars, image caches) goes through DemoHttpClient.
Future<void> main() => http.runWithClient(_main, DemoHttpClient.new);
Future<void> _main() async {
log('MarianumMobile started'); log('MarianumMobile started');
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
SessionManager().markUiEngine(); SessionManager().markUiEngine();
Loaded 100 of 330 files, more files were not shown because too many files have changed in this diff. Show more