23 Commits

Author SHA1 Message Date
MineTec 46d6b3410e implemented RMV commute integration in the timetable, added Nominatim geocoding for home station lookup, created CommuteCubit for daily trip management with TTL caching, and introduced specialized timetable tiles, detail sheets, and settings for transit connections and walking buffers 2026-05-20 22:50:57 +02:00
MineTec 067012cc84 implemented RMV public transit module including trip search, station departures, and nearby stop lookup, added "Marianum Connect" API integration with bearer token authentication and auto-refresh logic, integrated geolocator for location-based station search, added persistent storage for favorite stations and recent trip queries, and implemented comprehensive UI for journey details, trip results, and disruption alerts 2026-05-20 19:08:05 +02:00
MineTec f185b3273a updated version to 1.0.2+50 2026-05-17 11:55:40 +02:00
MineTec 831ea56869 Merge pull request 'updated app icons and splash screen assets, updated sort logic' (#100) from develop-logo into develop
Reviewed-on: #100
2026-05-17 09:54:59 +00:00
MineTec 215911cf29 refactored room and file sorting to use direct comparators instead of temporary sort strings, removed obsolete 'sort' properties from API models, and improved file list sorting with case-insensitive name comparisons and null-safe date handling 2026-05-17 00:27:17 +02:00
MineTec e5873f73b9 updated app icons and splash screen assets across Android, iOS, and Web platforms, added a new brand SVG asset, refined Android adaptive icon scaling with insets, and updated iOS asset catalog configurations and launch screen dimensions 2026-05-16 14:33:22 +02:00
MineTec 582eff8750 implemented current schoolyear API and dynamic timetable scroll boundaries, added handling for out-of-range errors to narrow accessible dates, optimized holiday region rendering by collapsing overlaps, and refined holiday tile UI 2026-05-14 15:07:48 +02:00
MineTec 2cb8321d07 implemented recurrence exception (EXDATE) support for custom events, refactored timetable break and holiday generation logic, and refined RRule editor UI/theming and tile layouts 2026-05-14 12:58:29 +02:00
MineTec 194d8d1857 moved flutter_native_splash from dev_dependencies to dependencies 2026-05-13 20:47:10 +02:00
MineTec 22e9c43f78 updated version to 1.0.1+49 2026-05-13 20:39:47 +02:00
MineTec 4c04d00323 improved app rating UI logic by showing a disabled state during availability check instead of hiding the component 2026-05-13 20:39:05 +02:00
MineTec 0fd42439e2 improved unknown file preview handling with probe failure fallbacks and switched to an explicit TabController in the share view to prevent build-time layout issues 2026-05-13 20:28:30 +02:00
MineTec d970cfbe0c centered file leading icons in share folder picker 2026-05-13 20:14:29 +02:00
MineTec 91ab109ec5 corrected spelling of Notendurchschnittsrechner in app modules and grade averages view 2026-05-13 20:09:46 +02:00
MineTec d9fcd9f624 implemented file thumbnails and enhanced file type icons, added reusable FileLeading widget, and updated search to support previews 2026-05-13 20:05:54 +02:00
MineTec 092f9b622b implemented Nextcloud file previews for unknown file types using fileId and has-preview flags, updated file models, and refined manual refresh logic. 2026-05-13 19:44:26 +02:00
MineTec 843686358f overhauled feedback dialog UI, implemented async action buttons for submission and image picking, and added a custom image preview widget 2026-05-13 19:07:06 +02:00
MineTec cfcb901adb implemented confirmation dialog for resetting module settings 2026-05-13 19:00:32 +02:00
MineTec ba5d9e0e4e integrated link sharing and clipboard options directly into QR view and simplified sharing flow by removing intermediate selection dialog 2026-05-13 18:57:56 +02:00
MineTec e8707b36f1 updated forward icon in message options and added scale limits to profile picture viewer 2026-05-13 18:50:45 +02:00
MineTec d0ba7c0fd6 refactored direct chat logic into a shared utility, implemented direct message shortcuts in the participant list and message reactions, and added reaction visibility checks in the message options dialog 2026-05-13 18:46:34 +02:00
MineTec a09817a975 added verbose error body in case of errors 2026-05-13 18:37:50 +02:00
MineTec 37dbb7b374 implemented keyboard-aware back navigation and refined message sending logic to prevent phantom drafts and handle mid-send navigation 2026-05-13 18:37:14 +02:00
208 changed files with 11207 additions and 892 deletions
+5 -1
View File
@@ -4,7 +4,8 @@
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:dataExtractionRules="@xml/data_extraction_rules"> android:dataExtractionRules="@xml/data_extraction_rules"
android:networkSecurityConfig="@xml/network_security_config">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@@ -106,4 +107,7 @@
<!-- Workmanager periodic widget refresh needs to reschedule after device <!-- Workmanager periodic widget refresh needs to reschedule after device
reboot, otherwise the widget freezes until the user opens the app. --> reboot, otherwise the widget freezes until the user opens the app. -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<!-- RMV "in meiner Nähe"-Suche. Coarse reicht (RMV-Suchradius >= 500 m). -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
</manifest> </manifest>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 454 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 319 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 654 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 1021 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 69 B

@@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/> <background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/> <foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
</adaptive-icon> </adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Allow cleartext for the MarianumConnect test instance only. Once the
production URL with HTTPS is live, drop this domain-config entry. -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">muelleel.ddns.net</domain>
</domain-config>
</network-security-config>
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="69.999985mm"
height="82.227501mm"
viewBox="0 0 69.999985 82.227501"
version="1.1"
id="svg1"
xml:space="preserve"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs1" /><g
id="g1"
transform="matrix(0.26458333,0,0,0.26458334,120.1078,-90.601937)"><g
id="group-R5"
transform="translate(-749.41293,290.52252)"><path
id="path2"
d="m 418.75901,57.817748 c -2.19013,14.15999 -3.96053,26.66133 -5.83906,39.13333 11.5776,2.532 18.4688,3.680002 29.7848,5.841332 8.50666,1.62133 23.36533,2.71733 23.952,11.096 0.86133,12.35866 -16.62534,7.388 -25.11734,5.84133 -11.244,-2.04667 -21.21693,-3.844 -30.9596,-5.84133 -1.82346,8.09866 -3.57813,15.82533 -5.25733,23.948 -4.90253,23.77866 -9.824,49.08933 -14.01666,73.59866 2.4136,-3.42133 4.11413,-5.69733 5.84226,-8.76267 8.89267,-15.76799 18.71867,-32.67066 30.37027,-47.31066 4.46106,-5.60667 10.24506,-12.64133 15.77306,-15.188 13.12267,-6.048 22.224,2.96133 22.78,15.77067 0.49334,11.372 -3.07733,24.15733 -4.676,36.80133 -1.65333,13.08933 -3.05066,25.98933 -4.66933,36.796 9.65733,-13.69067 19.72267,-35.95334 32.12533,-50.81733 3.668,-4.4 9.80667,-10.748 15.76934,-11.68134 29.16799,-4.57866 16.87733,41.62267 16.94133,63.084 0.0547,19.32001 5.86667,34.04934 23.36133,36.21068 5.73333,0.70933 14.10933,-1.48667 15.02533,3.51466 1.15467,6.31467 -10.42133,8.068 -16.77866,8.168 -38.84667,0.60267 -42.94133,-34.97734 -41.46933,-76.51601 -2.64534,2.476 -4.76134,6.336 -7.00934,9.93067 -13.00933,20.78267 -27.192,43.62801 -39.72133,67.17068 -3.45467,6.504 -7.15867,15.29333 -18.10667,11.09733 -2.30133,-0.88267 -6.43466,-5.92 -7.008,-7.59467 -2.60786,-7.62666 0.20667,-18.32533 1.168,-27.45333 2.87334,-27.16134 7.70934,-51.87868 10.516,-78.26534 -3.50533,-0.0707 -5.85733,4.008 -7.59333,6.42667 -6.2548,8.70266 -11.47147,18.76666 -16.94133,28.61866 -7.04214,12.684 -14.42814,25.71067 -21.02867,39.13201 -10.03173,20.41333 -25.14373,66.92133 -32.28013,90.50933 -1.15573,3.82133 -2.35933,7.744 -3.5052,11.68667 -0.998,3.428 -1.38693,7.69066 -4.67347,9.928 -13.33853,-2.52534 -11.962,-13.98534 -9.3464,-27.45467 6.31414,-32.45733 22.78547,-94.19334 28.19587,-126.13867 -9.72813,9.464 -20.53067,27.65466 -38.552,31.54133 -18.1928,3.92667 -29.05788,-6.35733 -36.2136,-16.35333 -2.28489,-3.19467 -5.52188,-6.60534 -3.50417,-10.51467 11.19375,-4.73333 15.11457,8.69467 26.28537,9.93067 3.0204,0.33199 7.2756,-0.32001 9.93027,-1.17067 17.65253,-5.64667 31.67346,-29.168 39.71506,-46.14133 9.88747,-20.86267 16.70987,-44.06667 19.86094,-65.42133 -8.76254,-1.51867 -19.064,-2.95467 -29.2344,-4.86933 -14.64054,-2.76134 -21.17334,-1.432 -26.83947,-7.980002 1.2228,-5.592 5.5228,-8.108 9.34627,-11.09867 16.83653,2.24533 33.6584,4.504 49.64946,7.59333 2.72454,-11.9 6.92973,-29.13199 9.92813,-41.47199 5.40267,-3.468 11.84947,1.12533 14.0204,4.676"
style="fill:#941a1f;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.133333" /></g></g></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+6 -6
View File
@@ -647,7 +647,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = BB0001010000000011111111 /* ShareExtension-Debug.xcconfig */; baseConfigurationReference = BB0001010000000011111111 /* ShareExtension-Debug.xcconfig */;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES; CLANG_ENABLE_OBJC_WEAK = YES;
@@ -691,7 +691,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = BB0001020000000011111111 /* ShareExtension-Release.xcconfig */; baseConfigurationReference = BB0001020000000011111111 /* ShareExtension-Release.xcconfig */;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES; CLANG_ENABLE_OBJC_WEAK = YES;
@@ -732,7 +732,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = BB0001030000000011111111 /* ShareExtension-Profile.xcconfig */; baseConfigurationReference = BB0001030000000011111111 /* ShareExtension-Profile.xcconfig */;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES; CLANG_ENABLE_OBJC_WEAK = YES;
@@ -941,7 +941,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */; baseConfigurationReference = BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
@@ -983,7 +983,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = BB0001050000000011111111 /* TimetableWidget-Release.xcconfig */; baseConfigurationReference = BB0001050000000011111111 /* TimetableWidget-Release.xcconfig */;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
@@ -1023,7 +1023,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = BB0001060000000011111111 /* TimetableWidget-Profile.xcconfig */; baseConfigurationReference = BB0001060000000011111111 /* TimetableWidget-Profile.xcconfig */;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
@@ -1,122 +1 @@
{ {"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 695 B

After

Width:  |  Height:  |  Size: 653 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 12 KiB

@@ -38,7 +38,7 @@
</scene> </scene>
</scenes> </scenes>
<resources> <resources>
<image name="LaunchImage" width="800" height="1131"/> <image name="LaunchImage" width="512" height="512"/>
<image name="LaunchBackground" width="1" height="1"/> <image name="LaunchBackground" width="1" height="1"/>
</resources> </resources>
</document> </document>
+17 -2
View File
@@ -1,7 +1,7 @@
<?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>CADisableMinimumFrameDurationOnPhone</key>
@@ -43,6 +43,21 @@
<string>Um Fotos direkt aus der App aufnehmen und teilen zu können wird Zugriff auf die Kamera benötigt.</string> <string>Um Fotos direkt aus der App aufnehmen und teilen zu können wird Zugriff auf die Kamera benötigt.</string>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>Um Medien mit anderen zu teilen wird Zugriff zu deine Dateien benötigt.</string> <string>Um Medien mit anderen zu teilen wird Zugriff zu deine Dateien benötigt.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Um Haltestellen in deiner Nähe im RMV-Fahrplan zu finden, wird dein aktueller Standort benötigt.</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>muelleel.ddns.net</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict>
</dict>
<key>UIApplicationSceneManifest</key> <key>UIApplicationSceneManifest</key>
<dict> <dict>
<key>UIApplicationSupportsMultipleScenes</key> <key>UIApplicationSupportsMultipleScenes</key>
@@ -86,5 +101,5 @@
</array> </array>
<key>UIViewControllerBasedStatusBarAppearance</key> <key>UIViewControllerBasedStatusBarAppearance</key>
<false/> <false/>
</dict> </dict>
</plist> </plist>
+24
View File
@@ -0,0 +1,24 @@
import 'dart:convert';
import '../../connect_api.dart';
import 'login_request.dart';
import 'login_response.dart';
class Login extends ConnectApi<LoginResponse> {
final LoginRequest payload;
Login(this.payload) : super('auth/login');
@override
bool get requiresAuth => false;
@override
ConnectHttpMethod get method => ConnectHttpMethod.post;
@override
Object? get body => payload.toJson();
@override
LoginResponse assemble(String raw) =>
LoginResponse.fromJson(jsonDecode(raw) as Map<String, dynamic>);
}
@@ -0,0 +1,18 @@
import 'package:json_annotation/json_annotation.dart';
part 'login_request.g.dart';
@JsonSerializable()
class LoginRequest {
final String username;
final String password;
final String tokenName;
LoginRequest({
required this.username,
required this.password,
required this.tokenName,
});
Map<String, dynamic> toJson() => _$LoginRequestToJson(this);
}
@@ -0,0 +1,20 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'login_request.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
LoginRequest _$LoginRequestFromJson(Map<String, dynamic> json) => LoginRequest(
username: json['username'] as String,
password: json['password'] as String,
tokenName: json['tokenName'] as String,
);
Map<String, dynamic> _$LoginRequestToJson(LoginRequest instance) =>
<String, dynamic>{
'username': instance.username,
'password': instance.password,
'tokenName': instance.tokenName,
};
@@ -0,0 +1,65 @@
/// Hand-rolled to be tolerant of the actual server payload: only [token] is
/// load-bearing. `expiresAt` may be `null` (server-issued tokens without an
/// explicit expiry); every other field shape is also tolerated so a stray
/// rename on the backend does not break login for everyone.
class LoginResponse {
final String token;
final String? tokenId;
/// `null` when the backend did not provide an expiry. In that case the
/// token is treated as long-lived; callers should refresh on 401.
final DateTime? expiresAt;
final ConnectUserDto? user;
LoginResponse({
required this.token,
required this.tokenId,
required this.expiresAt,
required this.user,
});
factory LoginResponse.fromJson(Map<String, dynamic> json) {
final token = json['token'];
if (token is! String || token.isEmpty) {
throw const FormatException('login response missing "token" string');
}
final expiresRaw = json['expiresAt'];
final expires = expiresRaw is String ? DateTime.tryParse(expiresRaw) : null;
final userJson = json['user'];
return LoginResponse(
token: token,
tokenId: json['tokenId']?.toString(),
expiresAt: expires,
user: userJson is Map<String, dynamic>
? ConnectUserDto.fromJson(userJson)
: null,
);
}
}
class ConnectUserDto {
final String? id;
final String? username;
final String? firstName;
final String? lastName;
final String? userType;
final String? className;
ConnectUserDto({
this.id,
this.username,
this.firstName,
this.lastName,
this.userType,
this.className,
});
factory ConnectUserDto.fromJson(Map<String, dynamic> json) => ConnectUserDto(
id: json['id']?.toString(),
username: json['username']?.toString(),
firstName: json['firstName']?.toString(),
lastName: json['lastName']?.toString(),
userType: json['userType']?.toString(),
className: json['className']?.toString(),
);
}
+179
View File
@@ -0,0 +1,179 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../api_request.dart';
import '../errors/network_exception.dart';
import '../errors/parse_exception.dart';
import '../errors/server_exception.dart';
import 'connect_auth_store.dart';
import 'connect_endpoint.dart';
import 'errors/connect_exception.dart';
import 'errors/rmv_rate_limited_exception.dart';
import 'errors/rmv_upstream_exception.dart';
enum ConnectHttpMethod { get, post }
/// Mirrors the [MhslApi] pattern: each endpoint subclasses this, declares the
/// subpath/query/body, and implements [assemble]. Handles bearer-token
/// injection (via [ConnectAuthStore]), one transparent 401-retry after a
/// fresh login, and turns the structured `RmvController.wrap` error strings
/// into typed exceptions.
abstract class ConnectApi<T> extends ApiRequest {
final String subpath;
ConnectApi(this.subpath);
/// Override to `false` for endpoints that must NOT receive a bearer token
/// (currently only login itself, to avoid an infinite refresh loop).
bool get requiresAuth => true;
ConnectHttpMethod get method => ConnectHttpMethod.get;
Map<String, String>? get queryParameters => null;
/// Returns the body to send for POST requests. Should be JSON-encodable.
Object? get body => null;
T assemble(String raw);
Future<T> run() async {
final response = await _runOnce(forceTokenRefresh: false);
if (response.statusCode == 401 && requiresAuth) {
// Single transparent retry after a forced refresh, then bail.
await ConnectAuthStore.instance.invalidate();
final retry = await _runOnce(forceTokenRefresh: true);
if (retry.statusCode == 401) {
throw ConnectException.authFailed(
technicalDetails:
'connect $subpath HTTP 401 after token refresh: ${_safeBody(retry)}',
);
}
return _handleResponse(retry);
}
return _handleResponse(response);
}
Future<http.Response> _runOnce({required bool forceTokenRefresh}) async {
final uri = ConnectEndpoint.resolve(subpath).replace(
queryParameters: _normaliseQuery(queryParameters),
);
final headers = <String, String>{
if (method == ConnectHttpMethod.post) 'Content-Type': 'application/json',
'Accept': 'application/json',
};
if (requiresAuth) {
final token = await ConnectAuthStore.instance.getToken(
forceRefresh: forceTokenRefresh,
);
headers['Authorization'] = 'Bearer $token';
}
try {
switch (method) {
case ConnectHttpMethod.get:
return await http.get(uri, headers: headers);
case ConnectHttpMethod.post:
final payload = body;
return await http.post(
uri,
headers: headers,
body: payload == null ? null : jsonEncode(payload),
);
}
} on SocketException catch (e) {
throw NetworkException(
technicalDetails: 'connect $subpath: ${e.message}',
);
} on TimeoutException catch (e) {
throw NetworkException.timeout(
technicalDetails: 'connect $subpath: $e',
);
} on http.ClientException catch (e) {
throw NetworkException(
technicalDetails: 'connect $subpath: ${e.message}',
);
} on HandshakeException catch (e) {
throw NetworkException(
technicalDetails: 'connect $subpath TLS: ${e.message}',
);
}
}
T _handleResponse(http.Response response) {
final status = response.statusCode;
final bodyText = _safeBody(response);
if (status == 503) {
final retryAfter = _parseRetryAfter(bodyText);
throw RmvRateLimitedException(
retryAfter: retryAfter,
technicalDetails: 'connect $subpath HTTP 503: $bodyText',
);
}
if (status == 502) {
final code = _parseUpstreamErrorCode(bodyText);
throw RmvUpstreamException(
errorCode: code,
technicalDetails: 'connect $subpath HTTP 502: $bodyText',
);
}
if (status > 299) {
throw ServerException(
statusCode: status,
technicalDetails: 'connect $subpath HTTP $status: $bodyText',
);
}
try {
return assemble(bodyText);
} catch (e, st) {
final preview = bodyText.length > 1024
? '${bodyText.substring(0, 1024)}'
: bodyText;
log(
'connect $subpath assemble failed: $e\nbody: $preview',
stackTrace: st,
);
throw ParseException(
technicalDetails: 'connect $subpath assemble: $e',
);
}
}
String _safeBody(http.Response response) {
try {
return utf8.decode(response.bodyBytes);
} catch (_) {
return response.body;
}
}
/// Body format from `RmvController.wrap`: `upstream_rate_limited|retryAfter=60`.
Duration _parseRetryAfter(String body) {
final match = RegExp(r'retryAfter=(\d+)').firstMatch(body);
final seconds = match == null ? 60 : int.tryParse(match.group(1)!) ?? 60;
return Duration(seconds: seconds);
}
/// Body format: `upstream_error|H390` — the segment after the pipe is the
/// RMV/HaFAS error code.
String? _parseUpstreamErrorCode(String body) {
final idx = body.indexOf('|');
if (idx < 0 || idx >= body.length - 1) return null;
return body.substring(idx + 1).trim();
}
Map<String, String>? _normaliseQuery(Map<String, String>? raw) {
if (raw == null) return null;
final cleaned = <String, String>{};
raw.forEach((key, value) {
if (value.isNotEmpty) cleaned[key] = value;
});
return cleaned.isEmpty ? null : cleaned;
}
}
+109
View File
@@ -0,0 +1,109 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../model/account_data.dart';
import 'auth/login/login.dart';
import 'auth/login/login_request.dart';
import 'auth/login/login_response.dart';
import 'errors/connect_exception.dart';
/// Holds the Bearer token issued by `POST /auth/login` so that subsequent
/// RMV calls can attach it without prompting the user. Uses the LDAP
/// credentials already kept in [AccountData], so this is transparent to the
/// user — no extra login UI.
class ConnectAuthStore {
static const _tokenKey = 'connect_bearer_token';
static const _expiresAtKey = 'connect_token_expires_at';
static const _tokenName = 'MarianumMobile App';
static const _expiryGuard = Duration(minutes: 1);
static final ConnectAuthStore _instance = ConnectAuthStore._();
factory ConnectAuthStore() => _instance;
static ConnectAuthStore get instance => _instance;
ConnectAuthStore._();
final FlutterSecureStorage _storage = const FlutterSecureStorage();
String? _token;
DateTime? _expiresAt;
bool _hydrated = false;
Future<String>? _inflightLogin;
Future<void> _hydrate() async {
if (_hydrated) return;
_token = await _storage.read(key: _tokenKey);
final rawExp = await _storage.read(key: _expiresAtKey);
_expiresAt = rawExp == null ? null : DateTime.tryParse(rawExp);
_hydrated = true;
}
bool _isUsable() {
if (_token == null || _token!.isEmpty) return false;
final exp = _expiresAt;
if (exp == null) return true;
return DateTime.now().add(_expiryGuard).isBefore(exp);
}
/// Returns a usable bearer token, logging in if necessary. Concurrent
/// callers share the same in-flight login future so a single 401 doesn't
/// trigger N parallel logins.
Future<String> getToken({bool forceRefresh = false}) async {
await _hydrate();
if (!forceRefresh && _isUsable()) return _token!;
return _inflightLogin ??= _login().whenComplete(() {
_inflightLogin = null;
});
}
Future<String> _login() async {
if (!AccountData().isPopulated()) {
throw ConnectException.notAuthenticated();
}
final username = AccountData().getUsername();
final password = AccountData().getPassword();
final LoginResponse response;
try {
response = await Login(
LoginRequest(
username: username,
password: password,
tokenName: _tokenName,
),
).run();
} on ConnectException {
rethrow;
} catch (e, st) {
log('connect login threw: $e', stackTrace: st);
throw ConnectException(
userMessage:
'Anmeldung am Connect-Server fehlgeschlagen. Bitte später erneut versuchen.',
technicalDetails: 'connect login failed: $e',
);
}
_token = response.token;
_expiresAt = response.expiresAt;
await _storage.write(key: _tokenKey, value: response.token);
if (response.expiresAt != null) {
await _storage.write(
key: _expiresAtKey,
value: response.expiresAt!.toIso8601String(),
);
} else {
await _storage.delete(key: _expiresAtKey);
}
return response.token;
}
Future<void> invalidate() async {
_token = null;
_expiresAt = null;
await _storage.delete(key: _tokenKey);
await _storage.delete(key: _expiresAtKey);
}
/// Same as [invalidate] — separate method to make logout call-sites read
/// clearly.
Future<void> clear() => invalidate();
}
+12
View File
@@ -0,0 +1,12 @@
/// Base URL for the MarianumConnect backend. Hardcoded against the test
/// instance for now; once the production URL is finalised this should move
/// into `EndpointData` alongside webuntis/nextcloud.
class ConnectEndpoint {
ConnectEndpoint._();
static const String _baseUrl = 'http://muelleel.ddns.net:8080';
static const String _apiPrefix = '/api/mobile/v1';
static Uri resolve(String subpath) =>
Uri.parse('$_baseUrl$_apiPrefix/${subpath.startsWith('/') ? subpath.substring(1) : subpath}');
}
@@ -0,0 +1,25 @@
import '../../errors/app_exception.dart';
class ConnectException extends AppException {
const ConnectException({
super.userMessage =
'Verbindung zum Marianum-Connect-Server fehlgeschlagen.',
super.technicalDetails,
super.allowRetry,
});
factory ConnectException.authFailed({String? technicalDetails}) =>
ConnectException(
userMessage:
'Anmeldung am Connect-Server fehlgeschlagen. Bitte prüfe deine Anmeldedaten.',
technicalDetails: technicalDetails,
allowRetry: false,
);
factory ConnectException.notAuthenticated() => const ConnectException(
userMessage:
'Für diese Funktion ist eine Anmeldung am Connect-Server nötig.',
technicalDetails: 'AccountData missing while trying to log in to connect',
allowRetry: false,
);
}
@@ -0,0 +1,12 @@
import '../../errors/app_exception.dart';
class RmvRateLimitedException extends AppException {
final Duration retryAfter;
RmvRateLimitedException({required this.retryAfter, super.technicalDetails})
: super(
userMessage:
'Die Fahrplanauskunft ist gerade überlastet. Bitte in ${retryAfter.inSeconds} Sekunden erneut versuchen.',
allowRetry: true,
);
}
@@ -0,0 +1,28 @@
import '../../errors/app_exception.dart';
class RmvUpstreamException extends AppException {
final String? errorCode;
RmvUpstreamException({required this.errorCode, super.technicalDetails})
: super(userMessage: _mapMessage(errorCode), allowRetry: true);
static String _mapMessage(String? code) {
switch (code) {
case 'H390':
return 'Keine Verbindung gefunden.';
case 'H891':
return 'Eine der angegebenen Stationen ist ungültig.';
case 'H895':
return 'Start- und Zielhaltestelle sind identisch.';
case 'H900':
case 'H892':
return 'Die Fahrplanauskunft ist gerade nicht verfügbar.';
case 'H910':
return 'Die angegebene Zeit liegt außerhalb des Fahrplans.';
case null:
return 'Die Fahrplanauskunft konnte keine Antwort liefern.';
default:
return 'Die Fahrplanauskunft hat einen Fehler gemeldet ($code).';
}
}
}
+36
View File
@@ -0,0 +1,36 @@
/// ISO-8601 duration (`PT1H30M5S`) ↔ Dart `Duration`. Backend serialises
/// `java.time.Duration` in this format; Dart has no builtin parser.
class IsoDuration {
IsoDuration._();
static final RegExp _pattern = RegExp(
r'^P(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$',
);
static Duration? fromJson(String? iso) {
if (iso == null || iso.isEmpty) return null;
final match = _pattern.firstMatch(iso);
if (match == null) return null;
final hours = int.parse(match.group(1) ?? '0');
final minutes = int.parse(match.group(2) ?? '0');
final secondsRaw = match.group(3) ?? '0';
final secondsValue = double.parse(secondsRaw);
return Duration(
hours: hours,
minutes: minutes,
milliseconds: (secondsValue * 1000).round(),
);
}
static String? toJson(Duration? d) {
if (d == null) return null;
final hours = d.inHours;
final minutes = d.inMinutes.remainder(60);
final seconds = d.inSeconds.remainder(60);
final buf = StringBuffer('PT');
if (hours > 0) buf.write('${hours}H');
if (minutes > 0) buf.write('${minutes}M');
if (seconds > 0 || (hours == 0 && minutes == 0)) buf.write('${seconds}S');
return buf.toString();
}
}
@@ -0,0 +1,13 @@
/// Formats a [DateTime] as `2026-05-19T14:30:00` for Java's
/// `LocalDateTime` parser (no timezone, no millis).
String formatLocalDateTime(DateTime dt) {
String two(int v) => v.toString().padLeft(2, '0');
return '${dt.year}-${two(dt.month)}-${two(dt.day)}T'
'${two(dt.hour)}:${two(dt.minute)}:${two(dt.second)}';
}
/// Formats a [DateTime] as `2026-05-19` for Java's `LocalDate` parser.
String formatLocalDate(DateTime dt) {
String two(int v) => v.toString().padLeft(2, '0');
return '${dt.year}-${two(dt.month)}-${two(dt.day)}';
}
@@ -0,0 +1,32 @@
import 'dart:convert';
import '../../connect_api.dart';
import '../rmv_models.dart';
import '_query_format.dart';
class GetArrivals extends ConnectApi<List<Arrival>> {
final String stopId;
final DateTime? when;
final int durationMinutes;
final int maxJourneys;
GetArrivals({
required this.stopId,
this.when,
this.durationMinutes = 60,
this.maxJourneys = -1,
}) : super('rmv/arrivals');
@override
Map<String, String>? get queryParameters => {
'stopId': stopId,
if (when != null) 'when': formatLocalDateTime(when!),
'duration': durationMinutes.toString(),
'max': maxJourneys.toString(),
};
@override
List<Arrival> assemble(String raw) => (jsonDecode(raw) as List)
.map((e) => Arrival.fromJson(e as Map<String, dynamic>))
.toList(growable: false);
}
@@ -0,0 +1,32 @@
import 'dart:convert';
import '../../connect_api.dart';
import '../rmv_models.dart';
import '_query_format.dart';
class GetDepartures extends ConnectApi<List<Departure>> {
final String stopId;
final DateTime? when;
final int durationMinutes;
final int maxJourneys;
GetDepartures({
required this.stopId,
this.when,
this.durationMinutes = 60,
this.maxJourneys = -1,
}) : super('rmv/departures');
@override
Map<String, String>? get queryParameters => {
'stopId': stopId,
if (when != null) 'when': formatLocalDateTime(when!),
'duration': durationMinutes.toString(),
'max': maxJourneys.toString(),
};
@override
List<Departure> assemble(String raw) => (jsonDecode(raw) as List)
.map((e) => Departure.fromJson(e as Map<String, dynamic>))
.toList(growable: false);
}
@@ -0,0 +1,20 @@
import 'dart:convert';
import '../../connect_api.dart';
import '../rmv_models.dart';
import '_query_format.dart';
class GetDisruptions extends ConnectApi<List<HimMessage>> {
final DateTime? when;
GetDisruptions({this.when}) : super('rmv/disruptions');
@override
Map<String, String>? get queryParameters =>
when == null ? null : {'when': formatLocalDateTime(when!)};
@override
List<HimMessage> assemble(String raw) => (jsonDecode(raw) as List)
.map((e) => HimMessage.fromJson(e as Map<String, dynamic>))
.toList(growable: false);
}
@@ -0,0 +1,23 @@
import 'dart:convert';
import '../../connect_api.dart';
import '../rmv_models.dart';
import '_query_format.dart';
class GetJourneyDetail extends ConnectApi<JourneyDetail> {
final String journeyRef;
final DateTime? date;
GetJourneyDetail({required this.journeyRef, this.date})
: super('rmv/journey');
@override
Map<String, String>? get queryParameters => {
'ref': journeyRef,
if (date != null) 'date': formatLocalDate(date!),
};
@override
JourneyDetail assemble(String raw) =>
JourneyDetail.fromJson(jsonDecode(raw) as Map<String, dynamic>);
}
@@ -0,0 +1,17 @@
import 'dart:convert';
import '../../connect_api.dart';
import '../rmv_models.dart';
class MoreTrips extends ConnectApi<TripSearchResult> {
final String ctx;
MoreTrips({required this.ctx}) : super('rmv/trips/more');
@override
Map<String, String>? get queryParameters => {'ctx': ctx};
@override
TripSearchResult assemble(String raw) =>
TripSearchResult.fromJson(jsonDecode(raw) as Map<String, dynamic>);
}
@@ -0,0 +1,31 @@
import 'dart:convert';
import '../../connect_api.dart';
import '../rmv_models.dart';
class NearbyStops extends ConnectApi<List<StopLocation>> {
final double lat;
final double lon;
final int radiusMeters;
final int max;
NearbyStops({
required this.lat,
required this.lon,
this.radiusMeters = 1000,
this.max = 20,
}) : super('rmv/stops/nearby');
@override
Map<String, String>? get queryParameters => {
'lat': lat.toString(),
'lon': lon.toString(),
'radius': radiusMeters.toString(),
'max': max.toString(),
};
@override
List<StopLocation> assemble(String raw) => (jsonDecode(raw) as List)
.map((e) => StopLocation.fromJson(e as Map<String, dynamic>))
.toList(growable: false);
}
@@ -0,0 +1,22 @@
import 'dart:convert';
import '../../connect_api.dart';
import '../rmv_models.dart';
class SearchStops extends ConnectApi<List<StopLocation>> {
final String query;
final int max;
SearchStops({required this.query, this.max = 10}) : super('rmv/stops');
@override
Map<String, String>? get queryParameters => {
'q': query,
'max': max.toString(),
};
@override
List<StopLocation> assemble(String raw) => (jsonDecode(raw) as List)
.map((e) => StopLocation.fromJson(e as Map<String, dynamic>))
.toList(growable: false);
}
@@ -0,0 +1,31 @@
import 'dart:convert';
import '../../connect_api.dart';
import '../rmv_models.dart';
import '_query_format.dart';
class SearchTrips extends ConnectApi<TripSearchResult> {
final String fromStopId;
final String toStopId;
final DateTime? when;
final bool searchByArrival;
SearchTrips({
required this.fromStopId,
required this.toStopId,
this.when,
this.searchByArrival = false,
}) : super('rmv/trips');
@override
Map<String, String>? get queryParameters => {
'from': fromStopId,
'to': toStopId,
if (when != null) 'when': formatLocalDateTime(when!),
'searchByArrival': searchByArrival.toString(),
};
@override
TripSearchResult assemble(String raw) =>
TripSearchResult.fromJson(jsonDecode(raw) as Map<String, dynamic>);
}
+243
View File
@@ -0,0 +1,243 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'iso_duration.dart';
part 'rmv_models.freezed.dart';
part 'rmv_models.g.dart';
@freezed
abstract class Product with _$Product {
const factory Product({
String? name,
String? line,
String? displayNumber,
String? category,
String? categoryCode,
String? operator,
}) = _Product;
factory Product.fromJson(Map<String, Object?> json) =>
_$ProductFromJson(json);
}
@freezed
abstract class StopLocation with _$StopLocation {
const factory StopLocation({
required String id,
String? extId,
required String name,
String? description,
double? lat,
double? lon,
int? products,
int? distanceMeters,
}) = _StopLocation;
factory StopLocation.fromJson(Map<String, Object?> json) =>
_$StopLocationFromJson(json);
}
@freezed
abstract class Departure with _$Departure {
const factory Departure({
required String stopId,
String? stopExtId,
required String stopName,
required String name,
required String direction,
String? directionFlag,
required DateTime scheduledTime,
DateTime? realTime,
int? delayMinutes,
String? track,
String? realTrack,
@Default(false) bool cancelled,
@Default(true) bool reachable,
Product? product,
String? journeyRef,
}) = _Departure;
factory Departure.fromJson(Map<String, Object?> json) =>
_$DepartureFromJson(json);
}
@freezed
abstract class Arrival with _$Arrival {
const factory Arrival({
required String stopId,
String? stopExtId,
required String stopName,
required String name,
required String origin,
required DateTime scheduledTime,
DateTime? realTime,
int? delayMinutes,
String? track,
String? realTrack,
@Default(false) bool cancelled,
Product? product,
String? journeyRef,
}) = _Arrival;
factory Arrival.fromJson(Map<String, Object?> json) =>
_$ArrivalFromJson(json);
}
@freezed
abstract class TripEndpoint with _$TripEndpoint {
const factory TripEndpoint({
required String stopId,
String? stopExtId,
required String name,
double? lat,
double? lon,
required DateTime scheduledTime,
DateTime? realTime,
int? delayMinutes,
String? track,
String? realTrack,
String? type,
}) = _TripEndpoint;
factory TripEndpoint.fromJson(Map<String, Object?> json) =>
_$TripEndpointFromJson(json);
}
@freezed
abstract class JourneyStop with _$JourneyStop {
const factory JourneyStop({
required String id,
String? extId,
required String name,
double? lat,
double? lon,
int? routeIdx,
DateTime? scheduledArrival,
DateTime? scheduledDeparture,
DateTime? realArrival,
DateTime? realDeparture,
String? arrTrack,
String? depTrack,
String? realArrTrack,
String? realDepTrack,
@Default(false) bool cancelled,
@Default(false) bool cancelledArrival,
@Default(false) bool cancelledDeparture,
}) = _JourneyStop;
factory JourneyStop.fromJson(Map<String, Object?> json) =>
_$JourneyStopFromJson(json);
}
enum LegType {
@JsonValue('JOURNEY')
journey,
@JsonValue('WALK')
walk,
@JsonValue('TRANSFER')
transfer,
@JsonValue('BIKE')
bike,
@JsonValue('CAR')
car,
@JsonValue('PARK_RIDE')
parkRide,
@JsonValue('TAXI')
taxi,
@JsonValue('CHECK_IN')
checkIn,
@JsonValue('CHECK_OUT')
checkOut,
@JsonValue('DUMMY')
dummy,
@JsonValue('UNKNOWN')
unknown,
}
@freezed
abstract class Leg with _$Leg {
const factory Leg({
required String id,
required int idx,
@Default(LegType.unknown) LegType type,
String? name,
String? category,
String? number,
String? direction,
required TripEndpoint origin,
required TripEndpoint destination,
@JsonKey(fromJson: IsoDuration.fromJson, toJson: IsoDuration.toJson)
Duration? duration,
@Default(false) bool cancelled,
@Default(false) bool partCancelled,
@Default(true) bool reachable,
Product? product,
String? journeyRef,
@Default(<JourneyStop>[]) List<JourneyStop> stops,
}) = _Leg;
factory Leg.fromJson(Map<String, Object?> json) => _$LegFromJson(json);
}
@freezed
abstract class Trip with _$Trip {
const factory Trip({
String? tripId,
String? ctxRecon,
String? checksum,
@JsonKey(fromJson: IsoDuration.fromJson, toJson: IsoDuration.toJson)
Duration? duration,
@JsonKey(fromJson: IsoDuration.fromJson, toJson: IsoDuration.toJson)
Duration? realDuration,
int? transferCount,
@Default(<Leg>[]) List<Leg> legs,
}) = _Trip;
factory Trip.fromJson(Map<String, Object?> json) => _$TripFromJson(json);
}
@freezed
abstract class TripSearchResult with _$TripSearchResult {
const factory TripSearchResult({
@Default(<Trip>[]) List<Trip> trips,
String? scrollContextLater,
String? scrollContextEarlier,
}) = _TripSearchResult;
factory TripSearchResult.fromJson(Map<String, Object?> json) =>
_$TripSearchResultFromJson(json);
}
@freezed
abstract class JourneyDetail with _$JourneyDetail {
const factory JourneyDetail({
String? journeyId,
Product? product,
String? direction,
@Default(<JourneyStop>[]) List<JourneyStop> stops,
}) = _JourneyDetail;
factory JourneyDetail.fromJson(Map<String, Object?> json) =>
_$JourneyDetailFromJson(json);
}
@freezed
abstract class HimMessage with _$HimMessage {
const factory HimMessage({
required String id,
String? externalId,
String? head,
String? lead,
String? text,
String? category,
String? company,
int? priority,
int? products,
DateTime? startValidity,
DateTime? endValidity,
DateTime? modified,
}) = _HimMessage;
factory HimMessage.fromJson(Map<String, Object?> json) =>
_$HimMessageFromJson(json);
}
File diff suppressed because it is too large Load Diff
+368
View File
@@ -0,0 +1,368 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'rmv_models.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_Product _$ProductFromJson(Map<String, dynamic> json) => _Product(
name: json['name'] as String?,
line: json['line'] as String?,
displayNumber: json['displayNumber'] as String?,
category: json['category'] as String?,
categoryCode: json['categoryCode'] as String?,
operator: json['operator'] as String?,
);
Map<String, dynamic> _$ProductToJson(_Product instance) => <String, dynamic>{
'name': instance.name,
'line': instance.line,
'displayNumber': instance.displayNumber,
'category': instance.category,
'categoryCode': instance.categoryCode,
'operator': instance.operator,
};
_StopLocation _$StopLocationFromJson(Map<String, dynamic> json) =>
_StopLocation(
id: json['id'] as String,
extId: json['extId'] as String?,
name: json['name'] as String,
description: json['description'] as String?,
lat: (json['lat'] as num?)?.toDouble(),
lon: (json['lon'] as num?)?.toDouble(),
products: (json['products'] as num?)?.toInt(),
distanceMeters: (json['distanceMeters'] as num?)?.toInt(),
);
Map<String, dynamic> _$StopLocationToJson(_StopLocation instance) =>
<String, dynamic>{
'id': instance.id,
'extId': instance.extId,
'name': instance.name,
'description': instance.description,
'lat': instance.lat,
'lon': instance.lon,
'products': instance.products,
'distanceMeters': instance.distanceMeters,
};
_Departure _$DepartureFromJson(Map<String, dynamic> json) => _Departure(
stopId: json['stopId'] as String,
stopExtId: json['stopExtId'] as String?,
stopName: json['stopName'] as String,
name: json['name'] as String,
direction: json['direction'] as String,
directionFlag: json['directionFlag'] as String?,
scheduledTime: DateTime.parse(json['scheduledTime'] as String),
realTime: json['realTime'] == null
? null
: DateTime.parse(json['realTime'] as String),
delayMinutes: (json['delayMinutes'] as num?)?.toInt(),
track: json['track'] as String?,
realTrack: json['realTrack'] as String?,
cancelled: json['cancelled'] as bool? ?? false,
reachable: json['reachable'] as bool? ?? true,
product: json['product'] == null
? null
: Product.fromJson(json['product'] as Map<String, dynamic>),
journeyRef: json['journeyRef'] as String?,
);
Map<String, dynamic> _$DepartureToJson(_Departure instance) =>
<String, dynamic>{
'stopId': instance.stopId,
'stopExtId': instance.stopExtId,
'stopName': instance.stopName,
'name': instance.name,
'direction': instance.direction,
'directionFlag': instance.directionFlag,
'scheduledTime': instance.scheduledTime.toIso8601String(),
'realTime': instance.realTime?.toIso8601String(),
'delayMinutes': instance.delayMinutes,
'track': instance.track,
'realTrack': instance.realTrack,
'cancelled': instance.cancelled,
'reachable': instance.reachable,
'product': instance.product,
'journeyRef': instance.journeyRef,
};
_Arrival _$ArrivalFromJson(Map<String, dynamic> json) => _Arrival(
stopId: json['stopId'] as String,
stopExtId: json['stopExtId'] as String?,
stopName: json['stopName'] as String,
name: json['name'] as String,
origin: json['origin'] as String,
scheduledTime: DateTime.parse(json['scheduledTime'] as String),
realTime: json['realTime'] == null
? null
: DateTime.parse(json['realTime'] as String),
delayMinutes: (json['delayMinutes'] as num?)?.toInt(),
track: json['track'] as String?,
realTrack: json['realTrack'] as String?,
cancelled: json['cancelled'] as bool? ?? false,
product: json['product'] == null
? null
: Product.fromJson(json['product'] as Map<String, dynamic>),
journeyRef: json['journeyRef'] as String?,
);
Map<String, dynamic> _$ArrivalToJson(_Arrival instance) => <String, dynamic>{
'stopId': instance.stopId,
'stopExtId': instance.stopExtId,
'stopName': instance.stopName,
'name': instance.name,
'origin': instance.origin,
'scheduledTime': instance.scheduledTime.toIso8601String(),
'realTime': instance.realTime?.toIso8601String(),
'delayMinutes': instance.delayMinutes,
'track': instance.track,
'realTrack': instance.realTrack,
'cancelled': instance.cancelled,
'product': instance.product,
'journeyRef': instance.journeyRef,
};
_TripEndpoint _$TripEndpointFromJson(Map<String, dynamic> json) =>
_TripEndpoint(
stopId: json['stopId'] as String,
stopExtId: json['stopExtId'] as String?,
name: json['name'] as String,
lat: (json['lat'] as num?)?.toDouble(),
lon: (json['lon'] as num?)?.toDouble(),
scheduledTime: DateTime.parse(json['scheduledTime'] as String),
realTime: json['realTime'] == null
? null
: DateTime.parse(json['realTime'] as String),
delayMinutes: (json['delayMinutes'] as num?)?.toInt(),
track: json['track'] as String?,
realTrack: json['realTrack'] as String?,
type: json['type'] as String?,
);
Map<String, dynamic> _$TripEndpointToJson(_TripEndpoint instance) =>
<String, dynamic>{
'stopId': instance.stopId,
'stopExtId': instance.stopExtId,
'name': instance.name,
'lat': instance.lat,
'lon': instance.lon,
'scheduledTime': instance.scheduledTime.toIso8601String(),
'realTime': instance.realTime?.toIso8601String(),
'delayMinutes': instance.delayMinutes,
'track': instance.track,
'realTrack': instance.realTrack,
'type': instance.type,
};
_JourneyStop _$JourneyStopFromJson(Map<String, dynamic> json) => _JourneyStop(
id: json['id'] as String,
extId: json['extId'] as String?,
name: json['name'] as String,
lat: (json['lat'] as num?)?.toDouble(),
lon: (json['lon'] as num?)?.toDouble(),
routeIdx: (json['routeIdx'] as num?)?.toInt(),
scheduledArrival: json['scheduledArrival'] == null
? null
: DateTime.parse(json['scheduledArrival'] as String),
scheduledDeparture: json['scheduledDeparture'] == null
? null
: DateTime.parse(json['scheduledDeparture'] as String),
realArrival: json['realArrival'] == null
? null
: DateTime.parse(json['realArrival'] as String),
realDeparture: json['realDeparture'] == null
? null
: DateTime.parse(json['realDeparture'] as String),
arrTrack: json['arrTrack'] as String?,
depTrack: json['depTrack'] as String?,
realArrTrack: json['realArrTrack'] as String?,
realDepTrack: json['realDepTrack'] as String?,
cancelled: json['cancelled'] as bool? ?? false,
cancelledArrival: json['cancelledArrival'] as bool? ?? false,
cancelledDeparture: json['cancelledDeparture'] as bool? ?? false,
);
Map<String, dynamic> _$JourneyStopToJson(_JourneyStop instance) =>
<String, dynamic>{
'id': instance.id,
'extId': instance.extId,
'name': instance.name,
'lat': instance.lat,
'lon': instance.lon,
'routeIdx': instance.routeIdx,
'scheduledArrival': instance.scheduledArrival?.toIso8601String(),
'scheduledDeparture': instance.scheduledDeparture?.toIso8601String(),
'realArrival': instance.realArrival?.toIso8601String(),
'realDeparture': instance.realDeparture?.toIso8601String(),
'arrTrack': instance.arrTrack,
'depTrack': instance.depTrack,
'realArrTrack': instance.realArrTrack,
'realDepTrack': instance.realDepTrack,
'cancelled': instance.cancelled,
'cancelledArrival': instance.cancelledArrival,
'cancelledDeparture': instance.cancelledDeparture,
};
_Leg _$LegFromJson(Map<String, dynamic> json) => _Leg(
id: json['id'] as String,
idx: (json['idx'] as num).toInt(),
type: $enumDecodeNullable(_$LegTypeEnumMap, json['type']) ?? LegType.unknown,
name: json['name'] as String?,
category: json['category'] as String?,
number: json['number'] as String?,
direction: json['direction'] as String?,
origin: TripEndpoint.fromJson(json['origin'] as Map<String, dynamic>),
destination: TripEndpoint.fromJson(
json['destination'] as Map<String, dynamic>,
),
duration: IsoDuration.fromJson(json['duration'] as String?),
cancelled: json['cancelled'] as bool? ?? false,
partCancelled: json['partCancelled'] as bool? ?? false,
reachable: json['reachable'] as bool? ?? true,
product: json['product'] == null
? null
: Product.fromJson(json['product'] as Map<String, dynamic>),
journeyRef: json['journeyRef'] as String?,
stops:
(json['stops'] as List<dynamic>?)
?.map((e) => JourneyStop.fromJson(e as Map<String, dynamic>))
.toList() ??
const <JourneyStop>[],
);
Map<String, dynamic> _$LegToJson(_Leg instance) => <String, dynamic>{
'id': instance.id,
'idx': instance.idx,
'type': _$LegTypeEnumMap[instance.type]!,
'name': instance.name,
'category': instance.category,
'number': instance.number,
'direction': instance.direction,
'origin': instance.origin,
'destination': instance.destination,
'duration': IsoDuration.toJson(instance.duration),
'cancelled': instance.cancelled,
'partCancelled': instance.partCancelled,
'reachable': instance.reachable,
'product': instance.product,
'journeyRef': instance.journeyRef,
'stops': instance.stops,
};
const _$LegTypeEnumMap = {
LegType.journey: 'JOURNEY',
LegType.walk: 'WALK',
LegType.transfer: 'TRANSFER',
LegType.bike: 'BIKE',
LegType.car: 'CAR',
LegType.parkRide: 'PARK_RIDE',
LegType.taxi: 'TAXI',
LegType.checkIn: 'CHECK_IN',
LegType.checkOut: 'CHECK_OUT',
LegType.dummy: 'DUMMY',
LegType.unknown: 'UNKNOWN',
};
_Trip _$TripFromJson(Map<String, dynamic> json) => _Trip(
tripId: json['tripId'] as String?,
ctxRecon: json['ctxRecon'] as String?,
checksum: json['checksum'] as String?,
duration: IsoDuration.fromJson(json['duration'] as String?),
realDuration: IsoDuration.fromJson(json['realDuration'] as String?),
transferCount: (json['transferCount'] as num?)?.toInt(),
legs:
(json['legs'] as List<dynamic>?)
?.map((e) => Leg.fromJson(e as Map<String, dynamic>))
.toList() ??
const <Leg>[],
);
Map<String, dynamic> _$TripToJson(_Trip instance) => <String, dynamic>{
'tripId': instance.tripId,
'ctxRecon': instance.ctxRecon,
'checksum': instance.checksum,
'duration': IsoDuration.toJson(instance.duration),
'realDuration': IsoDuration.toJson(instance.realDuration),
'transferCount': instance.transferCount,
'legs': instance.legs,
};
_TripSearchResult _$TripSearchResultFromJson(Map<String, dynamic> json) =>
_TripSearchResult(
trips:
(json['trips'] as List<dynamic>?)
?.map((e) => Trip.fromJson(e as Map<String, dynamic>))
.toList() ??
const <Trip>[],
scrollContextLater: json['scrollContextLater'] as String?,
scrollContextEarlier: json['scrollContextEarlier'] as String?,
);
Map<String, dynamic> _$TripSearchResultToJson(_TripSearchResult instance) =>
<String, dynamic>{
'trips': instance.trips,
'scrollContextLater': instance.scrollContextLater,
'scrollContextEarlier': instance.scrollContextEarlier,
};
_JourneyDetail _$JourneyDetailFromJson(Map<String, dynamic> json) =>
_JourneyDetail(
journeyId: json['journeyId'] as String?,
product: json['product'] == null
? null
: Product.fromJson(json['product'] as Map<String, dynamic>),
direction: json['direction'] as String?,
stops:
(json['stops'] as List<dynamic>?)
?.map((e) => JourneyStop.fromJson(e as Map<String, dynamic>))
.toList() ??
const <JourneyStop>[],
);
Map<String, dynamic> _$JourneyDetailToJson(_JourneyDetail instance) =>
<String, dynamic>{
'journeyId': instance.journeyId,
'product': instance.product,
'direction': instance.direction,
'stops': instance.stops,
};
_HimMessage _$HimMessageFromJson(Map<String, dynamic> json) => _HimMessage(
id: json['id'] as String,
externalId: json['externalId'] as String?,
head: json['head'] as String?,
lead: json['lead'] as String?,
text: json['text'] as String?,
category: json['category'] as String?,
company: json['company'] as String?,
priority: (json['priority'] as num?)?.toInt(),
products: (json['products'] as num?)?.toInt(),
startValidity: json['startValidity'] == null
? null
: DateTime.parse(json['startValidity'] as String),
endValidity: json['endValidity'] == null
? null
: DateTime.parse(json['endValidity'] as String),
modified: json['modified'] == null
? null
: DateTime.parse(json['modified'] as String),
);
Map<String, dynamic> _$HimMessageToJson(_HimMessage instance) =>
<String, dynamic>{
'id': instance.id,
'externalId': instance.externalId,
'head': instance.head,
'lead': instance.lead,
'text': instance.text,
'category': instance.category,
'company': instance.company,
'priority': instance.priority,
'products': instance.products,
'startValidity': instance.startValidity?.toIso8601String(),
'endValidity': instance.endValidity?.toIso8601String(),
'modified': instance.modified?.toIso8601String(),
};
+16
View File
@@ -0,0 +1,16 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'nominatim_result.freezed.dart';
part 'nominatim_result.g.dart';
@freezed
abstract class NominatimResult with _$NominatimResult {
const factory NominatimResult({
required String displayName,
required double lat,
required double lon,
}) = _NominatimResult;
factory NominatimResult.fromJson(Map<String, Object?> json) =>
_$NominatimResultFromJson(json);
}
@@ -0,0 +1,283 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'nominatim_result.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$NominatimResult {
String get displayName; double get lat; double get lon;
/// Create a copy of NominatimResult
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$NominatimResultCopyWith<NominatimResult> get copyWith => _$NominatimResultCopyWithImpl<NominatimResult>(this as NominatimResult, _$identity);
/// Serializes this NominatimResult to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is NominatimResult&&(identical(other.displayName, displayName) || other.displayName == displayName)&&(identical(other.lat, lat) || other.lat == lat)&&(identical(other.lon, lon) || other.lon == lon));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,displayName,lat,lon);
@override
String toString() {
return 'NominatimResult(displayName: $displayName, lat: $lat, lon: $lon)';
}
}
/// @nodoc
abstract mixin class $NominatimResultCopyWith<$Res> {
factory $NominatimResultCopyWith(NominatimResult value, $Res Function(NominatimResult) _then) = _$NominatimResultCopyWithImpl;
@useResult
$Res call({
String displayName, double lat, double lon
});
}
/// @nodoc
class _$NominatimResultCopyWithImpl<$Res>
implements $NominatimResultCopyWith<$Res> {
_$NominatimResultCopyWithImpl(this._self, this._then);
final NominatimResult _self;
final $Res Function(NominatimResult) _then;
/// Create a copy of NominatimResult
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? displayName = null,Object? lat = null,Object? lon = null,}) {
return _then(_self.copyWith(
displayName: null == displayName ? _self.displayName : displayName // ignore: cast_nullable_to_non_nullable
as String,lat: null == lat ? _self.lat : lat // ignore: cast_nullable_to_non_nullable
as double,lon: null == lon ? _self.lon : lon // ignore: cast_nullable_to_non_nullable
as double,
));
}
}
/// Adds pattern-matching-related methods to [NominatimResult].
extension NominatimResultPatterns on NominatimResult {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _NominatimResult value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _NominatimResult() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _NominatimResult value) $default,){
final _that = this;
switch (_that) {
case _NominatimResult():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _NominatimResult value)? $default,){
final _that = this;
switch (_that) {
case _NominatimResult() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String displayName, double lat, double lon)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _NominatimResult() when $default != null:
return $default(_that.displayName,_that.lat,_that.lon);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String displayName, double lat, double lon) $default,) {final _that = this;
switch (_that) {
case _NominatimResult():
return $default(_that.displayName,_that.lat,_that.lon);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String displayName, double lat, double lon)? $default,) {final _that = this;
switch (_that) {
case _NominatimResult() when $default != null:
return $default(_that.displayName,_that.lat,_that.lon);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _NominatimResult implements NominatimResult {
const _NominatimResult({required this.displayName, required this.lat, required this.lon});
factory _NominatimResult.fromJson(Map<String, dynamic> json) => _$NominatimResultFromJson(json);
@override final String displayName;
@override final double lat;
@override final double lon;
/// Create a copy of NominatimResult
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$NominatimResultCopyWith<_NominatimResult> get copyWith => __$NominatimResultCopyWithImpl<_NominatimResult>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$NominatimResultToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _NominatimResult&&(identical(other.displayName, displayName) || other.displayName == displayName)&&(identical(other.lat, lat) || other.lat == lat)&&(identical(other.lon, lon) || other.lon == lon));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,displayName,lat,lon);
@override
String toString() {
return 'NominatimResult(displayName: $displayName, lat: $lat, lon: $lon)';
}
}
/// @nodoc
abstract mixin class _$NominatimResultCopyWith<$Res> implements $NominatimResultCopyWith<$Res> {
factory _$NominatimResultCopyWith(_NominatimResult value, $Res Function(_NominatimResult) _then) = __$NominatimResultCopyWithImpl;
@override @useResult
$Res call({
String displayName, double lat, double lon
});
}
/// @nodoc
class __$NominatimResultCopyWithImpl<$Res>
implements _$NominatimResultCopyWith<$Res> {
__$NominatimResultCopyWithImpl(this._self, this._then);
final _NominatimResult _self;
final $Res Function(_NominatimResult) _then;
/// Create a copy of NominatimResult
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? displayName = null,Object? lat = null,Object? lon = null,}) {
return _then(_NominatimResult(
displayName: null == displayName ? _self.displayName : displayName // ignore: cast_nullable_to_non_nullable
as String,lat: null == lat ? _self.lat : lat // ignore: cast_nullable_to_non_nullable
as double,lon: null == lon ? _self.lon : lon // ignore: cast_nullable_to_non_nullable
as double,
));
}
}
// dart format on
+21
View File
@@ -0,0 +1,21 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'nominatim_result.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_NominatimResult _$NominatimResultFromJson(Map<String, dynamic> json) =>
_NominatimResult(
displayName: json['displayName'] as String,
lat: (json['lat'] as num).toDouble(),
lon: (json['lon'] as num).toDouble(),
);
Map<String, dynamic> _$NominatimResultToJson(_NominatimResult instance) =>
<String, dynamic>{
'displayName': instance.displayName,
'lat': instance.lat,
'lon': instance.lon,
};
+72
View File
@@ -0,0 +1,72 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../errors/network_exception.dart';
import '../errors/parse_exception.dart';
import '../errors/server_exception.dart';
import 'nominatim_result.dart';
/// Tiny wrapper around the public Nominatim geocoder. Only used in the
/// commute-settings flow to look up a home address; not called from any
/// hot path. The User-Agent header is **required** by the Nominatim usage
/// policy — without it the service throttles/blocks the client.
class NominatimSearch {
static const _userAgent = 'MarianumMobile/1.0 (contact@elias-mueller.com)';
static final Uri _base = Uri.parse('https://nominatim.openstreetmap.org/search');
/// Returns up to [limit] geocoded matches for the user-typed [query].
Future<List<NominatimResult>> run(String query, {int limit = 5}) async {
final uri = _base.replace(
queryParameters: {
'q': query,
'format': 'json',
'limit': limit.toString(),
'addressdetails': '0',
'accept-language': 'de',
},
);
final http.Response response;
try {
response = await http
.get(uri, headers: {'User-Agent': _userAgent, 'Accept': 'application/json'})
.timeout(const Duration(seconds: 15));
} on SocketException catch (e) {
throw NetworkException(technicalDetails: 'nominatim: ${e.message}');
} on TimeoutException catch (e) {
throw NetworkException.timeout(technicalDetails: 'nominatim: $e');
} on http.ClientException catch (e) {
throw NetworkException(technicalDetails: 'nominatim: ${e.message}');
}
if (response.statusCode > 299) {
throw ServerException(
statusCode: response.statusCode,
technicalDetails: 'nominatim HTTP ${response.statusCode}',
);
}
try {
final raw = jsonDecode(utf8.decode(response.bodyBytes)) as List;
return raw
.map((e) => _resultFromRaw(e as Map<String, dynamic>))
.toList(growable: false);
} catch (e) {
throw ParseException(technicalDetails: 'nominatim assemble: $e');
}
}
static NominatimResult _resultFromRaw(Map<String, dynamic> json) {
// Nominatim returns lat/lon as strings, not numbers. Normalise here.
final lat = double.parse(json['lat'].toString());
final lon = double.parse(json['lon'].toString());
return NominatimResult(
displayName: json['display_name'] as String? ?? '?',
lat: lat,
lon: lon,
);
}
}
@@ -83,9 +83,49 @@ class SearchFilesEntry {
return null; return null;
} }
/// Derives a filename with its extension from the WebDAV-relative path.
/// The provider's `title` is often the display name *without* the
/// extension, which would defeat extension-based icon mapping.
String _nameFromPath(String path) {
final stripped = path.replaceAll(RegExp(r'^/+|/+$'), '');
if (stripped.isEmpty) return title;
final last = stripped.split('/').last;
return last.isEmpty ? title : last;
}
/// The files search provider exposes the numeric Nextcloud file id either
/// as an attribute, or implicitly in the `?openfile=<id>` query parameter
/// of [resourceUrl]. Parses both into an [int] when available — used to
/// hit the preview endpoint directly.
int? _extractFileId() {
final attr = attributes?['fileId'];
if (attr is int) return attr;
if (attr is String) {
final parsed = int.tryParse(attr);
if (parsed != null) return parsed;
}
final url = resourceUrl;
if (url != null) {
final raw = Uri.tryParse(url)?.queryParameters['openfile'];
if (raw != null) return int.tryParse(raw);
}
return null;
}
CacheableFile? toCacheable() { CacheableFile? toCacheable() {
final path = webdavPath; final path = webdavPath;
if (path == null) return null; if (path == null) return null;
return CacheableFile(path: path, isDirectory: isDirectory, name: title); final fileId = _extractFileId();
return CacheableFile(
path: path,
isDirectory: isDirectory,
name: _nameFromPath(path),
fileId: fileId,
// Search provider responses don't carry `nc:has-preview`. Probe
// optimistically when a fileId is available — the preview endpoint
// simply returns 404 for unsupported formats, which the file-leading
// widget already falls back from to the typed icon.
hasPreview: (!isDirectory && fileId != null) ? true : null,
);
} }
} }

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