From 11e92c16552935b855f55378f963960a3b3a0b5c Mon Sep 17 00:00:00 2001 From: Shautvast Date: Mon, 30 Mar 2026 09:22:16 +0200 Subject: [PATCH] draft --- .gitignore | 6 + README.md | 0 backend/Cargo.lock | 3713 +++++++++++++++++ backend/Cargo.toml | 24 + backend/Dockerfile | 21 + backend/docker-compose.yml | 73 + backend/src/config.rs | 55 + backend/src/errors.rs | 113 + backend/src/main.rs | 83 + backend/src/models/health.rs | 46 + backend/src/models/mod.rs | 5 + backend/src/models/offline.rs | 82 + backend/src/models/poi.rs | 140 + backend/src/models/route.rs | 33 + backend/src/models/search.rs | 16 + backend/src/routes/health.rs | 111 + backend/src/routes/mod.rs | 46 + backend/src/routes/offline.rs | 194 + backend/src/routes/pois.rs | 249 ++ backend/src/routes/routing.rs | 182 + backend/src/routes/search.rs | 216 + backend/src/routes/tiles.rs | 93 + backend/src/services/cache.rs | 113 + backend/src/services/martin.rs | 102 + backend/src/services/mod.rs | 4 + backend/src/services/osrm.rs | 98 + backend/src/services/photon.rs | 118 + docs/API.md | 917 ++++ docs/ARCHITECTURE.md | 791 ++++ docs/DATA_MODEL.md | 1329 ++++++ docs/SPECS.md | 847 ++++ mobile/.gitignore | 45 + mobile/.metadata | 45 + mobile/README.md | 16 + mobile/analysis_options.yaml | 28 + mobile/android/.gitignore | 14 + mobile/android/app/build.gradle.kts | 44 + .../android/app/src/debug/AndroidManifest.xml | 7 + .../android/app/src/main/AndroidManifest.xml | 45 + .../privacymaps/privacy_maps/MainActivity.kt | 5 + .../res/drawable-v21/launch_background.xml | 12 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values-night/styles.xml | 18 + .../app/src/main/res/values/styles.xml | 18 + .../app/src/profile/AndroidManifest.xml | 7 + mobile/android/build.gradle.kts | 21 + mobile/android/gradle.properties | 3 + .../gradle/wrapper/gradle-wrapper.properties | 5 + mobile/android/settings.gradle.kts | 25 + mobile/ios/.gitignore | 34 + mobile/ios/Flutter/AppFrameworkInfo.plist | 26 + mobile/ios/Flutter/Debug.xcconfig | 2 + mobile/ios/Flutter/Release.xcconfig | 2 + mobile/ios/Podfile | 41 + mobile/ios/Runner.xcodeproj/project.pbxproj | 616 +++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + .../xcshareddata/xcschemes/Runner.xcscheme | 99 + .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + mobile/ios/Runner/AppDelegate.swift | 13 + .../AppIcon.appiconset/Contents.json | 122 + .../Icon-App-1024x1024@1x.png | Bin 0 -> 10932 bytes .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 0 -> 295 bytes .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin 0 -> 450 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 0 -> 282 bytes .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin 0 -> 462 bytes .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin 0 -> 704 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin 0 -> 586 bytes .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin 0 -> 1674 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 0 -> 762 bytes .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin 0 -> 1226 bytes .../Icon-App-83.5x83.5@2x.png | Bin 0 -> 1418 bytes .../LaunchImage.imageset/Contents.json | 23 + .../LaunchImage.imageset/LaunchImage.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@2x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@3x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/README.md | 5 + .../Runner/Base.lproj/LaunchScreen.storyboard | 37 + mobile/ios/Runner/Base.lproj/Main.storyboard | 26 + mobile/ios/Runner/Info.plist | 49 + mobile/ios/Runner/Runner-Bridging-Header.h | 1 + mobile/ios/RunnerTests/RunnerTests.swift | 12 + mobile/lib/app/app.dart | 22 + mobile/lib/app/router.dart | 55 + mobile/lib/app/theme.dart | 69 + mobile/lib/core/api/api_client.dart | 115 + mobile/lib/core/constants.dart | 30 + mobile/lib/core/database/app_database.dart | 173 + mobile/lib/core/database/app_database.g.dart | 2936 +++++++++++++ mobile/lib/core/database/tables.dart | 53 + .../map/presentation/screens/map_screen.dart | 157 + .../presentation/widgets/map_controls.dart | 47 + .../map/presentation/widgets/place_card.dart | 104 + .../features/map/providers/map_provider.dart | 158 + .../offline/data/offline_repository.dart | 168 + .../presentation/screens/offline_screen.dart | 202 + .../offline/providers/offline_provider.dart | 171 + .../places/data/places_repository.dart | 164 + .../screens/place_detail_screen.dart | 313 ++ .../places/presentation/widgets/poi_chip.dart | 56 + .../places/providers/places_provider.dart | 91 + .../routing/data/routing_repository.dart | 173 + .../presentation/screens/route_screen.dart | 347 ++ .../presentation/widgets/route_summary.dart | 87 + .../presentation/widgets/step_list.dart | 84 + .../routing/providers/routing_provider.dart | 137 + .../search/data/search_repository.dart | 136 + .../presentation/screens/search_screen.dart | 170 + .../widgets/search_result_tile.dart | 76 + .../search/providers/search_provider.dart | 117 + .../presentation/screens/settings_screen.dart | 251 ++ mobile/lib/main.dart | 12 + mobile/linux/.gitignore | 1 + mobile/linux/CMakeLists.txt | 128 + mobile/linux/flutter/CMakeLists.txt | 88 + .../flutter/generated_plugin_registrant.cc | 15 + .../flutter/generated_plugin_registrant.h | 15 + mobile/linux/flutter/generated_plugins.cmake | 24 + mobile/linux/runner/CMakeLists.txt | 26 + mobile/linux/runner/main.cc | 6 + mobile/linux/runner/my_application.cc | 130 + mobile/linux/runner/my_application.h | 18 + mobile/macos/.gitignore | 7 + mobile/macos/Flutter/Flutter-Debug.xcconfig | 2 + mobile/macos/Flutter/Flutter-Release.xcconfig | 2 + .../Flutter/GeneratedPluginRegistrant.swift | 16 + mobile/macos/Podfile | 42 + mobile/macos/Runner.xcodeproj/project.pbxproj | 705 ++++ .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/xcschemes/Runner.xcscheme | 99 + .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + mobile/macos/Runner/AppDelegate.swift | 13 + .../AppIcon.appiconset/Contents.json | 68 + .../AppIcon.appiconset/app_icon_1024.png | Bin 0 -> 102994 bytes .../AppIcon.appiconset/app_icon_128.png | Bin 0 -> 5680 bytes .../AppIcon.appiconset/app_icon_16.png | Bin 0 -> 520 bytes .../AppIcon.appiconset/app_icon_256.png | Bin 0 -> 14142 bytes .../AppIcon.appiconset/app_icon_32.png | Bin 0 -> 1066 bytes .../AppIcon.appiconset/app_icon_512.png | Bin 0 -> 36406 bytes .../AppIcon.appiconset/app_icon_64.png | Bin 0 -> 2218 bytes mobile/macos/Runner/Base.lproj/MainMenu.xib | 343 ++ mobile/macos/Runner/Configs/AppInfo.xcconfig | 14 + mobile/macos/Runner/Configs/Debug.xcconfig | 2 + mobile/macos/Runner/Configs/Release.xcconfig | 2 + mobile/macos/Runner/Configs/Warnings.xcconfig | 13 + mobile/macos/Runner/DebugProfile.entitlements | 12 + mobile/macos/Runner/Info.plist | 32 + mobile/macos/Runner/MainFlutterWindow.swift | 15 + mobile/macos/Runner/Release.entitlements | 8 + mobile/macos/RunnerTests/RunnerTests.swift | 12 + mobile/pubspec.lock | 1026 +++++ mobile/pubspec.yaml | 99 + mobile/test/widget_test.dart | 8 + mobile/web/favicon.png | Bin 0 -> 917 bytes mobile/web/icons/Icon-192.png | Bin 0 -> 5292 bytes mobile/web/icons/Icon-512.png | Bin 0 -> 8252 bytes mobile/web/icons/Icon-maskable-192.png | Bin 0 -> 5594 bytes mobile/web/icons/Icon-maskable-512.png | Bin 0 -> 20998 bytes mobile/web/index.html | 38 + mobile/web/manifest.json | 35 + mobile/windows/.gitignore | 17 + mobile/windows/CMakeLists.txt | 108 + mobile/windows/flutter/CMakeLists.txt | 109 + .../flutter/generated_plugin_registrant.cc | 17 + .../flutter/generated_plugin_registrant.h | 15 + .../windows/flutter/generated_plugins.cmake | 25 + mobile/windows/runner/CMakeLists.txt | 40 + mobile/windows/runner/Runner.rc | 121 + mobile/windows/runner/flutter_window.cpp | 71 + mobile/windows/runner/flutter_window.h | 33 + mobile/windows/runner/main.cpp | 43 + mobile/windows/runner/resource.h | 16 + mobile/windows/runner/resources/app_icon.ico | Bin 0 -> 33772 bytes mobile/windows/runner/runner.exe.manifest | 14 + mobile/windows/runner/utils.cpp | 65 + mobile/windows/runner/utils.h | 19 + mobile/windows/runner/win32_window.cpp | 288 ++ mobile/windows/runner/win32_window.h | 102 + 191 files changed, 22208 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/Cargo.lock create mode 100644 backend/Cargo.toml create mode 100644 backend/Dockerfile create mode 100644 backend/docker-compose.yml create mode 100644 backend/src/config.rs create mode 100644 backend/src/errors.rs create mode 100644 backend/src/main.rs create mode 100644 backend/src/models/health.rs create mode 100644 backend/src/models/mod.rs create mode 100644 backend/src/models/offline.rs create mode 100644 backend/src/models/poi.rs create mode 100644 backend/src/models/route.rs create mode 100644 backend/src/models/search.rs create mode 100644 backend/src/routes/health.rs create mode 100644 backend/src/routes/mod.rs create mode 100644 backend/src/routes/offline.rs create mode 100644 backend/src/routes/pois.rs create mode 100644 backend/src/routes/routing.rs create mode 100644 backend/src/routes/search.rs create mode 100644 backend/src/routes/tiles.rs create mode 100644 backend/src/services/cache.rs create mode 100644 backend/src/services/martin.rs create mode 100644 backend/src/services/mod.rs create mode 100644 backend/src/services/osrm.rs create mode 100644 backend/src/services/photon.rs create mode 100644 docs/API.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/DATA_MODEL.md create mode 100644 docs/SPECS.md create mode 100644 mobile/.gitignore create mode 100644 mobile/.metadata create mode 100644 mobile/README.md create mode 100644 mobile/analysis_options.yaml create mode 100644 mobile/android/.gitignore create mode 100644 mobile/android/app/build.gradle.kts create mode 100644 mobile/android/app/src/debug/AndroidManifest.xml create mode 100644 mobile/android/app/src/main/AndroidManifest.xml create mode 100644 mobile/android/app/src/main/kotlin/com/privacymaps/privacy_maps/MainActivity.kt create mode 100644 mobile/android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 mobile/android/app/src/main/res/drawable/launch_background.xml create mode 100644 mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 mobile/android/app/src/main/res/values-night/styles.xml create mode 100644 mobile/android/app/src/main/res/values/styles.xml create mode 100644 mobile/android/app/src/profile/AndroidManifest.xml create mode 100644 mobile/android/build.gradle.kts create mode 100644 mobile/android/gradle.properties create mode 100644 mobile/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 mobile/android/settings.gradle.kts create mode 100644 mobile/ios/.gitignore create mode 100644 mobile/ios/Flutter/AppFrameworkInfo.plist create mode 100644 mobile/ios/Flutter/Debug.xcconfig create mode 100644 mobile/ios/Flutter/Release.xcconfig create mode 100644 mobile/ios/Podfile create mode 100644 mobile/ios/Runner.xcodeproj/project.pbxproj create mode 100644 mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 mobile/ios/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 mobile/ios/Runner/AppDelegate.swift create mode 100644 mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png create mode 100644 mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png create mode 100644 mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png create mode 100644 mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png create mode 100644 mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png create mode 100644 mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png create mode 100644 mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png create mode 100644 mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png create mode 100644 mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png create mode 100644 mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png create mode 100644 mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png create mode 100644 mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png create mode 100644 mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png create mode 100644 mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png create mode 100644 mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png create mode 100644 mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json create mode 100644 mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png create mode 100644 mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png create mode 100644 mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png create mode 100644 mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md create mode 100644 mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard create mode 100644 mobile/ios/Runner/Base.lproj/Main.storyboard create mode 100644 mobile/ios/Runner/Info.plist create mode 100644 mobile/ios/Runner/Runner-Bridging-Header.h create mode 100644 mobile/ios/RunnerTests/RunnerTests.swift create mode 100644 mobile/lib/app/app.dart create mode 100644 mobile/lib/app/router.dart create mode 100644 mobile/lib/app/theme.dart create mode 100644 mobile/lib/core/api/api_client.dart create mode 100644 mobile/lib/core/constants.dart create mode 100644 mobile/lib/core/database/app_database.dart create mode 100644 mobile/lib/core/database/app_database.g.dart create mode 100644 mobile/lib/core/database/tables.dart create mode 100644 mobile/lib/features/map/presentation/screens/map_screen.dart create mode 100644 mobile/lib/features/map/presentation/widgets/map_controls.dart create mode 100644 mobile/lib/features/map/presentation/widgets/place_card.dart create mode 100644 mobile/lib/features/map/providers/map_provider.dart create mode 100644 mobile/lib/features/offline/data/offline_repository.dart create mode 100644 mobile/lib/features/offline/presentation/screens/offline_screen.dart create mode 100644 mobile/lib/features/offline/providers/offline_provider.dart create mode 100644 mobile/lib/features/places/data/places_repository.dart create mode 100644 mobile/lib/features/places/presentation/screens/place_detail_screen.dart create mode 100644 mobile/lib/features/places/presentation/widgets/poi_chip.dart create mode 100644 mobile/lib/features/places/providers/places_provider.dart create mode 100644 mobile/lib/features/routing/data/routing_repository.dart create mode 100644 mobile/lib/features/routing/presentation/screens/route_screen.dart create mode 100644 mobile/lib/features/routing/presentation/widgets/route_summary.dart create mode 100644 mobile/lib/features/routing/presentation/widgets/step_list.dart create mode 100644 mobile/lib/features/routing/providers/routing_provider.dart create mode 100644 mobile/lib/features/search/data/search_repository.dart create mode 100644 mobile/lib/features/search/presentation/screens/search_screen.dart create mode 100644 mobile/lib/features/search/presentation/widgets/search_result_tile.dart create mode 100644 mobile/lib/features/search/providers/search_provider.dart create mode 100644 mobile/lib/features/settings/presentation/screens/settings_screen.dart create mode 100644 mobile/lib/main.dart create mode 100644 mobile/linux/.gitignore create mode 100644 mobile/linux/CMakeLists.txt create mode 100644 mobile/linux/flutter/CMakeLists.txt create mode 100644 mobile/linux/flutter/generated_plugin_registrant.cc create mode 100644 mobile/linux/flutter/generated_plugin_registrant.h create mode 100644 mobile/linux/flutter/generated_plugins.cmake create mode 100644 mobile/linux/runner/CMakeLists.txt create mode 100644 mobile/linux/runner/main.cc create mode 100644 mobile/linux/runner/my_application.cc create mode 100644 mobile/linux/runner/my_application.h create mode 100644 mobile/macos/.gitignore create mode 100644 mobile/macos/Flutter/Flutter-Debug.xcconfig create mode 100644 mobile/macos/Flutter/Flutter-Release.xcconfig create mode 100644 mobile/macos/Flutter/GeneratedPluginRegistrant.swift create mode 100644 mobile/macos/Podfile create mode 100644 mobile/macos/Runner.xcodeproj/project.pbxproj create mode 100644 mobile/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 mobile/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 mobile/macos/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 mobile/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 mobile/macos/Runner/AppDelegate.swift create mode 100644 mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png create mode 100644 mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png create mode 100644 mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png create mode 100644 mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png create mode 100644 mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png create mode 100644 mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png create mode 100644 mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png create mode 100644 mobile/macos/Runner/Base.lproj/MainMenu.xib create mode 100644 mobile/macos/Runner/Configs/AppInfo.xcconfig create mode 100644 mobile/macos/Runner/Configs/Debug.xcconfig create mode 100644 mobile/macos/Runner/Configs/Release.xcconfig create mode 100644 mobile/macos/Runner/Configs/Warnings.xcconfig create mode 100644 mobile/macos/Runner/DebugProfile.entitlements create mode 100644 mobile/macos/Runner/Info.plist create mode 100644 mobile/macos/Runner/MainFlutterWindow.swift create mode 100644 mobile/macos/Runner/Release.entitlements create mode 100644 mobile/macos/RunnerTests/RunnerTests.swift create mode 100644 mobile/pubspec.lock create mode 100644 mobile/pubspec.yaml create mode 100644 mobile/test/widget_test.dart create mode 100644 mobile/web/favicon.png create mode 100644 mobile/web/icons/Icon-192.png create mode 100644 mobile/web/icons/Icon-512.png create mode 100644 mobile/web/icons/Icon-maskable-192.png create mode 100644 mobile/web/icons/Icon-maskable-512.png create mode 100644 mobile/web/index.html create mode 100644 mobile/web/manifest.json create mode 100644 mobile/windows/.gitignore create mode 100644 mobile/windows/CMakeLists.txt create mode 100644 mobile/windows/flutter/CMakeLists.txt create mode 100644 mobile/windows/flutter/generated_plugin_registrant.cc create mode 100644 mobile/windows/flutter/generated_plugin_registrant.h create mode 100644 mobile/windows/flutter/generated_plugins.cmake create mode 100644 mobile/windows/runner/CMakeLists.txt create mode 100644 mobile/windows/runner/Runner.rc create mode 100644 mobile/windows/runner/flutter_window.cpp create mode 100644 mobile/windows/runner/flutter_window.h create mode 100644 mobile/windows/runner/main.cpp create mode 100644 mobile/windows/runner/resource.h create mode 100644 mobile/windows/runner/resources/app_icon.ico create mode 100644 mobile/windows/runner/runner.exe.manifest create mode 100644 mobile/windows/runner/utils.cpp create mode 100644 mobile/windows/runner/utils.h create mode 100644 mobile/windows/runner/win32_window.cpp create mode 100644 mobile/windows/runner/win32_window.h diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4eb60be --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +target/ +.idea/ +.dart_tool +*.iml +.flutter-plugins +.flutter-plugins-dependencies diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/backend/Cargo.lock b/backend/Cargo.lock new file mode 100644 index 0000000..3206918 --- /dev/null +++ b/backend/Cargo.lock @@ -0,0 +1,3713 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "actix-codec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-sink", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-cors" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daa239b93927be1ff123eebada5a3ff23e89f0124ccb8609234e5103d5a5ae6d" +dependencies = [ + "actix-utils", + "actix-web", + "derive_more", + "futures-util", + "log", + "once_cell", + "smallvec", +] + +[[package]] +name = "actix-http" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f860ee6746d0c5b682147b2f7f8ef036d4f92fe518251a3a35ffa3650eafdf0e" +dependencies = [ + "actix-codec", + "actix-rt", + "actix-service", + "actix-utils", + "base64", + "bitflags", + "brotli", + "bytes", + "bytestring", + "derive_more", + "encoding_rs", + "flate2", + "foldhash", + "futures-core", + "h2 0.3.27", + "http 0.2.12", + "httparse", + "httpdate", + "itoa", + "language-tags", + "local-channel", + "mime", + "percent-encoding", + "pin-project-lite", + "rand 0.9.2", + "sha1", + "smallvec", + "tokio", + "tokio-util", + "tracing", + "zstd", +] + +[[package]] +name = "actix-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "actix-router" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14f8c75c51892f18d9c46150c5ac7beb81c95f78c8b83a634d49f4ca32551fe7" +dependencies = [ + "bytestring", + "cfg-if", + "http 0.2.12", + "regex", + "regex-lite", + "serde", + "tracing", +] + +[[package]] +name = "actix-rt" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" +dependencies = [ + "futures-core", + "tokio", +] + +[[package]] +name = "actix-server" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "futures-util", + "mio", + "socket2 0.5.10", + "tokio", + "tracing", +] + +[[package]] +name = "actix-service" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "actix-utils" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" +dependencies = [ + "local-waker", + "pin-project-lite", +] + +[[package]] +name = "actix-web" +version = "4.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff87453bc3b56e9b2b23c1cc0b1be8797184accf51d2abe0f8a33ec275d316bf" +dependencies = [ + "actix-codec", + "actix-http", + "actix-macros", + "actix-router", + "actix-rt", + "actix-server", + "actix-service", + "actix-utils", + "actix-web-codegen", + "bytes", + "bytestring", + "cfg-if", + "cookie", + "derive_more", + "encoding_rs", + "foldhash", + "futures-core", + "futures-util", + "impl-more", + "itoa", + "language-tags", + "log", + "mime", + "once_cell", + "pin-project-lite", + "regex", + "regex-lite", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "socket2 0.6.3", + "time", + "tracing", + "url", +] + +[[package]] +name = "actix-web-codegen" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f591380e2e68490b5dfaf1dd1aa0ebe78d84ba7067078512b4ea6e4492d622b8" +dependencies = [ + "actix-router", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arc-swap" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" +dependencies = [ + "rustversion", +] + +[[package]] +name = "as-slice" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45403b49e3954a4b8428a0ac21a4b7afadccf92bfd96273f1a58cd4812496ae0" +dependencies = [ + "generic-array 0.12.4", + "generic-array 0.13.3", + "generic-array 0.14.7", + "stable_deref_trait", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array 0.14.7", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "bytestring" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "113b4343b5f6617e7ad401ced8de3cc8b012e73a594347c307b90db3e9271289" +dependencies = [ + "bytes", +] + +[[package]] +name = "cc" +version = "1.2.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array 0.14.7", + "typenum", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd" +dependencies = [ + "typenum", +] + +[[package]] +name = "generic-array" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f797e67af32588215eaaab8327027ee8e71b9dd0b2b26996aedf20c030fce309" +dependencies = [ + "typenum", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "geo-types" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24f8647af4005fa11da47cd56252c6ef030be8fa97bdbf355e7dfb6348f0a82c" +dependencies = [ + "approx", + "num-traits", + "rstar 0.10.0", + "rstar 0.11.0", + "rstar 0.12.2", + "rstar 0.8.4", + "rstar 0.9.3", + "serde", +] + +[[package]] +name = "geojson" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e26f3c45b36fccc9cf2805e61d4da6bc4bbd5a3a9589b01afa3a40eff703bd79" +dependencies = [ + "geo-types", + "log", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hash32" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4041af86e63ac4298ce40e5cca669066e75b6f1aa3390fe2561ffa5e1d9f4cc" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heapless" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634bd4d29cbf24424d0a4bfcbf80c6960129dc24424752a7d1d1390607023422" +dependencies = [ + "as-slice", + "generic-array 0.14.7", + "hash32 0.1.1", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32 0.2.1", + "rustc_version", + "spin", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2 0.4.13", + "http 1.4.0", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http 1.4.0", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http 1.4.0", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.3", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "impl-more" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "language-tags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.7.3", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "local-channel" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8" +dependencies = [ + "futures-core", + "futures-sink", + "local-waker", +] + +[[package]] +name = "local-waker" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "maps-backend" +version = "1.0.0" +dependencies = [ + "actix-cors", + "actix-web", + "bytes", + "chrono", + "dotenvy", + "geojson", + "hex", + "redis", + "reqwest", + "serde", + "serde_json", + "sha2", + "sqlx", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "pdqselect" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ec91767ecc0a0bbe558ce8c9da33c068066c57ecc8bb8477ef8c1ad3ef77c27" + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redis" +version = "0.27.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09d8f99a4090c89cc489a94833c901ead69bfbf3877b4867d5482e321ee875bc" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "combine", + "futures-util", + "itertools", + "itoa", + "num-bigint", + "percent-encoding", + "pin-project-lite", + "ryu", + "sha1_smol", + "socket2 0.5.10", + "tokio", + "tokio-util", + "url", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2 0.4.13", + "http 1.4.0", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rstar" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a45c0e8804d37e4d97e55c6f258bc9ad9c5ee7b07437009dd152d764949a27c" +dependencies = [ + "heapless 0.6.1", + "num-traits", + "pdqselect", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40f1bfe5acdab44bc63e6699c28b74f75ec43afb59f3eda01e145aff86a25fa" +dependencies = [ + "heapless 0.7.17", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f39465655a1e3d8ae79c6d9e007f4953bfc5d55297602df9dc38f9ae9f1359a" +dependencies = [ + "heapless 0.7.17", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73111312eb7a2287d229f06c00ff35b51ddee180f017ab6dec1f69d62ac098d6" +dependencies = [ + "heapless 0.7.17", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "421400d13ccfd26dfa5858199c30a5d76f9c54e0dba7575273025b43c5175dbb" +dependencies = [ + "heapless 0.8.0", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array 0.14.7", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.5", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http 1.4.0", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/backend/Cargo.toml b/backend/Cargo.toml new file mode 100644 index 0000000..a92857c --- /dev/null +++ b/backend/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "maps-backend" +version = "1.0.0" +edition = "2021" +description = "Privacy-first maps API gateway" + +[dependencies] +actix-web = "4" +actix-cors = "0.7" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "json", "chrono"] } +bytes = "1" +reqwest = { version = "0.12", features = ["json"] } +redis = { version = "0.27", features = ["tokio-comp"] } +tokio = { version = "1", features = ["full"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +dotenvy = "0.15" +geojson = "0.24" +sha2 = "0.10" +hex = "0.4" +uuid = { version = "1", features = ["v4"] } +chrono = { version = "0.4", features = ["serde"] } diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..533e948 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,21 @@ +# Stage 1: Build +FROM rust:1.94-bookworm AS builder + +WORKDIR /usr/src/app +COPY Cargo.toml Cargo.lock* ./ +COPY src/ src/ + +RUN cargo build --release + +# Stage 2: Runtime +FROM debian:bookworm-slim + +RUN apt-get update && \ + apt-get install -y --no-install-recommends ca-certificates && \ + rm -rf /var/lib/apt/lists/* + +COPY --from=builder /usr/src/app/target/release/maps-backend /usr/local/bin/maps-backend + +EXPOSE 8080 + +CMD ["maps-backend"] diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..0fe4156 --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,73 @@ +services: + backend: + build: . + ports: + - "8080:8080" + environment: + HOST: "0.0.0.0" + PORT: "8080" + MARTIN_URL: "http://martin:3000" + PHOTON_URL: "http://photon:2322" + OSRM_DRIVING_URL: "http://osrm-driving:5000" + OSRM_WALKING_URL: "http://osrm-walking:5000" + OSRM_CYCLING_URL: "http://osrm-cycling:5000" + DATABASE_URL: "postgres://maps:maps@postgres:5432/maps" + REDIS_URL: "redis://redis:6379" + OFFLINE_DATA_DIR: "/data/offline" + depends_on: + - postgres + - redis + - martin + - photon + - osrm-driving + - osrm-walking + - osrm-cycling + + postgres: + image: postgis/postgis:16-3.4 + ports: + - "5432:5432" + environment: + POSTGRES_USER: maps + POSTGRES_PASSWORD: maps + POSTGRES_DB: maps + volumes: + - pgdata:/var/lib/postgresql/data + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + command: redis-server --maxmemory 2gb --maxmemory-policy allkeys-lru + + martin: + image: ghcr.io/maplibre/martin + ports: + - "3000:3000" + environment: + DATABASE_URL: "postgres://maps:maps@postgres:5432/maps" + depends_on: + - postgres + + photon: + image: komoot/photon:latest + ports: + - "2322:2322" + + osrm-driving: + image: osrm/osrm-backend:latest + ports: + - "5000:5000" + + osrm-walking: + image: osrm/osrm-backend:latest + ports: + - "5001:5000" + + osrm-cycling: + image: osrm/osrm-backend:latest + ports: + - "5002:5000" + +volumes: + pgdata: diff --git a/backend/src/config.rs b/backend/src/config.rs new file mode 100644 index 0000000..2e68e57 --- /dev/null +++ b/backend/src/config.rs @@ -0,0 +1,55 @@ +/// Environment-based configuration for all upstream services and the gateway itself. + +#[derive(Debug, Clone)] +pub struct AppConfig { + pub host: String, + pub port: u16, + pub martin_url: String, + pub photon_url: String, + pub osrm_driving_url: String, + pub osrm_walking_url: String, + pub osrm_cycling_url: String, + pub database_url: String, + pub redis_url: String, + pub offline_data_dir: String, +} + +impl AppConfig { + /// Load configuration from environment variables with sensible defaults + /// for docker-compose deployment. + pub fn from_env() -> Self { + Self { + host: std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".into()), + port: std::env::var("PORT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(8080), + martin_url: std::env::var("MARTIN_URL") + .unwrap_or_else(|_| "http://martin:3000".into()), + photon_url: std::env::var("PHOTON_URL") + .unwrap_or_else(|_| "http://photon:2322".into()), + osrm_driving_url: std::env::var("OSRM_DRIVING_URL") + .unwrap_or_else(|_| "http://osrm-driving:5000".into()), + osrm_walking_url: std::env::var("OSRM_WALKING_URL") + .unwrap_or_else(|_| "http://osrm-walking:5000".into()), + osrm_cycling_url: std::env::var("OSRM_CYCLING_URL") + .unwrap_or_else(|_| "http://osrm-cycling:5000".into()), + database_url: std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://maps:maps@postgis:5432/maps".into()), + redis_url: std::env::var("REDIS_URL") + .unwrap_or_else(|_| "redis://redis:6379".into()), + offline_data_dir: std::env::var("OFFLINE_DATA_DIR") + .unwrap_or_else(|_| "/data/offline".into()), + } + } + + /// Return the OSRM base URL for a given routing profile. + pub fn osrm_url_for_profile(&self, profile: &str) -> Option<&str> { + match profile { + "driving" => Some(&self.osrm_driving_url), + "walking" => Some(&self.osrm_walking_url), + "cycling" => Some(&self.osrm_cycling_url), + _ => None, + } + } +} diff --git a/backend/src/errors.rs b/backend/src/errors.rs new file mode 100644 index 0000000..a377d79 --- /dev/null +++ b/backend/src/errors.rs @@ -0,0 +1,113 @@ +use actix_web::{HttpResponse, ResponseError}; +use serde::Serialize; +use std::fmt; + +/// Standard JSON error body matching the API contract. +#[derive(Debug, Serialize)] +pub struct ErrorBody { + pub error: ErrorDetail, +} + +#[derive(Debug, Serialize)] +pub struct ErrorDetail { + pub code: String, + pub message: String, +} + +/// Application-level error type that maps to HTTP status codes and +/// the standard error JSON format defined in API.md. +#[derive(Debug)] +pub enum AppError { + MissingParameter(String), + InvalidParameter(String), + InvalidBbox(String), + InvalidCoordinates(String), + NotFound(String), + RateLimited, + UpstreamError(String), + ServiceUnavailable(String), + InternalError(String), +} + +impl fmt::Display for AppError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AppError::MissingParameter(msg) => write!(f, "Missing parameter: {msg}"), + AppError::InvalidParameter(msg) => write!(f, "Invalid parameter: {msg}"), + AppError::InvalidBbox(msg) => write!(f, "Invalid bbox: {msg}"), + AppError::InvalidCoordinates(msg) => write!(f, "Invalid coordinates: {msg}"), + AppError::NotFound(msg) => write!(f, "Not found: {msg}"), + AppError::RateLimited => write!(f, "Rate limited"), + AppError::UpstreamError(msg) => write!(f, "Upstream error: {msg}"), + AppError::ServiceUnavailable(msg) => write!(f, "Service unavailable: {msg}"), + AppError::InternalError(msg) => write!(f, "Internal error: {msg}"), + } + } +} + +impl ResponseError for AppError { + fn error_response(&self) -> HttpResponse { + let (status, code) = match self { + AppError::MissingParameter(_) => { + (actix_web::http::StatusCode::BAD_REQUEST, "MISSING_PARAMETER") + } + AppError::InvalidParameter(_) => { + (actix_web::http::StatusCode::BAD_REQUEST, "INVALID_PARAMETER") + } + AppError::InvalidBbox(_) => { + (actix_web::http::StatusCode::BAD_REQUEST, "INVALID_BBOX") + } + AppError::InvalidCoordinates(_) => { + (actix_web::http::StatusCode::BAD_REQUEST, "INVALID_COORDINATES") + } + AppError::NotFound(_) => (actix_web::http::StatusCode::NOT_FOUND, "NOT_FOUND"), + AppError::RateLimited => { + (actix_web::http::StatusCode::TOO_MANY_REQUESTS, "RATE_LIMITED") + } + AppError::UpstreamError(_) => { + (actix_web::http::StatusCode::BAD_GATEWAY, "UPSTREAM_ERROR") + } + AppError::ServiceUnavailable(_) => ( + actix_web::http::StatusCode::SERVICE_UNAVAILABLE, + "SERVICE_UNAVAILABLE", + ), + AppError::InternalError(_) => ( + actix_web::http::StatusCode::INTERNAL_SERVER_ERROR, + "INTERNAL_ERROR", + ), + }; + + HttpResponse::build(status).json(ErrorBody { + error: ErrorDetail { + code: code.to_string(), + message: self.to_string(), + }, + }) + } +} + +impl From for AppError { + fn from(err: sqlx::Error) -> Self { + tracing::error!(error = %err, "Database error"); + AppError::ServiceUnavailable("Database is unreachable".into()) + } +} + +impl From for AppError { + fn from(err: reqwest::Error) -> Self { + tracing::error!(error = %err, "HTTP client error"); + if err.is_connect() || err.is_timeout() { + AppError::ServiceUnavailable("Upstream service is unreachable".into()) + } else { + AppError::UpstreamError("Upstream service returned an error".into()) + } + } +} + +impl From for AppError { + fn from(err: redis::RedisError) -> Self { + tracing::warn!(error = %err, "Redis error (non-fatal)"); + // Redis errors are non-fatal — we skip cache and proceed + AppError::InternalError("Cache error".into()) + } +} diff --git a/backend/src/main.rs b/backend/src/main.rs new file mode 100644 index 0000000..9bf0760 --- /dev/null +++ b/backend/src/main.rs @@ -0,0 +1,83 @@ +mod config; +mod errors; +mod models; +mod routes; +mod services; + +use actix_cors::Cors; +use actix_web::{web, App, HttpServer, middleware}; +use sqlx::postgres::PgPoolOptions; +use tracing_subscriber::EnvFilter; + +use crate::config::AppConfig; +use crate::services::cache::CacheService; +use crate::services::martin::MartinService; +use crate::services::osrm::OsrmService; +use crate::services::photon::PhotonService; + +#[actix_web::main] +async fn main() -> std::io::Result<()> { + // Load .env file if present (ignore errors when missing). + let _ = dotenvy::dotenv(); + + // Set up tracing subscriber with env-filter (defaults to INFO). + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), + ) + .init(); + + let config = AppConfig::from_env(); + tracing::info!("Starting maps-backend on {}:{}", config.host, config.port); + + // Database connection pool. + let pool = PgPoolOptions::new() + .max_connections(20) + .connect(&config.database_url) + .await + .expect("Failed to connect to PostgreSQL"); + + tracing::info!("Connected to PostgreSQL"); + + // Redis cache service. + let cache = CacheService::new(&config.redis_url).expect("Failed to connect to Redis"); + tracing::info!("Redis client initialized"); + + // Upstream service clients. + let martin = MartinService::new(&config); + let photon = PhotonService::new(&config); + let osrm = OsrmService::new(&config); + + // Server start time for the health endpoint uptime counter. + let start_time = std::time::Instant::now(); + + let bind_addr = format!("{}:{}", config.host, config.port); + + // Wrap shared state in web::Data (Arc) so cloning inside the closure is cheap. + let pool = web::Data::new(pool); + let cache = web::Data::new(cache); + let martin = web::Data::new(martin); + let photon = web::Data::new(photon); + let osrm = web::Data::new(osrm); + let config = web::Data::new(config); + let start_time = web::Data::new(start_time); + + HttpServer::new(move || { + let cors = Cors::permissive(); + + App::new() + .wrap(cors) + .wrap(middleware::Logger::default()) + .app_data(pool.clone()) + .app_data(cache.clone()) + .app_data(martin.clone()) + .app_data(photon.clone()) + .app_data(osrm.clone()) + .app_data(config.clone()) + .app_data(start_time.clone()) + .configure(routes::configure) + }) + .bind(&bind_addr)? + .run() + .await +} diff --git a/backend/src/models/health.rs b/backend/src/models/health.rs new file mode 100644 index 0000000..99faf59 --- /dev/null +++ b/backend/src/models/health.rs @@ -0,0 +1,46 @@ +use serde::Serialize; +use std::collections::HashMap; + +/// Per-service health status. +#[derive(Debug, Clone, Serialize)] +pub struct ServiceHealth { + pub status: String, + pub latency_ms: u64, +} + +/// Full health check response matching API.md section 6.1. +#[derive(Debug, Serialize)] +pub struct HealthResponse { + pub status: String, + pub version: String, + pub uptime_seconds: u64, + pub services: HashMap, +} + +impl HealthResponse { + /// Derive the top-level status from per-service statuses. + /// - "ok" if all services are ok. + /// - "degraded" if some are down but postgres and martin are ok. + /// - "down" if postgres or martin are down. + pub fn compute_status(services: &HashMap) -> String { + let all_ok = services.values().all(|s| s.status == "ok"); + if all_ok { + return "ok".into(); + } + + let critical_down = ["postgres", "martin"] + .iter() + .any(|name| { + services + .get(*name) + .map(|s| s.status == "down") + .unwrap_or(true) + }); + + if critical_down { + "down".into() + } else { + "degraded".into() + } + } +} diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs new file mode 100644 index 0000000..5e1e259 --- /dev/null +++ b/backend/src/models/mod.rs @@ -0,0 +1,5 @@ +pub mod health; +pub mod offline; +pub mod poi; +pub mod route; +pub mod search; diff --git a/backend/src/models/offline.rs b/backend/src/models/offline.rs new file mode 100644 index 0000000..a989ba4 --- /dev/null +++ b/backend/src/models/offline.rs @@ -0,0 +1,82 @@ +use serde::Serialize; + +/// Row from the `offline_regions` PostGIS table. +#[derive(Debug, sqlx::FromRow)] +pub struct OfflineRegionRow { + pub id: String, + pub name: String, + pub description: Option, + /// bbox as [minLon, minLat, maxLon, maxLat] extracted via ST_Extent / ST_Envelope + pub min_lon: f64, + pub min_lat: f64, + pub max_lon: f64, + pub max_lat: f64, + pub tiles_size_bytes: i64, + pub routing_size_bytes: i64, + pub pois_size_bytes: i64, + pub last_updated: chrono::DateTime, +} + +/// Component breakdown in the JSON response. +#[derive(Debug, Serialize)] +pub struct RegionComponents { + pub tiles_mb: i64, + pub routing_driving_mb: i64, + pub routing_walking_mb: i64, + pub routing_cycling_mb: i64, + pub pois_mb: i64, +} + +/// Single region in the JSON response. +#[derive(Debug, Serialize)] +pub struct OfflineRegion { + pub id: String, + pub name: String, + pub description: Option, + pub bbox: [f64; 4], + pub size_mb: i64, + pub last_updated: String, + pub components: RegionComponents, +} + +/// Top-level response for GET /api/offline/regions. +#[derive(Debug, Serialize)] +pub struct OfflineRegionsResponse { + pub regions: Vec, +} + +/// Valid component names for download. +pub const VALID_COMPONENTS: &[&str] = &[ + "tiles", + "routing-driving", + "routing-walking", + "routing-cycling", + "pois", +]; + +impl OfflineRegionRow { + pub fn into_response(self) -> OfflineRegion { + let total_bytes = self.tiles_size_bytes + self.routing_size_bytes + self.pois_size_bytes; + let total_mb = total_bytes / (1024 * 1024); + let tiles_mb = self.tiles_size_bytes / (1024 * 1024); + let pois_mb = self.pois_size_bytes / (1024 * 1024); + // Split routing evenly across three profiles as an approximation. + let routing_per_profile_mb = self.routing_size_bytes / (3 * 1024 * 1024); + + OfflineRegion { + id: self.id, + name: self.name, + description: self.description, + bbox: [self.min_lon, self.min_lat, self.max_lon, self.max_lat], + size_mb: total_mb, + last_updated: self.last_updated.to_rfc3339(), + components: RegionComponents { + tiles_mb, + routing_driving_mb: routing_per_profile_mb, + routing_walking_mb: routing_per_profile_mb, + routing_cycling_mb: routing_per_profile_mb, + pois_mb, + }, + } + } +} diff --git a/backend/src/models/poi.rs b/backend/src/models/poi.rs new file mode 100644 index 0000000..2db3655 --- /dev/null +++ b/backend/src/models/poi.rs @@ -0,0 +1,140 @@ +use serde::{Deserialize, Serialize}; + +/// Row returned from PostGIS `pois` table. +#[derive(Debug, sqlx::FromRow)] +pub struct PoiRow { + pub osm_id: i64, + pub osm_type: String, + pub name: String, + pub category: String, + pub geometry: serde_json::Value, // ST_AsGeoJSON result + pub address: Option, + pub tags: Option, + pub opening_hours: Option, + pub phone: Option, + pub website: Option, + pub wheelchair: Option, +} + +/// Count row for total matching POIs. +#[derive(Debug, sqlx::FromRow)] +pub struct CountRow { + pub count: Option, +} + +/// Address sub-object in the API response. +#[derive(Debug, Serialize, Deserialize)] +pub struct PoiAddress { + pub street: Option, + pub housenumber: Option, + pub postcode: Option, + pub city: Option, +} + +/// Parsed opening hours for the API response. +#[derive(Debug, Serialize)] +pub struct OpeningHoursParsed { + pub is_open: bool, + pub today: Option, + pub next_change: Option, +} + +/// Properties block of a POI GeoJSON Feature. +#[derive(Debug, Serialize)] +pub struct PoiProperties { + pub osm_id: i64, + pub osm_type: String, + pub name: String, + pub category: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub address: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub opening_hours: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub opening_hours_parsed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub phone: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub website: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub wheelchair: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option, +} + +/// A single GeoJSON Feature for a POI. +#[derive(Debug, Serialize)] +pub struct PoiFeature { + pub r#type: String, + pub geometry: serde_json::Value, + pub properties: PoiProperties, +} + +/// GeoJSON FeatureCollection response with pagination metadata. +#[derive(Debug, Serialize)] +pub struct PoiFeatureCollection { + pub r#type: String, + pub features: Vec, + pub metadata: PaginationMetadata, +} + +#[derive(Debug, Serialize)] +pub struct PaginationMetadata { + pub total: i64, + pub limit: i64, + pub offset: i64, +} + +/// Converts a database row into a GeoJSON Feature. +impl PoiRow { + pub fn into_feature(self) -> PoiFeature { + let address: Option = self + .address + .as_ref() + .and_then(|v| serde_json::from_value(v.clone()).ok()); + + // Basic opening hours parsing — in production this would use a + // proper OSM opening_hours parser. For the MVP we return the raw + // string and a simple placeholder parsed object. + let opening_hours_parsed = self.opening_hours.as_ref().map(|_oh| OpeningHoursParsed { + is_open: true, // Placeholder — real parser needed + today: None, + next_change: None, + }); + + PoiFeature { + r#type: "Feature".into(), + geometry: self.geometry, + properties: PoiProperties { + osm_id: self.osm_id, + osm_type: self.osm_type, + name: self.name, + category: self.category, + address, + opening_hours: self.opening_hours, + opening_hours_parsed, + phone: self.phone, + website: self.website, + wheelchair: self.wheelchair, + tags: self.tags, + }, + } + } +} + +/// Valid POI categories as defined in the data model. +pub const VALID_CATEGORIES: &[&str] = &[ + "restaurant", + "cafe", + "shop", + "supermarket", + "pharmacy", + "hospital", + "fuel", + "parking", + "atm", + "public_transport", + "hotel", + "tourist_attraction", + "park", +]; diff --git a/backend/src/models/route.rs b/backend/src/models/route.rs new file mode 100644 index 0000000..14b9d3f --- /dev/null +++ b/backend/src/models/route.rs @@ -0,0 +1,33 @@ +/// Route models are pass-through from OSRM. The gateway proxies the JSON +/// response as-is (after validation), so we only need minimal types here +/// for any transformation logic. +/// +/// The full OSRM response schema is defined in API.md section 3.1. + +/// Valid routing profiles. +pub const VALID_PROFILES: &[&str] = &["driving", "walking", "cycling"]; + +/// Valid geometry formats. +pub const VALID_GEOMETRIES: &[&str] = &["polyline", "polyline6", "geojson"]; + +/// Valid overview options. +pub const VALID_OVERVIEWS: &[&str] = &["full", "simplified", "false"]; + +/// OSRM response code to our error mapping. +pub fn map_osrm_code(code: &str) -> OsrmCodeMapping { + match code { + "Ok" => OsrmCodeMapping::Ok, + "NoRoute" => OsrmCodeMapping::NoRoute, + "NoSegment" => OsrmCodeMapping::NoSegment, + "TooBig" => OsrmCodeMapping::TooBig, + _ => OsrmCodeMapping::Other(code.to_string()), + } +} + +pub enum OsrmCodeMapping { + Ok, + NoRoute, + NoSegment, + TooBig, + Other(String), +} diff --git a/backend/src/models/search.rs b/backend/src/models/search.rs new file mode 100644 index 0000000..54e5de9 --- /dev/null +++ b/backend/src/models/search.rs @@ -0,0 +1,16 @@ +/// Search models. The gateway proxies Photon's GeoJSON response as-is, +/// so we do not need to deserialize the full response. The types here +/// are used only for cache key construction and parameter validation. + +/// Maximum allowed query length. +pub const MAX_QUERY_LENGTH: usize = 500; + +/// Allowed limit range for forward search. +pub const SEARCH_LIMIT_MIN: u32 = 1; +pub const SEARCH_LIMIT_MAX: u32 = 20; +pub const SEARCH_LIMIT_DEFAULT: u32 = 10; + +/// Allowed limit range for reverse geocoding. +pub const REVERSE_LIMIT_MIN: u32 = 1; +pub const REVERSE_LIMIT_MAX: u32 = 5; +pub const REVERSE_LIMIT_DEFAULT: u32 = 1; diff --git a/backend/src/routes/health.rs b/backend/src/routes/health.rs new file mode 100644 index 0000000..6a0e5ca --- /dev/null +++ b/backend/src/routes/health.rs @@ -0,0 +1,111 @@ +use actix_web::{web, HttpResponse}; +use sqlx::PgPool; +use std::collections::HashMap; + +use crate::errors::AppError; +use crate::models::health::{HealthResponse, ServiceHealth}; +use crate::services::cache::CacheService; +use crate::services::martin::MartinService; +use crate::services::osrm::OsrmService; +use crate::services::photon::PhotonService; + +/// GET /api/health +/// +/// Checks connectivity to all upstream services and returns aggregated status. +/// Always returns HTTP 200; the client should inspect the `status` field. +pub async fn health_check( + martin: web::Data, + photon: web::Data, + osrm: web::Data, + pool: web::Data, + cache: web::Data, + start_time: web::Data, +) -> Result { + let mut services = HashMap::new(); + + // Check Martin + let martin_health = check_service("martin", martin.health_check()).await; + services.insert("martin".to_string(), martin_health); + + // Check Photon + let photon_health = check_service("photon", photon.health_check()).await; + services.insert("photon".to_string(), photon_health); + + // Check OSRM instances + let osrm_driving_health = check_service("osrm_driving", osrm.health_check("driving")).await; + services.insert("osrm_driving".to_string(), osrm_driving_health); + + let osrm_walking_health = check_service("osrm_walking", osrm.health_check("walking")).await; + services.insert("osrm_walking".to_string(), osrm_walking_health); + + let osrm_cycling_health = check_service("osrm_cycling", osrm.health_check("cycling")).await; + services.insert("osrm_cycling".to_string(), osrm_cycling_health); + + // Check PostgreSQL + let postgres_health = check_postgres(&pool).await; + services.insert("postgres".to_string(), postgres_health); + + // Check Redis + let redis_health = check_service("redis", cache.ping()).await; + services.insert("redis".to_string(), redis_health); + + let status = HealthResponse::compute_status(&services); + let uptime = start_time.elapsed().as_secs(); + + let response = HealthResponse { + status, + version: "1.0.0".to_string(), + uptime_seconds: uptime, + services, + }; + + Ok(HttpResponse::Ok() + .content_type("application/json; charset=utf-8") + .insert_header(("Cache-Control", "no-store")) + .json(response)) +} + +/// Check a service that returns Result. +async fn check_service( + _name: &str, + future: impl std::future::Future>, +) -> ServiceHealth { + match future.await { + Ok(latency_ms) => ServiceHealth { + status: if latency_ms > 2000 { + "degraded".to_string() + } else { + "ok".to_string() + }, + latency_ms, + }, + Err(()) => ServiceHealth { + status: "down".to_string(), + latency_ms: 0, + }, + } +} + +/// Check PostgreSQL connectivity. +async fn check_postgres(pool: &PgPool) -> ServiceHealth { + let start = std::time::Instant::now(); + let result = sqlx::query_scalar::<_, i32>("SELECT 1") + .fetch_one(pool) + .await; + let latency_ms = start.elapsed().as_millis() as u64; + + match result { + Ok(_) => ServiceHealth { + status: if latency_ms > 2000 { + "degraded".to_string() + } else { + "ok".to_string() + }, + latency_ms, + }, + Err(_) => ServiceHealth { + status: "down".to_string(), + latency_ms: 0, + }, + } +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs new file mode 100644 index 0000000..8fe9b69 --- /dev/null +++ b/backend/src/routes/mod.rs @@ -0,0 +1,46 @@ +pub mod health; +pub mod offline; +pub mod pois; +pub mod routing; +pub mod search; +pub mod tiles; + +use actix_web::web; + +/// Mount all routes onto the Actix-web application. +pub fn configure(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("") + // Tiles + .route( + "/tiles/{layer}/{z}/{x}/{y}.pbf", + web::get().to(tiles::get_tile), + ) + .route("/tiles/style.json", web::get().to(tiles::get_style)) + // Search + .route("/api/search", web::get().to(search::search)) + .route("/api/reverse", web::get().to(search::reverse)) + // Routing + .route( + "/api/route/{profile}/{coordinates}", + web::get().to(routing::route), + ) + // POIs + .route("/api/pois", web::get().to(pois::list_pois)) + .route( + "/api/pois/{osm_type}/{osm_id}", + web::get().to(pois::get_poi), + ) + // Offline + .route( + "/api/offline/regions", + web::get().to(offline::list_regions), + ) + .route( + "/api/offline/regions/{region_id}/{component}", + web::get().to(offline::download_component), + ) + // Health + .route("/api/health", web::get().to(health::health_check)), + ); +} diff --git a/backend/src/routes/offline.rs b/backend/src/routes/offline.rs new file mode 100644 index 0000000..735df19 --- /dev/null +++ b/backend/src/routes/offline.rs @@ -0,0 +1,194 @@ +use actix_web::{web, HttpResponse}; +use sqlx::PgPool; + +use crate::config::AppConfig; +use crate::errors::AppError; +use crate::models::offline::*; + +/// GET /api/offline/regions +/// +/// List all available offline regions from the PostGIS `offline_regions` table. +pub async fn list_regions( + pool: web::Data, +) -> Result { + let rows = sqlx::query_as::<_, OfflineRegionRow>( + "SELECT id, name, description, \ + ST_XMin(bbox) as min_lon, ST_YMin(bbox) as min_lat, \ + ST_XMax(bbox) as max_lon, ST_YMax(bbox) as max_lat, \ + tiles_size_bytes, routing_size_bytes, pois_size_bytes, \ + last_updated \ + FROM offline_regions \ + ORDER BY name", + ) + .fetch_all(pool.get_ref()) + .await?; + + let regions: Vec = rows.into_iter().map(|r| r.into_response()).collect(); + + let response = OfflineRegionsResponse { regions }; + + Ok(HttpResponse::Ok() + .content_type("application/json; charset=utf-8") + .insert_header(("Cache-Control", "public, max-age=3600")) + .json(response)) +} + +/// Path parameters for downloading a region component. +#[derive(serde::Deserialize)] +pub struct DownloadPath { + region_id: String, + component: String, +} + +/// GET /api/offline/regions/{region_id}/{component} +/// +/// Serve an offline data package file from disk. Supports Range requests +/// for pause/resume downloads. +pub async fn download_component( + path: web::Path, + config: web::Data, + pool: web::Data, + req: actix_web::HttpRequest, +) -> Result { + let DownloadPath { + region_id, + component, + } = path.into_inner(); + + // Validate component + if !VALID_COMPONENTS.contains(&component.as_str()) { + return Err(AppError::InvalidParameter(format!( + "Invalid component: {component}. Must be one of: {}", + VALID_COMPONENTS.join(", ") + ))); + } + + // Verify region exists + let exists = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM offline_regions WHERE id = $1)", + ) + .bind(®ion_id) + .fetch_one(pool.get_ref()) + .await?; + + if !exists { + return Err(AppError::NotFound(format!( + "Region not found: {region_id}" + ))); + } + + // Determine file path and name + let (filename, extension) = match component.as_str() { + "tiles" => (format!("{region_id}-tiles"), "mbtiles"), + "routing-driving" => (format!("{region_id}-routing-driving"), "tar"), + "routing-walking" => (format!("{region_id}-routing-walking"), "tar"), + "routing-cycling" => (format!("{region_id}-routing-cycling"), "tar"), + "pois" => (format!("{region_id}-pois"), "db"), + _ => unreachable!(), // Already validated above + }; + + let file_path = format!( + "{}/{}/{}.{}", + config.offline_data_dir, region_id, filename, extension + ); + + // Read file metadata + let metadata = tokio::fs::metadata(&file_path).await.map_err(|e| { + tracing::error!(error = %e, path = %file_path, "Failed to read offline data file"); + AppError::NotFound(format!( + "Component {component} not available for region {region_id}" + )) + })?; + + let file_size = metadata.len(); + let download_name = format!("{filename}.{extension}"); + + // Check for Range header + if let Some(range_header) = req.headers().get("Range") { + let range_str = range_header.to_str().unwrap_or(""); + if let Some(range) = parse_range(range_str, file_size) { + let (start, end) = range; + let content_length = end - start + 1; + + let data = read_file_range(&file_path, start, content_length).await?; + + return Ok(HttpResponse::PartialContent() + .content_type("application/octet-stream") + .insert_header(("Content-Range", format!("bytes {start}-{end}/{file_size}"))) + .insert_header(("Content-Length", content_length.to_string())) + .insert_header(("Accept-Ranges", "bytes")) + .body(data)); + } else { + return Err(AppError::InvalidParameter( + "Range not satisfiable".into(), + )); + } + } + + // Full download + let data = tokio::fs::read(&file_path).await.map_err(|e| { + tracing::error!(error = %e, path = %file_path, "Failed to read offline data file"); + AppError::InternalError("Failed to read package file".into()) + })?; + + Ok(HttpResponse::Ok() + .content_type("application/octet-stream") + .insert_header(("Content-Length", file_size.to_string())) + .insert_header(("Accept-Ranges", "bytes")) + .insert_header(( + "Content-Disposition", + format!("attachment; filename=\"{download_name}\""), + )) + .insert_header(("Cache-Control", "public, max-age=86400")) + .insert_header(( + "ETag", + format!("\"region-{region_id}-{component}\""), + )) + .body(data)) +} + +/// Parse a byte range header like "bytes=1000-2000" or "bytes=1000-". +/// Returns (start, end) inclusive. +fn parse_range(header: &str, file_size: u64) -> Option<(u64, u64)> { + let header = header.strip_prefix("bytes=")?; + let parts: Vec<&str> = header.splitn(2, '-').collect(); + if parts.len() != 2 { + return None; + } + + let start: u64 = parts[0].parse().ok()?; + let end: u64 = if parts[1].is_empty() { + file_size - 1 + } else { + parts[1].parse().ok()? + }; + + if start > end || end >= file_size { + return None; + } + + Some((start, end)) +} + +/// Read a byte range from a file. +async fn read_file_range(path: &str, offset: u64, length: u64) -> Result, AppError> { + use tokio::io::{AsyncReadExt, AsyncSeekExt}; + + let mut file = tokio::fs::File::open(path).await.map_err(|e| { + tracing::error!(error = %e, "Failed to open file for range read"); + AppError::InternalError("Failed to read package file".into()) + })?; + + file.seek(std::io::SeekFrom::Start(offset)).await.map_err(|e| { + tracing::error!(error = %e, "Failed to seek file"); + AppError::InternalError("Failed to read package file".into()) + })?; + + let mut buf = vec![0u8; length as usize]; + file.read_exact(&mut buf).await.map_err(|e| { + tracing::error!(error = %e, "Failed to read file range"); + AppError::InternalError("Failed to read package file".into()) + })?; + + Ok(buf) +} diff --git a/backend/src/routes/pois.rs b/backend/src/routes/pois.rs new file mode 100644 index 0000000..6647b76 --- /dev/null +++ b/backend/src/routes/pois.rs @@ -0,0 +1,249 @@ +use actix_web::{web, HttpResponse}; +use sqlx::PgPool; + +use crate::errors::AppError; +use crate::models::poi::*; + +/// Query parameters for listing POIs. +#[derive(serde::Deserialize)] +pub struct PoisQuery { + bbox: Option, + category: Option, + limit: Option, + offset: Option, +} + +/// Path parameters for a single POI. +#[derive(serde::Deserialize)] +pub struct PoiPath { + osm_type: String, + osm_id: i64, +} + +/// GET /api/pois +/// +/// Query PostGIS for POIs within a bounding box, optionally filtered by category. +pub async fn list_pois( + query: web::Query, + pool: web::Data, +) -> Result { + let bbox_str = query + .bbox + .as_ref() + .ok_or_else(|| AppError::MissingParameter("bbox parameter is required".into()))?; + + let (min_lon, min_lat, max_lon, max_lat) = parse_bbox(bbox_str)?; + + // Validate area: max 0.25 square degrees + let area = (max_lon - min_lon) * (max_lat - min_lat); + if area > 0.25 { + return Err(AppError::InvalidBbox( + "Bounding box area exceeds maximum of 0.25 square degrees".into(), + )); + } + + let limit = query.limit.unwrap_or(100); + if !(1..=500).contains(&limit) { + return Err(AppError::InvalidParameter( + "limit must be between 1 and 500".into(), + )); + } + + let offset = query.offset.unwrap_or(0); + if offset < 0 { + return Err(AppError::InvalidParameter( + "offset must be >= 0".into(), + )); + } + + // Validate and parse categories + let categories: Option> = if let Some(ref cat_str) = query.category { + let cats: Vec = cat_str.split(',').map(|s| s.trim().to_string()).collect(); + for cat in &cats { + if !VALID_CATEGORIES.contains(&cat.as_str()) { + return Err(AppError::InvalidParameter(format!( + "Unknown category: {cat}. Valid categories: {}", + VALID_CATEGORIES.join(", ") + ))); + } + } + Some(cats) + } else { + None + }; + + // Build and execute the count query + let total = if let Some(ref cats) = categories { + sqlx::query_as::<_, CountRow>( + "SELECT COUNT(*) as count FROM pois \ + WHERE geometry && ST_MakeEnvelope($1, $2, $3, $4, 4326) \ + AND category = ANY($5)", + ) + .bind(min_lon) + .bind(min_lat) + .bind(max_lon) + .bind(max_lat) + .bind(cats) + .fetch_one(pool.get_ref()) + .await? + .count + .unwrap_or(0) + } else { + sqlx::query_as::<_, CountRow>( + "SELECT COUNT(*) as count FROM pois \ + WHERE geometry && ST_MakeEnvelope($1, $2, $3, $4, 4326)", + ) + .bind(min_lon) + .bind(min_lat) + .bind(max_lon) + .bind(max_lat) + .fetch_one(pool.get_ref()) + .await? + .count + .unwrap_or(0) + }; + + // Build and execute the data query + let rows: Vec = if let Some(ref cats) = categories { + sqlx::query_as::<_, PoiRow>( + "SELECT osm_id, osm_type, name, category, \ + ST_AsGeoJSON(geometry)::json AS geometry, \ + address, tags, opening_hours, phone, website, wheelchair \ + FROM pois \ + WHERE geometry && ST_MakeEnvelope($1, $2, $3, $4, 4326) \ + AND category = ANY($5) \ + ORDER BY name \ + LIMIT $6 OFFSET $7", + ) + .bind(min_lon) + .bind(min_lat) + .bind(max_lon) + .bind(max_lat) + .bind(cats) + .bind(limit) + .bind(offset) + .fetch_all(pool.get_ref()) + .await? + } else { + sqlx::query_as::<_, PoiRow>( + "SELECT osm_id, osm_type, name, category, \ + ST_AsGeoJSON(geometry)::json AS geometry, \ + address, tags, opening_hours, phone, website, wheelchair \ + FROM pois \ + WHERE geometry && ST_MakeEnvelope($1, $2, $3, $4, 4326) \ + ORDER BY name \ + LIMIT $5 OFFSET $6", + ) + .bind(min_lon) + .bind(min_lat) + .bind(max_lon) + .bind(max_lat) + .bind(limit) + .bind(offset) + .fetch_all(pool.get_ref()) + .await? + }; + + let features: Vec = rows.into_iter().map(|r| r.into_feature()).collect(); + + let response = PoiFeatureCollection { + r#type: "FeatureCollection".into(), + features, + metadata: PaginationMetadata { + total, + limit, + offset, + }, + }; + + Ok(HttpResponse::Ok() + .content_type("application/json; charset=utf-8") + .insert_header(("Cache-Control", "public, max-age=300")) + .json(response)) +} + +/// GET /api/pois/{osm_type}/{osm_id} +/// +/// Fetch a single POI from PostGIS by OSM type and ID. +pub async fn get_poi( + path: web::Path, + pool: web::Data, +) -> Result { + let PoiPath { osm_type, osm_id } = path.into_inner(); + + // Validate osm_type + if !["N", "W", "R"].contains(&osm_type.as_str()) { + return Err(AppError::InvalidParameter( + "osm_type must be one of: N, W, R".into(), + )); + } + + if osm_id <= 0 { + return Err(AppError::InvalidParameter( + "osm_id must be a positive integer".into(), + )); + } + + let row = sqlx::query_as::<_, PoiRow>( + "SELECT osm_id, osm_type, name, category, \ + ST_AsGeoJSON(geometry)::json AS geometry, \ + address, tags, opening_hours, phone, website, wheelchair \ + FROM pois \ + WHERE osm_type = $1 AND osm_id = $2", + ) + .bind(&osm_type) + .bind(osm_id) + .fetch_optional(pool.get_ref()) + .await?; + + match row { + Some(r) => { + let feature = r.into_feature(); + Ok(HttpResponse::Ok() + .content_type("application/json; charset=utf-8") + .insert_header(("Cache-Control", "public, max-age=3600")) + .json(feature)) + } + None => Err(AppError::NotFound(format!( + "No POI found with type {osm_type} and id {osm_id}" + ))), + } +} + +/// Parse a bounding box string "minLon,minLat,maxLon,maxLat". +fn parse_bbox(bbox: &str) -> Result<(f64, f64, f64, f64), AppError> { + let parts: Vec = bbox + .split(',') + .map(|s| { + s.trim() + .parse::() + .map_err(|_| AppError::InvalidBbox("bbox contains non-numeric values".into())) + }) + .collect::, _>>()?; + + if parts.len() != 4 { + return Err(AppError::InvalidBbox( + "bbox must have exactly 4 comma-separated values: minLon,minLat,maxLon,maxLat".into(), + )); + } + + let (min_lon, min_lat, max_lon, max_lat) = (parts[0], parts[1], parts[2], parts[3]); + + if !(-180.0..=180.0).contains(&min_lon) + || !(-180.0..=180.0).contains(&max_lon) + || !(-90.0..=90.0).contains(&min_lat) + || !(-90.0..=90.0).contains(&max_lat) + { + return Err(AppError::InvalidBbox( + "bbox coordinates out of valid range".into(), + )); + } + + if min_lon >= max_lon || min_lat >= max_lat { + return Err(AppError::InvalidBbox( + "bbox min values must be less than max values".into(), + )); + } + + Ok((min_lon, min_lat, max_lon, max_lat)) +} diff --git a/backend/src/routes/routing.rs b/backend/src/routes/routing.rs new file mode 100644 index 0000000..c6edbf4 --- /dev/null +++ b/backend/src/routes/routing.rs @@ -0,0 +1,182 @@ +use actix_web::{web, HttpResponse}; + +use crate::errors::AppError; +use crate::models::route::*; +use crate::services::cache::CacheService; +use crate::services::osrm::OsrmService; + +/// Path parameters for route requests. +#[derive(serde::Deserialize)] +pub struct RoutePath { + profile: String, + coordinates: String, +} + +/// Query parameters for route requests. +#[derive(serde::Deserialize)] +pub struct RouteQuery { + alternatives: Option, + steps: Option, + geometries: Option, + overview: Option, + language: Option, +} + +/// GET /api/route/{profile}/{coordinates} +/// +/// Proxies to the appropriate OSRM instance with Redis caching (TTL 30m). +pub async fn route( + path: web::Path, + query: web::Query, + osrm: web::Data, + cache: web::Data, +) -> Result { + let RoutePath { + profile, + coordinates, + } = path.into_inner(); + + // Validate profile + if !VALID_PROFILES.contains(&profile.as_str()) { + return Err(AppError::InvalidParameter(format!( + "Invalid profile: {profile}. Must be one of: {}", + VALID_PROFILES.join(", ") + ))); + } + + // Validate coordinates + validate_coordinates(&coordinates)?; + + let alternatives = query.alternatives.unwrap_or(0); + if alternatives > 3 { + return Err(AppError::InvalidParameter( + "alternatives must be between 0 and 3".into(), + )); + } + + let steps = query.steps.unwrap_or(true); + + let geometries = query.geometries.as_deref().unwrap_or("geojson"); + if !VALID_GEOMETRIES.contains(&geometries) { + return Err(AppError::InvalidParameter(format!( + "Invalid geometries format: {geometries}. Must be one of: {}", + VALID_GEOMETRIES.join(", ") + ))); + } + + let overview = query.overview.as_deref().unwrap_or("full"); + if !VALID_OVERVIEWS.contains(&overview) { + return Err(AppError::InvalidParameter(format!( + "Invalid overview: {overview}. Must be one of: {}", + VALID_OVERVIEWS.join(", ") + ))); + } + + let language = query.language.as_deref().unwrap_or("en"); + + // Check cache + let cache_key = + CacheService::route_key(&profile, &coordinates, alternatives, steps, geometries, overview); + if let Some(cached) = cache.get_json(&cache_key).await { + tracing::debug!("Route cache hit"); + return Ok(HttpResponse::Ok() + .content_type("application/json; charset=utf-8") + .insert_header(("Cache-Control", "no-store")) + .body(cached)); + } + + // Call OSRM + let json = osrm + .route( + &profile, + &coordinates, + alternatives, + steps, + geometries, + overview, + language, + ) + .await?; + + // Check OSRM response code + if let Some(code) = json.get("code").and_then(|c| c.as_str()) { + match map_osrm_code(code) { + OsrmCodeMapping::Ok => {} // Success, continue + OsrmCodeMapping::NoRoute => { + return Err(AppError::NotFound("No route could be found".into())); + } + OsrmCodeMapping::NoSegment => { + return Err(AppError::InvalidCoordinates( + "Coordinates could not be snapped to the road network".into(), + )); + } + OsrmCodeMapping::TooBig => { + return Err(AppError::InvalidParameter( + "Route request is too large".into(), + )); + } + OsrmCodeMapping::Other(c) => { + return Err(AppError::UpstreamError(format!( + "OSRM returned unexpected code: {c}" + ))); + } + } + } + + let body = serde_json::to_string(&json) + .map_err(|e| AppError::InternalError(format!("JSON serialization failed: {e}")))?; + + // Cache for 30 minutes + cache.set_json(&cache_key, &body, 1800).await; + + Ok(HttpResponse::Ok() + .content_type("application/json; charset=utf-8") + .insert_header(("Cache-Control", "no-store")) + .body(body)) +} + +/// Validate coordinate string: "lon,lat;lon,lat[;...]" +/// Minimum 2 pairs, maximum 7 pairs. +fn validate_coordinates(coords: &str) -> Result<(), AppError> { + let pairs: Vec<&str> = coords.split(';').collect(); + + if pairs.len() < 2 { + return Err(AppError::InvalidCoordinates( + "At least 2 coordinate pairs are required".into(), + )); + } + if pairs.len() > 7 { + return Err(AppError::InvalidCoordinates( + "At most 7 coordinate pairs are allowed (origin + 5 waypoints + destination)".into(), + )); + } + + for pair in &pairs { + let parts: Vec<&str> = pair.split(',').collect(); + if parts.len() != 2 { + return Err(AppError::InvalidCoordinates(format!( + "Malformed coordinate pair: {pair}" + ))); + } + + let lon: f64 = parts[0].parse().map_err(|_| { + AppError::InvalidCoordinates(format!("Invalid longitude in pair: {pair}")) + })?; + let lat: f64 = parts[1].parse().map_err(|_| { + AppError::InvalidCoordinates(format!("Invalid latitude in pair: {pair}")) + })?; + + if !(-180.0..=180.0).contains(&lon) { + return Err(AppError::InvalidCoordinates(format!( + "Longitude {lon} out of range [-180, 180]" + ))); + } + if !(-90.0..=90.0).contains(&lat) { + return Err(AppError::InvalidCoordinates(format!( + "Latitude {lat} out of range [-90, 90]" + ))); + } + } + + Ok(()) +} diff --git a/backend/src/routes/search.rs b/backend/src/routes/search.rs new file mode 100644 index 0000000..c398f85 --- /dev/null +++ b/backend/src/routes/search.rs @@ -0,0 +1,216 @@ +use actix_web::{web, HttpResponse}; + +use crate::errors::AppError; +use crate::models::search::*; +use crate::services::cache::CacheService; +use crate::services::photon::PhotonService; + +/// Query parameters for forward search. +#[derive(serde::Deserialize)] +pub struct SearchQuery { + q: Option, + lat: Option, + lon: Option, + limit: Option, + lang: Option, + bbox: Option, +} + +/// GET /api/search +/// +/// Proxies to Photon with Redis caching (TTL 1h). +pub async fn search( + query: web::Query, + photon: web::Data, + cache: web::Data, +) -> Result { + let q = query + .q + .as_ref() + .ok_or_else(|| AppError::MissingParameter("q parameter is required".into()))?; + + if q.is_empty() { + return Err(AppError::MissingParameter( + "q parameter must not be empty".into(), + )); + } + + // Strip control characters + let q_clean: String = q.chars().filter(|c| !c.is_control()).collect(); + if q_clean.len() > MAX_QUERY_LENGTH { + return Err(AppError::InvalidParameter(format!( + "Query exceeds maximum length of {MAX_QUERY_LENGTH} characters" + ))); + } + + // lat and lon must both be provided or both omitted + match (query.lat, query.lon) { + (Some(_), None) | (None, Some(_)) => { + return Err(AppError::InvalidParameter( + "lat and lon must both be provided or both omitted".into(), + )); + } + (Some(lat), Some(_)) if !(-90.0..=90.0).contains(&lat) => { + return Err(AppError::InvalidParameter( + "lat must be between -90.0 and 90.0".into(), + )); + } + (Some(_), Some(lon)) if !(-180.0..=180.0).contains(&lon) => { + return Err(AppError::InvalidParameter( + "lon must be between -180.0 and 180.0".into(), + )); + } + _ => {} + } + + let limit = query.limit.unwrap_or(SEARCH_LIMIT_DEFAULT); + if !(SEARCH_LIMIT_MIN..=SEARCH_LIMIT_MAX).contains(&limit) { + return Err(AppError::InvalidParameter(format!( + "limit must be between {SEARCH_LIMIT_MIN} and {SEARCH_LIMIT_MAX}" + ))); + } + + let lang = query.lang.as_deref().unwrap_or("en"); + + // Validate bbox if provided + if let Some(ref bbox) = query.bbox { + validate_bbox(bbox)?; + } + + // Check cache + let cache_key = CacheService::search_key(&q_clean, query.lat, query.lon, limit, lang); + if let Some(cached) = cache.get_json(&cache_key).await { + tracing::debug!("Search cache hit"); + return Ok(HttpResponse::Ok() + .content_type("application/json; charset=utf-8") + .insert_header(("Cache-Control", "public, max-age=300")) + .body(cached)); + } + + // Call Photon + let json = photon + .search(&q_clean, query.lat, query.lon, limit, lang, query.bbox.as_deref()) + .await?; + + let body = serde_json::to_string(&json) + .map_err(|e| AppError::InternalError(format!("JSON serialization failed: {e}")))?; + + // Cache the result + cache.set_json(&cache_key, &body, 3600).await; + + Ok(HttpResponse::Ok() + .content_type("application/json; charset=utf-8") + .insert_header(("Cache-Control", "public, max-age=300")) + .body(body)) +} + +/// Query parameters for reverse geocoding. +#[derive(serde::Deserialize)] +pub struct ReverseQuery { + lat: Option, + lon: Option, + limit: Option, + lang: Option, +} + +/// GET /api/reverse +/// +/// Proxies to Photon reverse geocoding with caching (TTL 1h). +pub async fn reverse( + query: web::Query, + photon: web::Data, + cache: web::Data, +) -> Result { + let lat = query + .lat + .ok_or_else(|| AppError::MissingParameter("lat parameter is required".into()))?; + let lon = query + .lon + .ok_or_else(|| AppError::MissingParameter("lon parameter is required".into()))?; + + if !(-90.0..=90.0).contains(&lat) { + return Err(AppError::InvalidParameter( + "lat must be between -90.0 and 90.0".into(), + )); + } + if !(-180.0..=180.0).contains(&lon) { + return Err(AppError::InvalidParameter( + "lon must be between -180.0 and 180.0".into(), + )); + } + + let limit = query.limit.unwrap_or(REVERSE_LIMIT_DEFAULT); + if !(REVERSE_LIMIT_MIN..=REVERSE_LIMIT_MAX).contains(&limit) { + return Err(AppError::InvalidParameter(format!( + "limit must be between {REVERSE_LIMIT_MIN} and {REVERSE_LIMIT_MAX}" + ))); + } + + let lang = query.lang.as_deref().unwrap_or("en"); + + // Build cache key using the same search key scheme + let cache_key = CacheService::search_key( + &format!("reverse:{lat:.6},{lon:.6}"), + Some(lat), + Some(lon), + limit, + lang, + ); + + if let Some(cached) = cache.get_json(&cache_key).await { + tracing::debug!("Reverse geocode cache hit"); + return Ok(HttpResponse::Ok() + .content_type("application/json; charset=utf-8") + .insert_header(("Cache-Control", "public, max-age=3600")) + .body(cached)); + } + + let json = photon.reverse(lat, lon, limit, lang).await?; + let body = serde_json::to_string(&json) + .map_err(|e| AppError::InternalError(format!("JSON serialization failed: {e}")))?; + + cache.set_json(&cache_key, &body, 3600).await; + + Ok(HttpResponse::Ok() + .content_type("application/json; charset=utf-8") + .insert_header(("Cache-Control", "public, max-age=3600")) + .body(body)) +} + +/// Validate a bounding box string: "minLon,minLat,maxLon,maxLat". +fn validate_bbox(bbox: &str) -> Result<(), AppError> { + let parts: Vec = bbox + .split(',') + .map(|s| { + s.trim() + .parse::() + .map_err(|_| AppError::InvalidParameter("bbox contains non-numeric values".into())) + }) + .collect::, _>>()?; + + if parts.len() != 4 { + return Err(AppError::InvalidParameter( + "bbox must have exactly 4 comma-separated values".into(), + )); + } + + let (min_lon, min_lat, max_lon, max_lat) = (parts[0], parts[1], parts[2], parts[3]); + + if !(-180.0..=180.0).contains(&min_lon) + || !(-180.0..=180.0).contains(&max_lon) + || !(-90.0..=90.0).contains(&min_lat) + || !(-90.0..=90.0).contains(&max_lat) + { + return Err(AppError::InvalidParameter( + "bbox coordinates out of range".into(), + )); + } + + if min_lon >= max_lon || min_lat >= max_lat { + return Err(AppError::InvalidParameter( + "bbox min values must be less than max values".into(), + )); + } + + Ok(()) +} diff --git a/backend/src/routes/tiles.rs b/backend/src/routes/tiles.rs new file mode 100644 index 0000000..eeeb0eb --- /dev/null +++ b/backend/src/routes/tiles.rs @@ -0,0 +1,93 @@ +use actix_web::{web, HttpResponse}; +use bytes::Bytes; + +use crate::errors::AppError; +use crate::services::cache::CacheService; +use crate::services::martin::MartinService; + +/// Path parameters for tile requests. +#[derive(serde::Deserialize)] +pub struct TilePath { + layer: String, + z: u32, + x: u32, + y: u32, +} + +const VALID_LAYERS: &[&str] = &["openmaptiles", "terrain", "hillshade"]; + +/// GET /tiles/{layer}/{z}/{x}/{y}.pbf +/// +/// Proxies to Martin with Redis tile caching (TTL 24h). +pub async fn get_tile( + path: web::Path, + martin: web::Data, + cache: web::Data, +) -> Result { + let TilePath { layer, z, x, y } = path.into_inner(); + + // Validate layer + if !VALID_LAYERS.contains(&layer.as_str()) { + return Err(AppError::InvalidParameter(format!( + "Invalid layer: {layer}. Must be one of: {}", + VALID_LAYERS.join(", ") + ))); + } + + // Validate zoom + if z > 18 { + return Err(AppError::InvalidParameter( + "Zoom level must be between 0 and 18".into(), + )); + } + + // Validate x/y range for zoom level + let max_coord = (1u64 << z) - 1; + if (x as u64) > max_coord || (y as u64) > max_coord { + return Err(AppError::InvalidParameter(format!( + "x and y must be between 0 and {max_coord} for zoom level {z}" + ))); + } + + // Check Redis cache + let cache_key = CacheService::tile_key(&layer, z, x, y); + if let Some(cached) = cache.get_tile(&cache_key).await { + tracing::debug!("Tile cache hit"); + return Ok(HttpResponse::Ok() + .content_type("application/x-protobuf") + .insert_header(("Content-Encoding", "gzip")) + .insert_header(("Cache-Control", "public, max-age=86400")) + .body(cached)); + } + + // Fetch from Martin + let (data, etag): (Bytes, Option) = martin.get_tile(&layer, z, x, y).await?; + + // Store in cache (fire-and-forget) + cache.set_tile(&cache_key, &data).await; + + let mut resp = HttpResponse::Ok(); + resp.content_type("application/x-protobuf") + .insert_header(("Content-Encoding", "gzip")) + .insert_header(("Cache-Control", "public, max-age=86400")); + + if let Some(etag) = etag { + resp.insert_header(("ETag", etag)); + } + + Ok(resp.body(data)) +} + +/// GET /tiles/style.json +/// +/// Proxies the Mapbox GL style JSON from Martin. +pub async fn get_style( + martin: web::Data, +) -> Result { + let style = martin.get_style().await?; + + Ok(HttpResponse::Ok() + .content_type("application/json; charset=utf-8") + .insert_header(("Cache-Control", "public, max-age=3600")) + .json(style)) +} diff --git a/backend/src/services/cache.rs b/backend/src/services/cache.rs new file mode 100644 index 0000000..36e10bd --- /dev/null +++ b/backend/src/services/cache.rs @@ -0,0 +1,113 @@ +use redis::AsyncCommands; +use sha2::{Digest, Sha256}; + +/// Redis caching layer implementing the key patterns and TTLs +/// defined in DATA_MODEL.md section 3. +pub struct CacheService { + client: redis::Client, +} + +impl CacheService { + pub fn new(redis_url: &str) -> Result { + let client = redis::Client::open(redis_url)?; + Ok(Self { client }) + } + + async fn conn(&self) -> Result { + self.client.get_multiplexed_async_connection().await + } + + // ── Tile cache ────────────────────────────────────────────── + + /// Key: `tile:{layer}:{z}:{x}:{y}`, TTL: 24 hours. + pub fn tile_key(layer: &str, z: u32, x: u32, y: u32) -> String { + format!("tile:{layer}:{z}:{x}:{y}") + } + + pub async fn get_tile(&self, key: &str) -> Option> { + let mut conn = self.conn().await.ok()?; + conn.get::<_, Option>>(key).await.ok().flatten() + } + + pub async fn set_tile(&self, key: &str, data: &[u8]) { + if let Ok(mut conn) = self.conn().await { + let _: Result<(), _> = conn.set_ex(key, data, 86400).await; + } + } + + // ── Search cache ──────────────────────────────────────────── + + /// Key: `search:{sha256(params)}`, TTL: 1 hour. + pub fn search_key( + q: &str, + lat: Option, + lon: Option, + limit: u32, + lang: &str, + ) -> String { + let input = format!( + "q={}&lat={}&lon={}&limit={}&lang={}", + q, + lat.map(|v| format!("{v:.6}")).unwrap_or_default(), + lon.map(|v| format!("{v:.6}")).unwrap_or_default(), + limit, + lang, + ); + let hash = hex::encode(Sha256::digest(input.as_bytes())); + format!("search:{hash}") + } + + pub async fn get_json(&self, key: &str) -> Option { + let mut conn = self.conn().await.ok()?; + conn.get::<_, Option>(key).await.ok().flatten() + } + + pub async fn set_json(&self, key: &str, json: &str, ttl_secs: u64) { + if let Ok(mut conn) = self.conn().await { + let _: Result<(), _> = conn.set_ex(key, json, ttl_secs).await; + } + } + + // ── Route cache ───────────────────────────────────────────── + + /// Key: `route:{sha256(params)}`, TTL: 30 minutes. + pub fn route_key( + profile: &str, + coordinates: &str, + alternatives: u32, + steps: bool, + geometries: &str, + overview: &str, + ) -> String { + let input = format!( + "profile={}&coords={}&alt={}&steps={}&geom={}&overview={}", + profile, coordinates, alternatives, steps, geometries, overview, + ); + let hash = hex::encode(Sha256::digest(input.as_bytes())); + format!("route:{hash}") + } + + // ── Health cache ──────────────────────────────────────────── + + pub async fn get_health(&self, service: &str) -> Option { + let key = format!("health:{service}"); + self.get_json(&key).await + } + + pub async fn set_health(&self, service: &str, json: &str) { + let key = format!("health:{service}"); + self.set_json(&key, json, 30).await; + } + + // ── Generic health probe for Redis itself ─────────────────── + + pub async fn ping(&self) -> Result { + let start = std::time::Instant::now(); + let mut conn = self.conn().await.map_err(|_| ())?; + let _: String = redis::cmd("PING") + .query_async(&mut conn) + .await + .map_err(|_| ())?; + Ok(start.elapsed().as_millis() as u64) + } +} diff --git a/backend/src/services/martin.rs b/backend/src/services/martin.rs new file mode 100644 index 0000000..2129066 --- /dev/null +++ b/backend/src/services/martin.rs @@ -0,0 +1,102 @@ +use crate::config::AppConfig; +use crate::errors::AppError; +use bytes::Bytes; + +/// HTTP client for the Martin tile server. +pub struct MartinService { + client: reqwest::Client, + base_url: String, +} + +impl MartinService { + pub fn new(config: &AppConfig) -> Self { + Self { + client: reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .expect("Failed to build HTTP client"), + base_url: config.martin_url.clone(), + } + } + + /// Fetch a vector tile from Martin. + /// Returns the raw bytes (gzip-compressed protobuf). + pub async fn get_tile( + &self, + layer: &str, + z: u32, + x: u32, + y: u32, + ) -> Result<(Bytes, Option), AppError> { + let url = format!("{}/{layer}/{z}/{x}/{y}", self.base_url); + let resp = self.client.get(&url).send().await.map_err(|e| { + tracing::error!(error = %e, "Martin connection error"); + AppError::ServiceUnavailable("Martin tile server is unreachable".into()) + })?; + + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Err(AppError::NotFound( + "No tile data exists for these coordinates".into(), + )); + } + + if !resp.status().is_success() { + return Err(AppError::UpstreamError(format!( + "Martin returned status {}", + resp.status() + ))); + } + + let etag = resp + .headers() + .get("etag") + .and_then(|v| v.to_str().ok()) + .map(String::from); + + let body = resp.bytes().await.map_err(|e| { + tracing::error!(error = %e, "Failed to read Martin response body"); + AppError::UpstreamError("Failed to read tile data".into()) + })?; + + Ok((body, etag)) + } + + /// Fetch the style.json from Martin. + pub async fn get_style(&self) -> Result { + let url = format!("{}/style.json", self.base_url); + let resp = self.client.get(&url).send().await.map_err(|e| { + tracing::error!(error = %e, "Martin connection error"); + AppError::ServiceUnavailable("Martin tile server is unreachable".into()) + })?; + + if !resp.status().is_success() { + return Err(AppError::UpstreamError(format!( + "Martin returned status {}", + resp.status() + ))); + } + + let json: serde_json::Value = resp.json().await.map_err(|e| { + tracing::error!(error = %e, "Failed to parse Martin style.json"); + AppError::UpstreamError("Failed to parse style.json".into()) + })?; + + Ok(json) + } + + /// Health probe — simple connectivity check. + pub async fn health_check(&self) -> Result { + let start = std::time::Instant::now(); + let resp = self + .client + .get(&format!("{}/health", self.base_url)) + .timeout(std::time::Duration::from_secs(5)) + .send() + .await; + let latency = start.elapsed().as_millis() as u64; + match resp { + Ok(r) if r.status().is_success() => Ok(latency), + _ => Err(()), + } + } +} diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs new file mode 100644 index 0000000..d3ad01c --- /dev/null +++ b/backend/src/services/mod.rs @@ -0,0 +1,4 @@ +pub mod cache; +pub mod martin; +pub mod osrm; +pub mod photon; diff --git a/backend/src/services/osrm.rs b/backend/src/services/osrm.rs new file mode 100644 index 0000000..798c684 --- /dev/null +++ b/backend/src/services/osrm.rs @@ -0,0 +1,98 @@ +use crate::config::AppConfig; +use crate::errors::AppError; + +/// HTTP client for OSRM routing engine instances. +pub struct OsrmService { + client: reqwest::Client, + driving_url: String, + walking_url: String, + cycling_url: String, +} + +impl OsrmService { + pub fn new(config: &AppConfig) -> Self { + Self { + client: reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .expect("Failed to build HTTP client"), + driving_url: config.osrm_driving_url.clone(), + walking_url: config.osrm_walking_url.clone(), + cycling_url: config.osrm_cycling_url.clone(), + } + } + + fn base_url_for_profile(&self, profile: &str) -> Option<&str> { + match profile { + "driving" => Some(&self.driving_url), + "walking" => Some(&self.walking_url), + "cycling" => Some(&self.cycling_url), + _ => None, + } + } + + /// Request a route from the appropriate OSRM instance. + /// Returns raw JSON from OSRM. + pub async fn route( + &self, + profile: &str, + coordinates: &str, + alternatives: u32, + steps: bool, + geometries: &str, + overview: &str, + language: &str, + ) -> Result { + let base = self.base_url_for_profile(profile).ok_or_else(|| { + AppError::InvalidParameter(format!("Invalid profile: {profile}")) + })?; + + let url = format!( + "{base}/route/v1/{profile}/{coordinates}?alternatives={alternatives}&steps={steps}&geometries={geometries}&overview={overview}&language={language}" + ); + + let resp = self.client.get(&url).send().await.map_err(|e| { + tracing::error!(error = %e, "OSRM connection error"); + AppError::ServiceUnavailable(format!( + "OSRM instance for {profile} is unreachable" + )) + })?; + + if !resp.status().is_success() { + return Err(AppError::UpstreamError(format!( + "OSRM returned status {}", + resp.status() + ))); + } + + let json: serde_json::Value = resp.json().await.map_err(|e| { + tracing::error!(error = %e, "Failed to parse OSRM response"); + AppError::UpstreamError("Failed to parse routing response".into()) + })?; + + Ok(json) + } + + /// Health probe for a specific OSRM profile. + pub async fn health_check(&self, profile: &str) -> Result { + let base = match self.base_url_for_profile(profile) { + Some(u) => u, + None => return Err(()), + }; + + let start = std::time::Instant::now(); + // OSRM has no dedicated health endpoint; a minimal route request works. + let url = format!("{base}/route/v1/{profile}/0,0;0.001,0.001?overview=false&steps=false"); + let resp = self + .client + .get(&url) + .timeout(std::time::Duration::from_secs(5)) + .send() + .await; + let latency = start.elapsed().as_millis() as u64; + match resp { + Ok(r) if r.status().is_success() || r.status().as_u16() == 400 => Ok(latency), + _ => Err(()), + } + } +} diff --git a/backend/src/services/photon.rs b/backend/src/services/photon.rs new file mode 100644 index 0000000..69038d4 --- /dev/null +++ b/backend/src/services/photon.rs @@ -0,0 +1,118 @@ +use crate::config::AppConfig; +use crate::errors::AppError; + +/// HTTP client for the Photon geocoder. +pub struct PhotonService { + client: reqwest::Client, + base_url: String, +} + +impl PhotonService { + pub fn new(config: &AppConfig) -> Self { + Self { + client: reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .expect("Failed to build HTTP client"), + base_url: config.photon_url.clone(), + } + } + + /// Forward geocoding search. Returns raw JSON from Photon. + pub async fn search( + &self, + q: &str, + lat: Option, + lon: Option, + limit: u32, + lang: &str, + bbox: Option<&str>, + ) -> Result { + let mut url = format!("{}/api?q={}&limit={}&lang={}", self.base_url, urlencod(q), limit, lang); + + if let (Some(lat), Some(lon)) = (lat, lon) { + url.push_str(&format!("&lat={lat}&lon={lon}")); + } + + if let Some(bbox) = bbox { + url.push_str(&format!("&bbox={bbox}")); + } + + let resp = self.client.get(&url).send().await.map_err(|e| { + tracing::error!(error = %e, "Photon connection error"); + AppError::ServiceUnavailable("Photon geocoder is unreachable".into()) + })?; + + if !resp.status().is_success() { + return Err(AppError::UpstreamError(format!( + "Photon returned status {}", + resp.status() + ))); + } + + let json: serde_json::Value = resp.json().await.map_err(|e| { + tracing::error!(error = %e, "Failed to parse Photon response"); + AppError::UpstreamError("Failed to parse search response".into()) + })?; + + Ok(json) + } + + /// Reverse geocoding. Returns raw JSON from Photon. + pub async fn reverse( + &self, + lat: f64, + lon: f64, + limit: u32, + lang: &str, + ) -> Result { + let url = format!( + "{}/reverse?lat={lat}&lon={lon}&limit={limit}&lang={lang}", + self.base_url + ); + + let resp = self.client.get(&url).send().await.map_err(|e| { + tracing::error!(error = %e, "Photon connection error"); + AppError::ServiceUnavailable("Photon geocoder is unreachable".into()) + })?; + + if !resp.status().is_success() { + return Err(AppError::UpstreamError(format!( + "Photon returned status {}", + resp.status() + ))); + } + + let json: serde_json::Value = resp.json().await.map_err(|e| { + tracing::error!(error = %e, "Failed to parse Photon reverse response"); + AppError::UpstreamError("Failed to parse reverse geocoding response".into()) + })?; + + Ok(json) + } + + /// Health probe. + pub async fn health_check(&self) -> Result { + let start = std::time::Instant::now(); + let resp = self + .client + .get(&format!("{}/api?q=healthcheck&limit=1", self.base_url)) + .timeout(std::time::Duration::from_secs(5)) + .send() + .await; + let latency = start.elapsed().as_millis() as u64; + match resp { + Ok(r) if r.status().is_success() => Ok(latency), + _ => Err(()), + } + } +} + +/// Minimal URL encoding for query strings. +fn urlencod(s: &str) -> String { + s.replace(' ', "+") + .replace('&', "%26") + .replace('=', "%3D") + .replace('#', "%23") + .replace('?', "%3F") +} diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..11dd20e --- /dev/null +++ b/docs/API.md @@ -0,0 +1,917 @@ +# REST API Contract: Privacy-First Maps Application + +**Version:** 1.0.0 +**Date:** 2026-03-29 +**Base URL:** Configured by user at first launch (e.g., `https://maps.example.com`) + +--- + +## General Conventions + +### Authentication +None. All endpoints are unauthenticated. No API keys, tokens, cookies, or sessions. + +### Content Types +- JSON responses: `Content-Type: application/json; charset=utf-8` +- Protobuf tile responses: `Content-Type: application/x-protobuf` +- Binary download responses: `Content-Type: application/octet-stream` +- Style JSON: `Content-Type: application/json; charset=utf-8` + +### Standard Error Response + +All error responses use the following JSON format: + +```json +{ + "error": { + "code": "ERROR_CODE", + "message": "Human-readable description of the error." + } +} +``` + +### Error Codes Reference + +| HTTP Status | Error Code | Description | +|---|---|---| +| 400 | `MISSING_PARAMETER` | A required query parameter is missing | +| 400 | `INVALID_PARAMETER` | A parameter value is malformed or out of range | +| 400 | `INVALID_BBOX` | Bounding box coordinates are invalid | +| 400 | `INVALID_COORDINATES` | Route coordinates are invalid or out of range | +| 404 | `NOT_FOUND` | The requested resource does not exist | +| 429 | `RATE_LIMITED` | Request rate limit exceeded | +| 502 | `UPSTREAM_ERROR` | An upstream service (Martin/Photon/OSRM) returned an error | +| 503 | `SERVICE_UNAVAILABLE` | An upstream service is unreachable | +| 500 | `INTERNAL_ERROR` | An unexpected server error occurred | + +### Common Response Headers + +| Header | Value | Applies To | +|---|---|---| +| `Content-Type` | varies (see above) | All responses | +| `Cache-Control` | varies per endpoint | Cacheable responses | +| `X-Request-Id` | UUID v4 | All responses (for correlation, no PII) | +| `Access-Control-Allow-Origin` | `*` | All responses | +| `Strict-Transport-Security` | `max-age=63072000; includeSubDomains` | All responses | + +--- + +## 1. Tile Serving + +### 1.1 Get Vector Tile + +Proxied to Martin tile server. + +**Request:** + +``` +GET /tiles/{layer}/{z}/{x}/{y}.pbf +``` + +**Path Parameters:** + +| Parameter | Type | Required | Constraints | Description | +|---|---|---|---|---| +| `layer` | string | Yes | One of: `openmaptiles`, `terrain`, `hillshade` | Tile layer name | +| `z` | integer | Yes | [0, 18] | Zoom level | +| `x` | integer | Yes | [0, 2^z - 1] | Tile column | +| `y` | integer | Yes | [0, 2^z - 1] | Tile row | + +**Success Response:** + +``` +HTTP/1.1 200 OK +Content-Type: application/x-protobuf +Content-Encoding: gzip +Cache-Control: public, max-age=86400 +ETag: "abc123" +``` + +Body: Gzip-compressed Mapbox Vector Tile (protobuf binary). + +**Error Responses:** + +| Status | Code | Condition | +|---|---|---| +| 400 | `INVALID_PARAMETER` | `z` out of [0,18], or `x`/`y` out of range for zoom level | +| 404 | `NOT_FOUND` | No tile data exists for these coordinates | +| 502 | `UPSTREAM_ERROR` | Martin returned an error | +| 503 | `SERVICE_UNAVAILABLE` | Martin is unreachable | + +**Caching behavior:** Tiles are immutable between OSM data imports. The `ETag` changes when tile data is regenerated. Clients should use `If-None-Match` for conditional requests; the server returns `304 Not Modified` when the tile has not changed. + +--- + +### 1.2 Get Map Style + +**Request:** + +``` +GET /tiles/style.json +``` + +No parameters. + +**Success Response:** + +``` +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +Cache-Control: public, max-age=3600 +``` + +```json +{ + "version": 8, + "name": "Privacy Maps Default", + "sources": { + "openmaptiles": { + "type": "vector", + "tiles": ["/tiles/openmaptiles/{z}/{x}/{y}.pbf"], + "minzoom": 0, + "maxzoom": 14 + }, + "terrain": { + "type": "vector", + "tiles": ["/tiles/terrain/{z}/{x}/{y}.pbf"], + "minzoom": 0, + "maxzoom": 14 + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "#f8f4f0" + } + } + ] +} +``` + +The full style document includes all layer definitions for roads, buildings, water, land use, labels, etc. The above is an abbreviated example. The mobile app uses this to configure MapLibre GL Native. + +**Error Responses:** + +| Status | Code | Condition | +|---|---|---| +| 502 | `UPSTREAM_ERROR` | Martin returned an error | +| 503 | `SERVICE_UNAVAILABLE` | Martin is unreachable | + +--- + +## 2. Search / Geocoding + +### 2.1 Forward Search + +Proxied to Photon. + +**Request:** + +``` +GET /api/search?q={query}&lat={lat}&lon={lon}&limit={limit}&lang={lang}&bbox={bbox} +``` + +**Query Parameters:** + +| Parameter | Type | Required | Default | Constraints | Description | +|---|---|---|---|---|---| +| `q` | string | Yes | — | Max 500 characters, control chars stripped | Search query text | +| `lat` | float | No | — | [-90.0, 90.0] | Latitude for proximity bias | +| `lon` | float | No | — | [-180.0, 180.0] | Longitude for proximity bias | +| `limit` | integer | No | 10 | [1, 20] | Maximum number of results | +| `lang` | string | No | `en` | ISO 639-1 two-letter code | Preferred result language | +| `bbox` | string | No | — | `minLon,minLat,maxLon,maxLat` (valid coordinates, min < max) | Bounding box filter | + +**Note:** `lat` and `lon` must both be provided or both omitted. Providing only one returns `400 INVALID_PARAMETER`. + +**Success Response:** + +``` +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +Cache-Control: public, max-age=300 +``` + +```json +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [4.9041, 52.3676] + }, + "properties": { + "osm_id": 12345678, + "osm_type": "N", + "name": "Vondelpark", + "street": "Vondelpark", + "housenumber": null, + "postcode": "1071 AA", + "city": "Amsterdam", + "state": "North Holland", + "country": "Netherlands", + "country_code": "nl", + "type": "park", + "extent": [4.8580, 52.3585, 4.8820, 52.3620] + } + } + ] +} +``` + +**Feature properties schema:** + +| Field | Type | Nullable | Description | +|---|---|---|---| +| `osm_id` | integer | No | OpenStreetMap element ID | +| `osm_type` | string | No | `"N"` (node), `"W"` (way), or `"R"` (relation) | +| `name` | string | No | Place name | +| `street` | string | Yes | Street name | +| `housenumber` | string | Yes | House number | +| `postcode` | string | Yes | Postal code | +| `city` | string | Yes | City name | +| `state` | string | Yes | State or province | +| `country` | string | Yes | Country name | +| `country_code` | string | Yes | ISO 3166-1 alpha-2 country code | +| `type` | string | No | Place type (e.g., `"park"`, `"house"`, `"street"`, `"city"`) | +| `extent` | array | Yes | Bounding box `[minLon, minLat, maxLon, maxLat]` for area features | + +**Error Responses:** + +| Status | Code | Condition | +|---|---|---| +| 400 | `MISSING_PARAMETER` | `q` parameter is missing | +| 400 | `INVALID_PARAMETER` | `lat`/`lon` out of range, `limit` out of range, `lang` not a valid ISO 639-1 code, `bbox` malformed, only one of `lat`/`lon` provided | +| 502 | `UPSTREAM_ERROR` | Photon returned an error | +| 503 | `SERVICE_UNAVAILABLE` | Photon is unreachable | + +--- + +### 2.2 Reverse Geocoding + +Proxied to Photon. + +**Request:** + +``` +GET /api/reverse?lat={lat}&lon={lon}&limit={limit}&lang={lang} +``` + +**Query Parameters:** + +| Parameter | Type | Required | Default | Constraints | Description | +|---|---|---|---|---|---| +| `lat` | float | Yes | — | [-90.0, 90.0] | Latitude | +| `lon` | float | Yes | — | [-180.0, 180.0] | Longitude | +| `limit` | integer | No | 1 | [1, 5] | Maximum number of results | +| `lang` | string | No | `en` | ISO 639-1 two-letter code | Preferred result language | + +**Success Response:** + +``` +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +Cache-Control: public, max-age=3600 +``` + +Response body: same GeoJSON `FeatureCollection` format as forward search (Section 2.1). + +**Error Responses:** + +| Status | Code | Condition | +|---|---|---| +| 400 | `MISSING_PARAMETER` | `lat` or `lon` missing | +| 400 | `INVALID_PARAMETER` | `lat`/`lon` out of range, `limit` out of range | +| 502 | `UPSTREAM_ERROR` | Photon returned an error | +| 503 | `SERVICE_UNAVAILABLE` | Photon is unreachable | + +--- + +## 3. Routing + +### 3.1 Calculate Route + +Proxied to OSRM. + +**Request:** + +``` +GET /api/route/{profile}/{coordinates}?alternatives={n}&steps={bool}&geometries={fmt}&overview={detail}&language={lang} +``` + +**Path Parameters:** + +| Parameter | Type | Required | Constraints | Description | +|---|---|---|---|---| +| `profile` | string | Yes | One of: `driving`, `walking`, `cycling` | Routing profile | +| `coordinates` | string | Yes | 2-7 coordinate pairs, semicolon-separated: `{lon},{lat};{lon},{lat}[;...]` | Route waypoints (first = origin, last = destination, middle = intermediate stops) | + +**Coordinate constraints:** Each `lon` in [-180, 180], each `lat` in [-90, 90]. Minimum 2 pairs, maximum 7 (origin + 5 waypoints + destination). + +**Query Parameters:** + +| Parameter | Type | Required | Default | Constraints | Description | +|---|---|---|---|---|---| +| `alternatives` | integer | No | 0 | [0, 3] | Number of alternative routes to return | +| `steps` | boolean | No | `true` | `true` or `false` | Include turn-by-turn steps | +| `geometries` | string | No | `geojson` | One of: `polyline`, `polyline6`, `geojson` | Geometry encoding format | +| `overview` | string | No | `full` | One of: `full`, `simplified`, `false` | Route geometry detail level | +| `language` | string | No | `en` | ISO 639-1 two-letter code | Language for turn instructions | + +**Success Response:** + +``` +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +Cache-Control: no-store +``` + +```json +{ + "code": "Ok", + "routes": [ + { + "distance": 12456.7, + "duration": 1823.4, + "weight": 1823.4, + "weight_name": "routability", + "geometry": { + "type": "LineString", + "coordinates": [ + [4.9041, 52.3676], + [4.9060, 52.3680], + [4.9100, 52.3700] + ] + }, + "legs": [ + { + "distance": 12456.7, + "duration": 1823.4, + "summary": "Keizersgracht, Damrak", + "steps": [ + { + "distance": 234.5, + "duration": 32.1, + "geometry": { + "type": "LineString", + "coordinates": [ + [4.9041, 52.3676], + [4.9060, 52.3680] + ] + }, + "maneuver": { + "type": "turn", + "modifier": "left", + "location": [4.9041, 52.3676], + "bearing_before": 90, + "bearing_after": 0, + "instruction": "Turn left onto Keizersgracht" + }, + "name": "Keizersgracht", + "mode": "driving", + "driving_side": "right", + "ref": "", + "intersections": [ + { + "location": [4.9041, 52.3676], + "bearings": [0, 90, 180, 270], + "entry": [true, false, true, true], + "out": 0, + "in": 1 + } + ] + }, + { + "distance": 0, + "duration": 0, + "geometry": { + "type": "LineString", + "coordinates": [[4.9100, 52.3700]] + }, + "maneuver": { + "type": "arrive", + "modifier": null, + "location": [4.9100, 52.3700], + "bearing_before": 45, + "bearing_after": 0, + "instruction": "You have arrived at your destination" + }, + "name": "Dam Square", + "mode": "driving" + } + ] + } + ] + } + ], + "waypoints": [ + { + "name": "Vondelstraat", + "location": [4.9041, 52.3676], + "hint": "..." + }, + { + "name": "Dam Square", + "location": [4.8952, 52.3732], + "hint": "..." + } + ] +} +``` + +**Route object schema:** + +| Field | Type | Description | +|---|---|---| +| `distance` | float | Total route distance in meters | +| `duration` | float | Estimated travel time in seconds | +| `weight` | float | OSRM routing weight | +| `weight_name` | string | Weight metric name | +| `geometry` | GeoJSON LineString | Full route geometry | +| `legs` | array of Leg | One leg per pair of consecutive waypoints | + +**Leg object schema:** + +| Field | Type | Description | +|---|---|---| +| `distance` | float | Leg distance in meters | +| `duration` | float | Leg duration in seconds | +| `summary` | string | Human-readable summary of major roads | +| `steps` | array of Step | Turn-by-turn steps (if `steps=true`) | + +**Step object schema:** + +| Field | Type | Description | +|---|---|---| +| `distance` | float | Step distance in meters | +| `duration` | float | Step duration in seconds | +| `geometry` | GeoJSON LineString | Step geometry | +| `maneuver` | Maneuver | Maneuver details | +| `name` | string | Road name | +| `mode` | string | Travel mode for this step | +| `ref` | string | Road reference number (if applicable) | +| `driving_side` | string | `"left"` or `"right"` | +| `intersections` | array | Intersection details | + +**Maneuver object schema:** + +| Field | Type | Nullable | Description | +|---|---|---|---| +| `type` | string | No | One of: `depart`, `arrive`, `turn`, `new name`, `merge`, `on ramp`, `off ramp`, `fork`, `end of road`, `continue`, `roundabout`, `rotary`, `roundabout turn`, `notification` | +| `modifier` | string | Yes | One of: `uturn`, `sharp right`, `right`, `slight right`, `straight`, `slight left`, `left`, `sharp left` | +| `location` | [lon, lat] | No | Coordinate of the maneuver | +| `bearing_before` | integer | No | Bearing before the maneuver (0-359) | +| `bearing_after` | integer | No | Bearing after the maneuver (0-359) | +| `instruction` | string | No | Human-readable instruction text | + +**Waypoint object schema:** + +| Field | Type | Description | +|---|---|---| +| `name` | string | Nearest road name to the snapped waypoint | +| `location` | [lon, lat] | Snapped coordinate | +| `hint` | string | OSRM hint for faster subsequent queries | + +**Error Responses:** + +| Status | Code | Condition | +|---|---|---| +| 400 | `INVALID_PARAMETER` | Invalid profile, `alternatives` out of range, invalid `geometries`/`overview` value | +| 400 | `INVALID_COORDINATES` | Coordinates out of range, fewer than 2 pairs, more than 7 pairs, malformed format | +| 404 | `NOT_FOUND` | OSRM could not find a route (e.g., coordinates on unreachable islands). OSRM code: `"NoRoute"` | +| 502 | `UPSTREAM_ERROR` | OSRM returned an unexpected error | +| 503 | `SERVICE_UNAVAILABLE` | OSRM instance for the requested profile is unreachable | + +**OSRM error code mapping:** + +| OSRM `code` | HTTP Status | API Error Code | +|---|---|---| +| `"Ok"` | 200 | — | +| `"NoRoute"` | 404 | `NOT_FOUND` | +| `"NoSegment"` | 400 | `INVALID_COORDINATES` | +| `"TooBig"` | 400 | `INVALID_PARAMETER` | +| Other | 502 | `UPSTREAM_ERROR` | + +**Caching:** Route responses are NOT cached (`Cache-Control: no-store`). Routes depend on real-time road graph state, and caching by coordinates would have an extremely low hit rate. + +--- + +## 4. Points of Interest + +### 4.1 List POIs in Bounding Box + +Served directly by the Rust gateway from PostGIS. + +**Request:** + +``` +GET /api/pois?bbox={bbox}&category={categories}&limit={limit}&offset={offset} +``` + +**Query Parameters:** + +| Parameter | Type | Required | Default | Constraints | Description | +|---|---|---|---|---|---| +| `bbox` | string | Yes | — | `minLon,minLat,maxLon,maxLat`. Valid coordinate ranges. `maxLon > minLon`, `maxLat > minLat`. Max area: 0.25 square degrees (~25km x 25km at equator) | Bounding box to query | +| `category` | string | No | — (all categories) | Comma-separated. Valid values: `restaurant`, `cafe`, `shop`, `supermarket`, `pharmacy`, `hospital`, `fuel`, `parking`, `atm`, `public_transport`, `hotel`, `tourist_attraction`, `park` | Category filter | +| `limit` | integer | No | 100 | [1, 500] | Maximum results | +| `offset` | integer | No | 0 | >= 0 | Pagination offset | + +**Success Response:** + +``` +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +Cache-Control: public, max-age=300 +``` + +```json +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [4.9041, 52.3676] + }, + "properties": { + "osm_id": 987654321, + "osm_type": "N", + "name": "Cafe de Jaren", + "category": "cafe", + "address": { + "street": "Nieuwe Doelenstraat", + "housenumber": "20", + "postcode": "1012 CP", + "city": "Amsterdam" + }, + "opening_hours": "Mo-Th 09:30-01:00; Fr-Sa 09:30-02:00; Su 10:00-01:00", + "opening_hours_parsed": { + "is_open": true, + "today": "09:30 - 01:00", + "next_change": "Closes at 01:00" + }, + "phone": "+31 20 625 5771", + "website": "https://www.cafedejaren.nl", + "wheelchair": "yes", + "tags": { + "cuisine": "dutch", + "outdoor_seating": "yes", + "internet_access": "wlan" + } + } + } + ], + "metadata": { + "total": 247, + "limit": 100, + "offset": 0 + } +} +``` + +**Feature properties schema:** + +| Field | Type | Nullable | Description | +|---|---|---|---| +| `osm_id` | integer | No | OpenStreetMap element ID | +| `osm_type` | string | No | `"N"`, `"W"`, or `"R"` | +| `name` | string | No | POI name | +| `category` | string | No | Normalized category (one of the valid category values) | +| `address` | object | Yes | Address details | +| `address.street` | string | Yes | Street name | +| `address.housenumber` | string | Yes | House number | +| `address.postcode` | string | Yes | Postal code | +| `address.city` | string | Yes | City name | +| `opening_hours` | string | Yes | Raw OSM `opening_hours` value | +| `opening_hours_parsed` | object | Yes | Parsed opening hours (null if `opening_hours` is null or unparseable) | +| `opening_hours_parsed.is_open` | boolean | No | Whether the POI is currently open | +| `opening_hours_parsed.today` | string | Yes | Today's hours in human-readable format | +| `opening_hours_parsed.next_change` | string | Yes | When the open/closed status next changes | +| `phone` | string | Yes | Phone number | +| `website` | string | Yes | Website URL | +| `wheelchair` | string | Yes | `"yes"`, `"no"`, `"limited"`, or null | +| `tags` | object | Yes | Additional OSM tags as key-value pairs | + +**Pagination metadata:** + +| Field | Type | Description | +|---|---|---| +| `total` | integer | Total number of POIs matching the query in this bbox | +| `limit` | integer | Limit used for this request | +| `offset` | integer | Offset used for this request | + +To paginate, increment `offset` by `limit` until `offset >= total`. + +**Error Responses:** + +| Status | Code | Condition | +|---|---|---| +| 400 | `MISSING_PARAMETER` | `bbox` not provided | +| 400 | `INVALID_BBOX` | Malformed bbox, coordinates out of range, min >= max, area exceeds maximum | +| 400 | `INVALID_PARAMETER` | Unknown category, `limit` out of range | +| 503 | `SERVICE_UNAVAILABLE` | PostGIS is unreachable | + +--- + +### 4.2 Get Single POI + +**Request:** + +``` +GET /api/pois/{osm_type}/{osm_id} +``` + +**Path Parameters:** + +| Parameter | Type | Required | Constraints | Description | +|---|---|---|---|---| +| `osm_type` | string | Yes | One of: `N`, `W`, `R` | OSM element type | +| `osm_id` | integer | Yes | Positive integer | OSM element ID | + +**Success Response:** + +``` +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +Cache-Control: public, max-age=3600 +``` + +```json +{ + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [4.9041, 52.3676] + }, + "properties": { + "osm_id": 987654321, + "osm_type": "N", + "name": "Cafe de Jaren", + "category": "cafe", + "address": { + "street": "Nieuwe Doelenstraat", + "housenumber": "20", + "postcode": "1012 CP", + "city": "Amsterdam" + }, + "opening_hours": "Mo-Th 09:30-01:00; Fr-Sa 09:30-02:00; Su 10:00-01:00", + "opening_hours_parsed": { + "is_open": true, + "today": "09:30 - 01:00", + "next_change": "Closes at 01:00" + }, + "phone": "+31 20 625 5771", + "website": "https://www.cafedejaren.nl", + "wheelchair": "yes", + "tags": { + "cuisine": "dutch", + "outdoor_seating": "yes", + "internet_access": "wlan" + } + } +} +``` + +Same property schema as Section 4.1 features. + +**Error Responses:** + +| Status | Code | Condition | +|---|---|---| +| 400 | `INVALID_PARAMETER` | `osm_type` not one of `N`/`W`/`R`, `osm_id` not a positive integer | +| 404 | `NOT_FOUND` | No POI found with the given type and ID | +| 503 | `SERVICE_UNAVAILABLE` | PostGIS is unreachable | + +--- + +## 5. Offline Data Packages + +### 5.1 List Available Regions + +**Request:** + +``` +GET /api/offline/regions +``` + +No parameters. + +**Success Response:** + +``` +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +Cache-Control: public, max-age=3600 +``` + +```json +{ + "regions": [ + { + "id": "amsterdam", + "name": "Amsterdam", + "description": "Amsterdam metropolitan area", + "bbox": [4.7288, 52.2783, 5.0796, 52.4311], + "size_mb": 95, + "last_updated": "2026-03-25T00:00:00Z", + "components": { + "tiles_mb": 55, + "routing_driving_mb": 12, + "routing_walking_mb": 10, + "routing_cycling_mb": 8, + "pois_mb": 10 + } + }, + { + "id": "netherlands", + "name": "Netherlands", + "description": "Full country coverage", + "bbox": [3.3316, 50.7504, 7.2275, 53.4720], + "size_mb": 1250, + "last_updated": "2026-03-25T00:00:00Z", + "components": { + "tiles_mb": 650, + "routing_driving_mb": 200, + "routing_walking_mb": 150, + "routing_cycling_mb": 150, + "pois_mb": 100 + } + } + ] +} +``` + +**Region object schema:** + +| Field | Type | Description | +|---|---|---| +| `id` | string | Unique region identifier (URL-safe slug) | +| `name` | string | Human-readable region name | +| `description` | string | Region description | +| `bbox` | array | `[minLon, minLat, maxLon, maxLat]` | +| `size_mb` | integer | Total download size in megabytes | +| `last_updated` | string | ISO 8601 timestamp of last data update | +| `components` | object | Size breakdown per component | +| `components.tiles_mb` | integer | Tile package size (MB) | +| `components.routing_driving_mb` | integer | Driving OSRM data size (MB) | +| `components.routing_walking_mb` | integer | Walking OSRM data size (MB) | +| `components.routing_cycling_mb` | integer | Cycling OSRM data size (MB) | +| `components.pois_mb` | integer | POI database size (MB) | + +**Error Responses:** + +| Status | Code | Condition | +|---|---|---| +| 500 | `INTERNAL_ERROR` | Failed to read region metadata | + +--- + +### 5.2 Download Region Component + +**Request:** + +``` +GET /api/offline/regions/{region_id}/{component} +``` + +**Path Parameters:** + +| Parameter | Type | Required | Constraints | Description | +|---|---|---|---|---| +| `region_id` | string | Yes | Must match an `id` from the regions list | Region identifier | +| `component` | string | Yes | One of: `tiles`, `routing-driving`, `routing-walking`, `routing-cycling`, `pois` | Component to download | + +**Request Headers (optional, for resume):** + +| Header | Value | Description | +|---|---|---| +| `Range` | `bytes={start}-{end}` | Request a byte range for pause/resume | + +**Success Response (full download):** + +``` +HTTP/1.1 200 OK +Content-Type: application/octet-stream +Content-Length: 57671680 +Accept-Ranges: bytes +Content-Disposition: attachment; filename="amsterdam-tiles.mbtiles" +Cache-Control: public, max-age=86400 +ETag: "region-amsterdam-tiles-20260325" +``` + +Body: binary file data. + +**Success Response (partial / resumed download):** + +``` +HTTP/1.1 206 Partial Content +Content-Type: application/octet-stream +Content-Range: bytes 10485760-57671679/57671680 +Content-Length: 47185920 +Accept-Ranges: bytes +``` + +Body: requested byte range. + +**Downloaded file formats:** + +| Component | File Format | Description | +|---|---|---| +| `tiles` | MBTiles (SQLite) | Vector tiles for zoom levels 0-16 | +| `routing-driving` | Tar archive | OSRM driving profile data files (`.osrm.*`) | +| `routing-walking` | Tar archive | OSRM walking profile data files | +| `routing-cycling` | Tar archive | OSRM cycling profile data files | +| `pois` | SQLite database | POI data with FTS5 search index | + +**Error Responses:** + +| Status | Code | Condition | +|---|---|---| +| 400 | `INVALID_PARAMETER` | Invalid component name | +| 404 | `NOT_FOUND` | Region ID not found or component not available | +| 416 | `INVALID_PARAMETER` | Range not satisfiable | +| 500 | `INTERNAL_ERROR` | Failed to read package file | + +--- + +## 6. Health Check + +### 6.1 System Health + +**Request:** + +``` +GET /api/health +``` + +No parameters. + +**Success Response:** + +``` +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +Cache-Control: no-store +``` + +```json +{ + "status": "ok", + "version": "1.0.0", + "uptime_seconds": 86421, + "services": { + "martin": { + "status": "ok", + "latency_ms": 12 + }, + "photon": { + "status": "ok", + "latency_ms": 45 + }, + "osrm_driving": { + "status": "ok", + "latency_ms": 8 + }, + "osrm_walking": { + "status": "ok", + "latency_ms": 7 + }, + "osrm_cycling": { + "status": "ok", + "latency_ms": 9 + }, + "postgres": { + "status": "ok", + "latency_ms": 3 + }, + "redis": { + "status": "ok", + "latency_ms": 1 + } + } +} +``` + +**Top-level `status` values:** +- `"ok"` — all services are healthy. +- `"degraded"` — one or more services are unhealthy but the system is partially functional. +- `"down"` — critical services (postgres, martin) are unreachable. + +**Per-service `status` values:** +- `"ok"` — service responded within timeout. +- `"degraded"` — service responded but slowly (> 2x typical latency). +- `"down"` — service did not respond within timeout. + +**Note:** The health endpoint always returns `200 OK` regardless of service status. The client should inspect the `status` field to determine overall health. This design allows load balancers to distinguish between "the gateway is running" (HTTP 200) and "upstream services are down" (status: degraded/down). + +**Error Responses:** + +| Status | Code | Condition | +|---|---|---| +| 500 | `INTERNAL_ERROR` | The health check itself failed (should not happen in normal operation) | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..e7d391c --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,791 @@ +# Architecture Document: Privacy-First Maps Application + +**Version:** 1.0.0 +**Date:** 2026-03-29 +**Status:** Ready for development review + +--- + +## 1. High-Level Architecture + +``` + +-----------------------+ + | Mobile App | + | (Flutter / Dart) | + | | + | +-------+ +--------+ | + | |MapLibre| |Drift DB| | + | |GL Native| (SQLite)| | + | +-------+ +--------+ | + +-----------+-----------+ + | + TLS 1.2+ only + | + +-----------v-----------+ + | Rust API Gateway | + | (Actix-web) | + | | + | /tiles/* /api/search | + | /api/route /api/pois | + | /api/offline /api/health + +-+------+------+------+-+ + | | | | + +------------+ +---+--+ +-+----+ +----------+ + | | | | | | + +------v------+ +-----v----+ +v------v-+ +--------v--------+ + | Martin | | Photon | | OSRM | | PostgreSQL | + | Tile Server | | Geocoder | | x3 | | + PostGIS | + | (Rust) | | (Java) | | profiles | | | + +------+------+ +----------+ +----------+ +-----------------+ + | ^ + +-------------------------------------------+ + (reads tile data from PostGIS) + + +-------------------+ + | Redis | + | (response cache) | + +-------------------+ + + +-----------------------------+ + | Caddy / nginx | + | (TLS termination, | + | reverse proxy) | + +-----------------------------+ +``` + +**Data flow summary:** +1. The mobile app connects exclusively to the Rust API Gateway over TLS. +2. The gateway routes requests to the appropriate upstream service. +3. Martin reads vector tile data from PostGIS. +4. Photon provides geocoding from its own Elasticsearch-backed index (built from OSM/Nominatim). +5. OSRM provides routing from preprocessed OSM graph data (3 separate instances). +6. The gateway directly queries PostGIS for POI data. +7. Redis caches frequently accessed responses (tiles, POI queries, region metadata). + +--- + +## 2. Component Responsibilities + +### 2.1 Mobile App (Flutter) +- Renders vector map tiles via MapLibre GL Native. +- Manages on-device state: search history, favorites, offline regions. +- Handles all user interactions: gestures, search, routing, POI browsing. +- Implements offline mode using locally stored MBTiles, OSRM data, and POI SQLite databases. +- Enforces TLS-only communication; no third-party network calls. + +### 2.2 Rust API Gateway (Actix-web) +- Single entry point for all mobile app requests. +- Proxies tile requests to Martin. +- Proxies search/reverse-geocoding requests to Photon. +- Proxies routing requests to the correct OSRM instance based on profile. +- Serves POI data directly by querying PostGIS. +- Serves offline data packages (tiles, routing data, POI extracts). +- Performs input validation, rate limiting, CORS, and health checks. +- Implements Redis caching layer. +- Logs requests without PII (no IPs, no query strings, no coordinates). + +### 2.3 Martin (Tile Server) +- Serves Mapbox Vector Tiles (MVT) from PostGIS. +- Serves the `style.json` used by MapLibre GL to configure rendering. +- Connected directly to PostGIS; reads from `openmaptiles`-schema tables. + +### 2.4 Photon (Geocoder) +- Forward geocoding: text query to coordinates + metadata. +- Reverse geocoding: coordinates to address/place. +- Stateless: does not log queries. +- Backed by Elasticsearch index built from Nominatim/OSM data. + +### 2.5 OSRM (Routing Engine) +- Three separate instances, one per profile: `driving`, `walking`, `cycling`. +- Each uses a profile-specific graph preprocessed from OSM PBF data. +- Returns routes with geometry, distance, duration, and turn-by-turn steps. +- Supports alternative routes (up to 3). + +### 2.6 PostgreSQL + PostGIS +- Stores OSM-imported geographic data used by Martin for tile generation. +- Stores POI data queried directly by the Rust gateway. +- Spatial indexes enable efficient bounding-box and proximity queries. + +### 2.7 Redis +- Caches tile responses, POI bounding-box query results, and region metadata. +- Reduces load on PostGIS and upstream services. + +--- + +## 3. Tech Stack Rationale + +| Choice | Rationale | +|---|---| +| **Flutter** | Single codebase for Android and iOS. Strong ecosystem for maps (`maplibre_gl`). Dart compiles to native ARM, achieving 60fps rendering. Large community reduces hiring risk. | +| **MapLibre GL Native** | Open-source fork of Mapbox GL Native. No telemetry, no API key required. Supports MVT vector tiles, client-side styling, and offline MBTiles. The privacy constraint eliminates Mapbox SDK. | +| **Riverpod** | Compile-safe, testable state management. Better than `bloc` for this use case because map state is inherently reactive (viewport changes, location updates, search results). Riverpod's provider model fits naturally with dependency injection. | +| **Drift** | Type-safe SQLite wrapper for Dart. Supports migrations, DAOs, and complex queries. Better than raw `sqflite` for maintainability. Compiles queries at build time. | +| **Dio** | Full-featured HTTP client with interceptor support (for TLS enforcement, logging, caching headers). No third-party interceptors are used. | +| **Rust + Actix-web** | Memory-safe, high-performance API gateway. No garbage collection pauses. Actix-web is the fastest Rust web framework by most benchmarks. Async runtime (Tokio) handles thousands of concurrent connections efficiently. Binary deployment (no JVM, no runtime). | +| **Martin** | Written in Rust (same ecosystem as the gateway). Serves tiles directly from PostGIS with minimal configuration. Supports MBTiles and PMTiles. Actively maintained. | +| **Photon** | Purpose-built for OSM geocoding. Better OSM coverage than Nominatim's API layer. Supports proximity-biased results. Self-hostable with no external dependencies beyond its Elasticsearch index. | +| **OSRM** | Industry-standard open-source routing. Sub-second query times for continental distances. Well-documented API. Multiple profile support. Used by many OSM-based apps (known quantity). | +| **PostGIS** | The standard for geospatial databases. Mature, well-indexed spatial queries. Native support in Martin. Ecosystem of import tools (`osm2pgsql`, `imposm3`). | +| **Redis** | In-memory cache with TTL support. Simple, fast, well-understood. Reduces repeated expensive PostGIS queries. | +| **Docker Compose** | Appropriate for self-hosted single-server deployment. All services defined declaratively. Easy for the target audience (self-hosters) to deploy and manage. Kubernetes would be overkill for the typical deployment scenario. | + +--- + +## 4. Mobile App Architecture + +### 4.1 Folder Structure + +``` +lib/ +├── main.dart # App entry point, ProviderScope +├── app.dart # MaterialApp, router config, theme +├── core/ +│ ├── config/ +│ │ ├── app_config.dart # Backend URL, defaults +│ │ └── env.dart # Environment-specific config +│ ├── constants/ +│ │ ├── map_constants.dart # Zoom levels, default location +│ │ └── api_constants.dart # Endpoint paths, timeouts +│ ├── error/ +│ │ ├── app_exception.dart # Base exception hierarchy +│ │ ├── error_handler.dart # Global error handling +│ │ └── failure.dart # Failure types for Either pattern +│ ├── network/ +│ │ ├── dio_client.dart # Dio instance, TLS config +│ │ ├── api_interceptor.dart # Request/response logging (no PII) +│ │ └── connectivity.dart # Online/offline detection +│ ├── theme/ +│ │ ├── app_theme.dart # Day/night Material themes +│ │ ├── map_styles.dart # MapLibre style JSON references +│ │ └── colors.dart +│ └── utils/ +│ ├── debouncer.dart +│ ├── coordinate_utils.dart +│ └── opening_hours_parser.dart +├── features/ +│ ├── map/ +│ │ ├── data/ +│ │ │ ├── repositories/ +│ │ │ │ └── tile_repository.dart +│ │ │ └── datasources/ +│ │ │ ├── tile_remote_source.dart +│ │ │ └── tile_cache_source.dart # MBTiles SQLite +│ │ ├── domain/ +│ │ │ ├── models/ +│ │ │ │ ├── map_position.dart +│ │ │ │ └── map_marker.dart +│ │ │ └── repositories/ +│ │ │ └── tile_repository.dart # Abstract interface +│ │ ├── presentation/ +│ │ │ ├── providers/ +│ │ │ │ ├── map_controller_provider.dart +│ │ │ │ ├── location_provider.dart +│ │ │ │ └── theme_provider.dart +│ │ │ ├── widgets/ +│ │ │ │ ├── map_view.dart +│ │ │ │ ├── compass_button.dart +│ │ │ │ ├── zoom_controls.dart +│ │ │ │ ├── attribution_widget.dart +│ │ │ │ └── location_button.dart +│ │ │ └── screens/ +│ │ │ └── map_screen.dart +│ │ └── map_providers.dart # Feature-level provider definitions +│ ├── search/ +│ │ ├── data/ +│ │ │ ├── repositories/ +│ │ │ │ └── search_repository.dart +│ │ │ ├── datasources/ +│ │ │ │ ├── photon_remote_source.dart +│ │ │ │ ├── search_history_local_source.dart +│ │ │ │ └── offline_search_source.dart +│ │ │ └── models/ +│ │ │ └── search_result_dto.dart +│ │ ├── domain/ +│ │ │ ├── models/ +│ │ │ │ ├── search_result.dart +│ │ │ │ └── search_history_item.dart +│ │ │ └── repositories/ +│ │ │ └── search_repository.dart +│ │ ├── presentation/ +│ │ │ ├── providers/ +│ │ │ │ ├── search_provider.dart +│ │ │ │ └── search_history_provider.dart +│ │ │ ├── widgets/ +│ │ │ │ ├── search_bar.dart +│ │ │ │ ├── search_result_tile.dart +│ │ │ │ └── recent_searches_list.dart +│ │ │ └── screens/ +│ │ │ └── search_screen.dart +│ │ └── search_providers.dart +│ ├── routing/ +│ │ ├── data/ +│ │ │ ├── repositories/ +│ │ │ │ └── routing_repository.dart +│ │ │ ├── datasources/ +│ │ │ │ ├── osrm_remote_source.dart +│ │ │ │ └── offline_routing_source.dart +│ │ │ └── models/ +│ │ │ └── route_dto.dart +│ │ ├── domain/ +│ │ │ ├── models/ +│ │ │ │ ├── route.dart +│ │ │ │ ├── route_step.dart +│ │ │ │ ├── maneuver.dart +│ │ │ │ └── route_profile.dart +│ │ │ └── repositories/ +│ │ │ └── routing_repository.dart +│ │ ├── presentation/ +│ │ │ ├── providers/ +│ │ │ │ ├── routing_provider.dart +│ │ │ │ ├── navigation_provider.dart +│ │ │ │ └── reroute_provider.dart +│ │ │ ├── widgets/ +│ │ │ │ ├── route_summary_card.dart +│ │ │ │ ├── turn_instruction.dart +│ │ │ │ ├── profile_selector.dart +│ │ │ │ └── route_line_layer.dart +│ │ │ └── screens/ +│ │ │ ├── directions_screen.dart +│ │ │ └── navigation_screen.dart +│ │ └── routing_providers.dart +│ ├── pois/ +│ │ ├── data/ +│ │ │ ├── repositories/ +│ │ │ │ └── poi_repository.dart +│ │ │ ├── datasources/ +│ │ │ │ ├── poi_remote_source.dart +│ │ │ │ └── poi_local_source.dart +│ │ │ └── models/ +│ │ │ └── poi_dto.dart +│ │ ├── domain/ +│ │ │ ├── models/ +│ │ │ │ ├── poi.dart +│ │ │ │ └── poi_category.dart +│ │ │ └── repositories/ +│ │ │ └── poi_repository.dart +│ │ ├── presentation/ +│ │ │ ├── providers/ +│ │ │ │ └── poi_provider.dart +│ │ │ ├── widgets/ +│ │ │ │ ├── poi_marker_layer.dart +│ │ │ │ ├── place_card.dart +│ │ │ │ └── opening_hours_display.dart +│ │ │ └── screens/ +│ │ │ └── poi_detail_screen.dart +│ │ └── poi_providers.dart +│ ├── favorites/ +│ │ ├── data/ +│ │ │ ├── repositories/ +│ │ │ │ └── favorites_repository.dart +│ │ │ └── datasources/ +│ │ │ └── favorites_local_source.dart +│ │ ├── domain/ +│ │ │ ├── models/ +│ │ │ │ ├── favorite.dart +│ │ │ │ └── favorite_group.dart +│ │ │ └── repositories/ +│ │ │ └── favorites_repository.dart +│ │ ├── presentation/ +│ │ │ ├── providers/ +│ │ │ │ └── favorites_provider.dart +│ │ │ ├── widgets/ +│ │ │ │ ├── favorite_list_tile.dart +│ │ │ │ └── save_favorite_dialog.dart +│ │ │ └── screens/ +│ │ │ └── favorites_screen.dart +│ │ └── favorites_providers.dart +│ ├── offline/ +│ │ ├── data/ +│ │ │ ├── repositories/ +│ │ │ │ └── offline_repository.dart +│ │ │ └── datasources/ +│ │ │ ├── offline_remote_source.dart +│ │ │ └── offline_local_source.dart +│ │ ├── domain/ +│ │ │ ├── models/ +│ │ │ │ ├── offline_region.dart +│ │ │ │ └── download_progress.dart +│ │ │ └── repositories/ +│ │ │ └── offline_repository.dart +│ │ ├── presentation/ +│ │ │ ├── providers/ +│ │ │ │ ├── offline_regions_provider.dart +│ │ │ │ └── download_manager_provider.dart +│ │ │ ├── widgets/ +│ │ │ │ ├── region_list_tile.dart +│ │ │ │ ├── download_progress_bar.dart +│ │ │ │ └── region_selector_map.dart +│ │ │ └── screens/ +│ │ │ └── offline_maps_screen.dart +│ │ └── offline_providers.dart +│ ├── sharing/ +│ │ └── presentation/ +│ │ ├── providers/ +│ │ │ └── share_provider.dart +│ │ └── widgets/ +│ │ └── share_sheet.dart +│ └── settings/ +│ ├── presentation/ +│ │ ├── providers/ +│ │ │ └── settings_provider.dart +│ │ └── screens/ +│ │ ├── settings_screen.dart +│ │ └── about_screen.dart +│ └── settings_providers.dart +├── database/ +│ ├── app_database.dart # Drift database definition +│ ├── tables/ +│ │ ├── search_history_table.dart +│ │ ├── favorites_table.dart +│ │ ├── favorite_groups_table.dart +│ │ └── offline_regions_table.dart +│ └── daos/ +│ ├── search_history_dao.dart +│ ├── favorites_dao.dart +│ └── offline_regions_dao.dart +└── router/ + └── app_router.dart # GoRouter or declarative routing +``` + +### 4.2 State Management (Riverpod) + +The app uses Riverpod with the following provider hierarchy: + +``` +Core Providers (app-wide): + ├── dioClientProvider → Dio instance (singleton) + ├── databaseProvider → Drift AppDatabase (singleton) + ├── connectivityProvider → Stream online/offline + ├── locationProvider → Stream from platform + └── settingsProvider → User preferences (theme, units, cache size) + +Feature Providers (scoped per feature): + ├── Map: + │ ├── mapControllerProvider → MapLibre controller + │ ├── mapPositionProvider → Current viewport (center, zoom, bearing) + │ └── themeProvider → Active map style (day/night/terrain) + ├── Search: + │ ├── searchQueryProvider → StateProvider + │ ├── searchResultsProvider → FutureProvider (debounced, calls repository) + │ └── searchHistoryProvider → StreamProvider (from Drift DAO) + ├── Routing: + │ ├── routeRequestProvider → StateProvider + │ ├── routeResultsProvider → FutureProvider (calls OSRM) + │ ├── selectedRouteProvider → StateProvider (selected alternative index) + │ └── navigationStateProvider → StateNotifierProvider (active navigation) + ├── POIs: + │ ├── visiblePoisProvider → FutureProvider (bbox-based, auto-refreshes on viewport change) + │ └── selectedPoiProvider → StateProvider + ├── Favorites: + │ ├── favoritesListProvider → StreamProvider (from Drift DAO) + │ └── favoriteGroupsProvider → StreamProvider + └── Offline: + ├── availableRegionsProvider → FutureProvider (from backend) + ├── downloadedRegionsProvider → StreamProvider (from Drift DAO) + └── activeDownloadsProvider → StateNotifierProvider (download manager) +``` + +**Key patterns:** +- `FutureProvider` for one-shot async data fetches. +- `StreamProvider` for reactive local data (Drift streams). +- `StateNotifierProvider` for complex mutable state (navigation, downloads). +- `Provider` for computed/derived values. +- Feature providers use `ref.watch` to react to upstream changes (e.g., `visiblePoisProvider` watches `mapPositionProvider`). + +### 4.3 Navigation + +Use `go_router` for declarative navigation: + +``` +/ → MapScreen (root) +/search → SearchScreen (overlay on map) +/directions → DirectionsScreen (origin/destination input) +/directions/navigate → NavigationScreen (active turn-by-turn) +/poi/:osmType/:osmId → POI detail (bottom sheet on map) +/favorites → FavoritesScreen +/settings → SettingsScreen +/settings/offline → OfflineMapsScreen +/settings/about → AboutScreen +``` + +The map remains persistent underneath all routes using a `ShellRoute` with the map as the shell. Search, POI details, and directions overlay the map as bottom sheets or panels. + +### 4.4 Dependency Injection + +Riverpod serves as the DI container. All dependencies are defined as providers: + +```dart +// Core +final dioClientProvider = Provider((ref) => createDioClient(ref)); +final databaseProvider = Provider((ref) => AppDatabase()); + +// Repositories (depend on data sources) +final searchRepositoryProvider = Provider((ref) { + return SearchRepositoryImpl( + remote: ref.watch(photonRemoteSourceProvider), + local: ref.watch(searchHistoryLocalSourceProvider), + offline: ref.watch(offlineSearchSourceProvider), + connectivity: ref.watch(connectivityProvider), + ); +}); +``` + +This makes testing straightforward: override any provider in tests with a mock. + +--- + +## 5. Backend Architecture (Rust / Actix-web) + +### 5.1 Project Structure + +``` +backend/ +├── Cargo.toml +├── src/ +│ ├── main.rs # Server bootstrap, service wiring +│ ├── config.rs # Configuration from env vars +│ ├── routes/ +│ │ ├── mod.rs +│ │ ├── tiles.rs # GET /tiles/{layer}/{z}/{x}/{y}.pbf +│ │ ├── search.rs # GET /api/search, GET /api/reverse +│ │ ├── routing.rs # GET /api/route/{profile}/{coordinates} +│ │ ├── pois.rs # GET /api/pois, GET /api/pois/{type}/{id} +│ │ ├── offline.rs # GET /api/offline/regions, downloads +│ │ └── health.rs # GET /api/health +│ ├── services/ +│ │ ├── mod.rs +│ │ ├── martin_proxy.rs # HTTP proxy to Martin +│ │ ├── photon_proxy.rs # HTTP proxy to Photon +│ │ ├── osrm_proxy.rs # HTTP proxy to OSRM instances +│ │ ├── poi_service.rs # PostGIS POI queries +│ │ ├── offline_service.rs # Region package management +│ │ └── health_service.rs # Upstream health checks +│ ├── middleware/ +│ │ ├── mod.rs +│ │ ├── cors.rs # CORS configuration +│ │ ├── request_logger.rs # Structured logging (no PII) +│ │ ├── rate_limiter.rs # Token bucket per IP (IP not logged) +│ │ └── input_validator.rs # Coordinate range checks, string sanitization +│ ├── cache/ +│ │ ├── mod.rs +│ │ └── redis_cache.rs # Redis get/set with TTL +│ ├── models/ +│ │ ├── mod.rs +│ │ ├── poi.rs # POI structs, GeoJSON serialization +│ │ ├── region.rs # Offline region metadata +│ │ └── error.rs # Error types and API error response +│ └── db/ +│ ├── mod.rs +│ └── postgres.rs # Connection pool (deadpool-postgres), spatial queries +├── migrations/ +│ └── 001_create_pois.sql +└── tests/ + ├── integration/ + └── common/ +``` + +### 5.2 Route Configuration + +```rust +// Simplified Actix-web route configuration +App::new() + .wrap(middleware::cors()) + .wrap(middleware::request_logger()) + .wrap(middleware::rate_limiter()) + // Tiles (proxied to Martin) + .route("/tiles/{layer}/{z}/{x}/{y}.pbf", web::get().to(tiles::get_tile)) + .route("/tiles/style.json", web::get().to(tiles::get_style)) + // Search (proxied to Photon) + .route("/api/search", web::get().to(search::search)) + .route("/api/reverse", web::get().to(search::reverse)) + // Routing (proxied to OSRM) + .route("/api/route/{profile}/{coordinates}", web::get().to(routing::route)) + // POIs (direct PostGIS queries) + .route("/api/pois", web::get().to(pois::list_pois)) + .route("/api/pois/{osm_type}/{osm_id}", web::get().to(pois::get_poi)) + // Offline + .route("/api/offline/regions", web::get().to(offline::list_regions)) + .route("/api/offline/regions/{id}/{component}", web::get().to(offline::download_component)) + // Health + .route("/api/health", web::get().to(health::check)) +``` + +### 5.3 Middleware + +**CORS:** +- Allow all origins (the mobile app's origin varies by deployment). +- Allow methods: `GET`, `HEAD`, `OPTIONS`. +- Allow headers: `Content-Type`, `Accept`, `Accept-Encoding`, `Range`. +- `Access-Control-Max-Age: 86400`. + +**Request Logger:** +- Logs: HTTP method, path (without query string), response status, response time in ms. +- Does NOT log: query parameters, client IP, request body, `User-Agent`, cookies. +- Uses `tracing` crate with structured JSON output. +- Log format: `{"method":"GET","path":"/api/pois","status":200,"duration_ms":42}` + +**Rate Limiter:** +- Token bucket algorithm, keyed by client IP (IP used only for bucket lookup, never logged or stored). +- Default: 100 requests/second per IP, burst of 200. +- Tile requests: 500 requests/second (tiles are cheap to serve from cache). +- Returns `429 Too Many Requests` with `Retry-After` header when exceeded. + +**Input Validator:** +- Validates coordinate ranges: latitude [-90, 90], longitude [-180, 180]. +- Validates zoom levels: [0, 18]. +- Validates `limit` parameters: enforces maximum values. +- Sanitizes string inputs: max length 500 characters for search queries, strip control characters. +- Returns `400 Bad Request` with error details on validation failure. + +### 5.4 Health Checks + +The `/api/health` endpoint actively probes each upstream service: + +| Service | Check | Timeout | +|---|---|---| +| Martin | `GET /health` on Martin's internal port | 2s | +| Photon | `GET /api/search?q=test&limit=1` | 3s | +| OSRM (driving) | `GET /route/v1/driving/0,0;0.001,0.001?overview=false` | 3s | +| OSRM (walking) | Same pattern | 3s | +| OSRM (cycling) | Same pattern | 3s | +| PostgreSQL | `SELECT 1` | 2s | + +If any service is down, the endpoint still returns `200 OK` but with that service's status as `"degraded"` or `"down"`. This allows the mobile app to show degraded-mode indicators. + +--- + +## 6. Deployment Architecture (Docker Compose) + +### 6.1 Topology + +```yaml +# docker-compose.yml (structural overview) +services: + gateway: # Rust API gateway port 8080 (internal) + martin: # Tile server port 3000 (internal) + photon: # Geocoder port 2322 (internal) + osrm-driving: # OSRM driving profile port 5001 (internal) + osrm-walking: # OSRM walking profile port 5002 (internal) + osrm-cycling: # OSRM cycling profile port 5003 (internal) + postgres: # PostgreSQL + PostGIS port 5432 (internal) + redis: # Cache port 6379 (internal) + caddy: # Reverse proxy + TLS ports 80, 443 (external) +``` + +### 6.2 Networking + +- All services are on a single Docker bridge network (`maps_net`). +- Only `caddy` exposes ports to the host (80 for HTTPS redirect, 443 for TLS). +- The gateway communicates with upstream services by Docker DNS names (`martin:3000`, `photon:2322`, etc.). +- No service except `caddy` is reachable from outside the Docker network. + +### 6.3 Volumes + +| Volume | Service | Purpose | +|---|---|---| +| `pg_data` | postgres | PostgreSQL data directory (persistent) | +| `martin_config` | martin | Martin configuration file | +| `photon_data` | photon | Photon/Elasticsearch index (persistent, ~2-20 GB depending on coverage) | +| `osrm_data` | osrm-* | Preprocessed OSRM graph files (persistent, per profile) | +| `redis_data` | redis | Redis AOF/RDB persistence (optional, cache can be cold-started) | +| `offline_packages` | gateway | Pre-built offline region packages served to mobile clients | +| `caddy_data` | caddy | TLS certificates (Let's Encrypt auto-provisioned) | +| `caddy_config` | caddy | Caddy configuration | + +### 6.4 Resource Allocation (for Netherlands-scale deployment) + +| Service | CPU Limit | Memory Limit | +|---|---|---| +| gateway | 1 core | 256 MB | +| martin | 1 core | 512 MB | +| photon | 2 cores | 2 GB | +| osrm-driving | 1 core | 1.5 GB | +| osrm-walking | 0.5 core | 1 GB | +| osrm-cycling | 0.5 core | 1 GB | +| postgres | 2 cores | 2 GB | +| redis | 0.5 core | 256 MB | +| caddy | 0.5 core | 128 MB | +| **Total** | **~9 cores** | **~8.5 GB** | + +--- + +## 7. Offline Architecture + +### 7.1 Overview + +Offline support has three pillars, each with its own storage format and data pipeline: + +``` ++-------------------------------------------+ +| Mobile Device Storage | +| | +| +-------------+ +-----------+ +-------+ | +| | MBTiles | | OSRM | | POI | | +| | (tiles.db) | | .osrm.* | | .db | | +| | SQLite | | files | | SQLite| | +| +-------------+ +-----------+ +-------+ | ++-------------------------------------------+ +``` + +### 7.2 Offline Tiles + +- Format: MBTiles (SQLite database with tile data as blobs). +- The backend pre-generates MBTiles files per region using Martin's `mbtiles` tool or `tilelive`. +- Zoom levels 0-16 are included (level 17-18 excluded to reduce size). +- On the device, MapLibre GL Native is configured with a composite tile source: + 1. First, check the offline MBTiles database for the requested tile. + 2. If not found, check the LRU tile cache (also MBTiles format). + 3. If not found and online, fetch from the backend and store in cache. + +### 7.3 Offline Routing + +- OSRM data files (`.osrm`, `.osrm.cell_metrics`, `.osrm.partition`, etc.) are downloaded per profile per region. +- On the device, routing is performed using a compiled OSRM library accessed via Dart FFI (Foreign Function Interface) bindings to the OSRM C++ library. +- The FFI binding exposes a minimal interface: `route(profile_data_path, coordinates) -> RouteResult`. +- Fallback: if FFI integration proves too complex for v1.0, the app can use pre-calculated route graphs in a simplified Dart-native implementation (Dijkstra on a simplified road graph stored in SQLite). This is a degraded experience but ensures offline routing works. + +### 7.4 Offline Search + +- POI data is downloaded as a SQLite database per region, containing the same schema as the PostGIS `pois` table. +- Offline search uses SQLite FTS5 (full-text search) on the `name` and `address` columns. +- Results are ranked by FTS5 relevance score, with proximity to the viewport center as a tiebreaker (using the Haversine formula in SQL). + +### 7.5 Download Manager + +- Downloads are managed by a background isolate (Dart isolate) to prevent blocking the UI. +- Each region download consists of multiple sequential component downloads (tiles, routing x3, POIs). +- Supports HTTP `Range` headers for pause/resume. +- Progress is tracked per component and aggregated per region. +- Downloads persist across app restarts by storing state in Drift (download URL, bytes received, total bytes). + +--- + +## 8. Privacy Architecture + +Each privacy commitment from the spec is enforced as follows: + +| Commitment | Technical Enforcement | +|---|---| +| **No accounts** | No authentication middleware on backend. No session tokens, cookies, or `Authorization` headers. No user table in the database. | +| **No telemetry** | CI pipeline runs `dart pub deps --json` and rejects any dependency matching a deny-list (Firebase, Sentry, Amplitude, etc.). Static analysis scans compiled binary for known analytics domain strings. | +| **No third-party network calls** | Dio base URL is the single configured backend URL. Dio interceptor rejects any request not matching the base URL prefix. CI step: `strings` on the compiled APK/IPA, grep for known third-party domains — fail if any found. | +| **On-device history** | Search history, favorites, and offline region metadata are stored in Drift (SQLite). No provider or repository ever sends this data over the network. Code review enforced. | +| **Self-hosted backend** | Docker Compose includes all services. No SaaS API keys in configuration. Backend makes zero outbound network calls (all data is local). | +| **Auditable** | Open-source. CI publishes dependency tree. Network monitor-friendly (single backend domain). | +| **No PII logging (backend)** | `request_logger` middleware logs only: method, path (without query params), status, duration. The `tracing` subscriber is configured to redact any field named `ip`, `query`, `user_agent`, `coordinates`. | +| **On-device encryption** | Android: database files stored in app-internal storage (encrypted by default on Android 10+; on Android 8-9, use `EncryptedSharedPreferences` for the SQLite encryption key with `SQLCipher`). iOS: files stored with `NSFileProtectionComplete` attribute (encrypted until first unlock). | +| **TLS only** | Dio configured with `baseUrl` starting with `https://`. A custom `SecurityContext` rejects plaintext. On the backend, Caddy enforces HTTPS and redirects HTTP to HTTPS. HSTS header set. | +| **No device fingerprinting** | No code reads IMEI, advertising ID, MAC address, or serial number. CI lint rule: any import of `device_info_plus`, `android_id`, or similar packages fails the build. | + +--- + +## 9. Error Handling Strategy + +### 9.1 Mobile App + +**Network errors:** +- Connection timeout / no connectivity: switch to offline mode automatically. Show "Offline mode" banner. Serve tiles from cache, search from local DB, routing from local OSRM data (if available). +- HTTP 429 (rate limited): retry after `Retry-After` duration with exponential backoff (max 3 retries). +- HTTP 5xx: show a non-intrusive snackbar ("Service temporarily unavailable"). Retry with exponential backoff. +- HTTP 404 (tile): render blank tile area, do not retry. + +**Service-specific degradation:** +| Service Down | User Experience | +|---|---| +| Martin (tiles) | Map shows cached tiles only. Uncached areas are blank. Banner: "Some map areas unavailable." | +| Photon (search) | Search falls back to offline POI database (if downloaded). Otherwise: "Search unavailable. Try offline maps." | +| OSRM (routing) | Routing falls back to offline OSRM data (if downloaded). Otherwise: "Routing unavailable." | +| PostGIS (POIs) | POI markers not shown. POI detail returns "Details unavailable." | +| Entire backend | Full offline mode. All features work if region is downloaded. Otherwise: cached tiles only, no search, no routing. | + +**Local errors:** +- SQLite corruption: detect on open, offer to clear cache (favorites are backed up separately). +- Disk full: warn user before download starts (check 80% threshold). If cache is full, LRU eviction runs automatically. + +### 9.2 Backend API + +All error responses use a standard format: + +```json +{ + "error": { + "code": "INVALID_BBOX", + "message": "Bounding box coordinates are out of valid range." + } +} +``` + +Error code mapping: + +| HTTP Status | Code | When | +|---|---|---| +| 400 | `INVALID_PARAMETER` | Query parameter fails validation | +| 400 | `INVALID_BBOX` | Bounding box coordinates out of range | +| 400 | `INVALID_COORDINATES` | Route coordinates out of range | +| 400 | `MISSING_PARAMETER` | Required parameter missing | +| 404 | `NOT_FOUND` | POI, tile, or region not found | +| 429 | `RATE_LIMITED` | Client exceeded rate limit | +| 502 | `UPSTREAM_ERROR` | Martin/Photon/OSRM returned an error | +| 503 | `SERVICE_UNAVAILABLE` | Upstream service is down | +| 500 | `INTERNAL_ERROR` | Unexpected server error | + +The gateway never exposes internal error details (stack traces, database errors) to the client. + +--- + +## 10. Security + +### 10.1 TLS + +- Caddy auto-provisions TLS certificates via Let's Encrypt (ACME). +- Minimum TLS version: 1.2. Preferred: 1.3. +- HSTS header: `Strict-Transport-Security: max-age=63072000; includeSubDomains`. +- HTTP requests to port 80 are 301-redirected to HTTPS. +- Internal Docker network communication is plaintext (acceptable: all services are on the same host, in the same Docker network, not exposed externally). + +### 10.2 No Authentication (by design) + +- The backend has no authentication mechanism. This is intentional. +- The backend is meant to be deployed on a private network or behind a VPN/firewall. +- If public exposure is needed, the deployer can add HTTP Basic Auth or client certificates at the Caddy layer. This is documented but not built into the application. + +### 10.3 Input Validation + +All user-supplied input is validated at the gateway before proxying: + +| Input | Validation | +|---|---| +| Zoom level (`z`) | Integer, [0, 18] | +| Tile coordinates (`x`, `y`) | Integer, >= 0, <= 2^z - 1 | +| Latitude | Float, [-90.0, 90.0] | +| Longitude | Float, [-180.0, 180.0] | +| Bounding box | 4 floats, valid lat/lon, min < max | +| Search query (`q`) | String, max 500 chars, control chars stripped | +| `limit` | Integer, [1, max_for_endpoint] | +| Routing profile | Enum: `driving`, `walking`, `cycling` | +| Coordinates (routing) | 2-7 coordinate pairs (origin + up to 5 waypoints + destination) | +| OSM type | Enum: `N`, `W`, `R` | +| OSM ID | Positive integer | +| `lang` | 2-char ISO 639-1, validated against allow-list | + +### 10.4 Rate Limiting + +- Applied per source IP using an in-memory token bucket (not stored in Redis to avoid logging IPs). +- Default limits (configurable via environment variables): + - Tile requests: 500/s per IP, burst 1000. + - API requests: 100/s per IP, burst 200. +- Returns `429` with `Retry-After` header. +- The rate limiter state is ephemeral (lost on restart), which is acceptable. + +### 10.5 Dependency Security + +- Backend: `cargo audit` runs in CI to detect known vulnerabilities in Rust dependencies. +- Mobile: `dart pub outdated` and manual review of transitive dependencies. +- No native dependencies beyond MapLibre GL Native and (optionally) OSRM FFI. +- Docker images use minimal base images (`rust:slim` for build, `debian:bookworm-slim` for runtime). + +### 10.6 Content Security + +- Martin tiles are binary protobuf; no injection risk. +- Photon and OSRM responses are validated JSON. The gateway re-serializes POI responses from PostGIS to prevent SQL injection artifacts from reaching the client. +- The gateway uses parameterized queries (via `tokio-postgres` / `sqlx`) for all PostGIS access. No string interpolation in SQL. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md new file mode 100644 index 0000000..d8c625a --- /dev/null +++ b/docs/DATA_MODEL.md @@ -0,0 +1,1329 @@ +# Data Model: Privacy-First Maps Application + +**Version:** 1.0.0 +**Date:** 2026-03-29 +**Status:** Ready for development review + +--- + +## Table of Contents + +1. [PostGIS Database Schema](#1-postgis-database-schema) +2. [Mobile SQLite Schemas (Drift ORM)](#2-mobile-sqlite-schemas-drift-orm) +3. [Redis Caching Strategy](#3-redis-caching-strategy) +4. [OSM Data Import Pipeline](#4-osm-data-import-pipeline) + +--- + +## 1. PostGIS Database Schema + +The PostgreSQL 16+ database with PostGIS 3.4+ stores two primary application tables: `pois` for point-of-interest data served by the Rust API gateway, and `offline_regions` for offline download package metadata. Martin reads separate `openmaptiles`-schema tables for tile generation; those tables are managed by the openmaptiles toolchain and are not documented here. + +### 1.1 `pois` Table + +Stores POI data extracted from OpenStreetMap via `osm2pgsql` with a custom style file. Queried directly by the Rust gateway for the `GET /api/pois` and `GET /api/pois/{osm_type}/{osm_id}` endpoints. + +```sql +CREATE TABLE pois ( + osm_id BIGINT NOT NULL, + osm_type CHAR(1) NOT NULL CHECK (osm_type IN ('N', 'W', 'R')), + name TEXT NOT NULL, + category TEXT NOT NULL, + geometry geometry(Point, 4326) NOT NULL, + address JSONB, + tags JSONB, + opening_hours TEXT, + phone TEXT, + website TEXT, + wheelchair TEXT CHECK (wheelchair IN ('yes', 'no', 'limited', NULL)), + CONSTRAINT pois_pk PRIMARY KEY (osm_type, osm_id) +); +``` + +**Column details:** + +| Column | Type | Nullable | Description | +|---|---|---|---| +| `osm_id` | `BIGINT` | No | OpenStreetMap element ID | +| `osm_type` | `CHAR(1)` | No | `'N'` (node), `'W'` (way), or `'R'` (relation) | +| `name` | `TEXT` | No | POI name from the OSM `name` tag | +| `category` | `TEXT` | No | Normalized category. One of: `restaurant`, `cafe`, `shop`, `supermarket`, `pharmacy`, `hospital`, `fuel`, `parking`, `atm`, `public_transport`, `hotel`, `tourist_attraction`, `park` | +| `geometry` | `geometry(Point, 4326)` | No | WGS84 point location. For ways and relations, this is the centroid | +| `address` | `JSONB` | Yes | Structured address: `{"street": "...", "housenumber": "...", "postcode": "...", "city": "..."}` | +| `tags` | `JSONB` | Yes | Additional OSM tags as key-value pairs (e.g., `{"cuisine": "italian", "outdoor_seating": "yes"}`) | +| `opening_hours` | `TEXT` | Yes | Raw OSM `opening_hours` tag value | +| `phone` | `TEXT` | Yes | Phone number from `phone` or `contact:phone` tag | +| `website` | `TEXT` | Yes | URL from `website` or `contact:website` tag | +| `wheelchair` | `TEXT` | Yes | Wheelchair accessibility: `'yes'`, `'no'`, `'limited'`, or `NULL` | + +**Indexes:** + +```sql +-- Spatial index for bounding-box queries (used by GET /api/pois?bbox=...) +CREATE INDEX idx_pois_geometry ON pois USING GIST (geometry); + +-- Category index for filtered POI queries (used by GET /api/pois?category=...) +CREATE INDEX idx_pois_category ON pois (category); + +-- Composite index for single-POI lookups (covered by the primary key) +-- The PK constraint already creates a unique index on (osm_type, osm_id) +``` + +**Example query (bounding-box with category filter):** + +```sql +SELECT osm_id, osm_type, name, category, + ST_AsGeoJSON(geometry)::json AS geometry, + address, tags, opening_hours, phone, website, wheelchair +FROM pois +WHERE geometry && ST_MakeEnvelope(4.85, 52.35, 4.95, 52.38, 4326) + AND category IN ('cafe', 'restaurant') +ORDER BY name +LIMIT 100 OFFSET 0; +``` + +**Example query (single POI by type and ID):** + +```sql +SELECT osm_id, osm_type, name, category, + ST_AsGeoJSON(geometry)::json AS geometry, + address, tags, opening_hours, phone, website, wheelchair +FROM pois +WHERE osm_type = 'N' AND osm_id = 987654321; +``` + +### 1.2 `offline_regions` Table + +Stores metadata about pre-built offline region packages. Queried by the gateway for `GET /api/offline/regions`. Region packages themselves are stored as files on disk (see Section 4). + +```sql +CREATE TABLE offline_regions ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + bbox geometry(Polygon, 4326) NOT NULL, + tiles_size_bytes BIGINT NOT NULL DEFAULT 0, + routing_size_bytes BIGINT NOT NULL DEFAULT 0, + pois_size_bytes BIGINT NOT NULL DEFAULT 0, + last_updated TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +**Column details:** + +| Column | Type | Nullable | Description | +|---|---|---|---| +| `id` | `TEXT` | No | URL-safe slug identifier (e.g., `amsterdam`, `netherlands`) | +| `name` | `TEXT` | No | Human-readable region name | +| `description` | `TEXT` | Yes | Region description | +| `bbox` | `geometry(Polygon, 4326)` | No | Region bounding box as a polygon | +| `tiles_size_bytes` | `BIGINT` | No | Size of the MBTiles package in bytes | +| `routing_size_bytes` | `BIGINT` | No | Combined size of all routing profile packages in bytes | +| `pois_size_bytes` | `BIGINT` | No | Size of the POI SQLite package in bytes | +| `last_updated` | `TIMESTAMPTZ` | No | Timestamp of the last data rebuild | + +**Indexes:** + +```sql +-- Spatial index for finding regions that overlap a given bounding box +CREATE INDEX idx_offline_regions_bbox ON offline_regions USING GIST (bbox); +``` + +**Example insert:** + +```sql +INSERT INTO offline_regions (id, name, description, bbox, tiles_size_bytes, routing_size_bytes, pois_size_bytes, last_updated) +VALUES ( + 'amsterdam', + 'Amsterdam', + 'Amsterdam metropolitan area', + ST_MakeEnvelope(4.7288, 52.2783, 5.0796, 52.4311, 4326), + 57671680, -- ~55 MB tiles + 31457280, -- ~30 MB routing (all profiles combined) + 10485760, -- ~10 MB POIs + '2026-03-25T00:00:00Z' +); +``` + +### 1.3 Full Migration Script + +```sql +-- migrations/001_create_pois.sql + +BEGIN; + +-- Enable PostGIS extension if not already present +CREATE EXTENSION IF NOT EXISTS postgis; + +-- POI table +CREATE TABLE IF NOT EXISTS pois ( + osm_id BIGINT NOT NULL, + osm_type CHAR(1) NOT NULL CHECK (osm_type IN ('N', 'W', 'R')), + name TEXT NOT NULL, + category TEXT NOT NULL, + geometry geometry(Point, 4326) NOT NULL, + address JSONB, + tags JSONB, + opening_hours TEXT, + phone TEXT, + website TEXT, + wheelchair TEXT CHECK (wheelchair IN ('yes', 'no', 'limited', NULL)), + CONSTRAINT pois_pk PRIMARY KEY (osm_type, osm_id) +); + +CREATE INDEX IF NOT EXISTS idx_pois_geometry ON pois USING GIST (geometry); +CREATE INDEX IF NOT EXISTS idx_pois_category ON pois (category); + +-- Offline regions table +CREATE TABLE IF NOT EXISTS offline_regions ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + bbox geometry(Polygon, 4326) NOT NULL, + tiles_size_bytes BIGINT NOT NULL DEFAULT 0, + routing_size_bytes BIGINT NOT NULL DEFAULT 0, + pois_size_bytes BIGINT NOT NULL DEFAULT 0, + last_updated TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_offline_regions_bbox ON offline_regions USING GIST (bbox); + +COMMIT; +``` + +--- + +## 2. Mobile SQLite Schemas (Drift ORM) + +The Flutter app uses [Drift](https://drift.simonbinder.eu/) (formerly Moor) as a type-safe SQLite ORM. All on-device data is stored in a single Drift-managed database (`app.db`), with a separate MBTiles database for tile caching. Database files are stored in the platform's encrypted storage directory. + +### 2.1 `search_history` Table + +Stores the last 50 search queries, displayed when the search bar is focused with an empty query. Never transmitted over the network. + +**Drift table definition (Dart):** + +```dart +// lib/database/tables/search_history_table.dart + +class SearchHistory extends Table { + IntColumn get id => integer().autoIncrement()(); + TextColumn get query => text()(); + RealColumn get latitude => real().nullable()(); + RealColumn get longitude => real().nullable()(); + IntColumn get timestamp => integer()(); // Unix epoch seconds +} +``` + +**Generated SQLite schema:** + +```sql +CREATE TABLE search_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + query TEXT NOT NULL, + latitude REAL, + longitude REAL, + timestamp INTEGER NOT NULL +); +``` + +**Drift DAO:** + +```dart +// lib/database/daos/search_history_dao.dart + +@DriftAccessor(tables: [SearchHistory]) +class SearchHistoryDao extends DatabaseAccessor + with _$SearchHistoryDaoMixin { + SearchHistoryDao(AppDatabase db) : super(db); + + /// Returns the most recent 50 search history items, newest first. + Future> getRecentSearches() { + return (select(searchHistory) + ..orderBy([(t) => OrderingTerm.desc(t.timestamp)]) + ..limit(50)) + .get(); + } + + /// Watches search history as a reactive stream (for Riverpod StreamProvider). + Stream> watchRecentSearches() { + return (select(searchHistory) + ..orderBy([(t) => OrderingTerm.desc(t.timestamp)]) + ..limit(50)) + .watch(); + } + + /// Inserts a new search entry. If the history exceeds 50 items, the oldest + /// entry is deleted. + Future addSearch(String query, {double? lat, double? lon}) async { + await into(searchHistory).insert(SearchHistoryCompanion.insert( + query: query, + latitude: Value(lat), + longitude: Value(lon), + timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + )); + // Evict entries beyond the 50-item limit + await customStatement(''' + DELETE FROM search_history + WHERE id NOT IN ( + SELECT id FROM search_history ORDER BY timestamp DESC LIMIT 50 + ) + '''); + } + + /// Deletes a single history entry by ID. + Future deleteSearch(int id) { + return (delete(searchHistory)..where((t) => t.id.equals(id))).go(); + } + + /// Deletes all search history. + Future clearAll() { + return delete(searchHistory).go(); + } +} +``` + +### 2.2 `favorites` Table + +Stores user-saved places. Each favorite belongs to a named group (default: `'Favorites'`). Supports import/export as GeoJSON. Never transmitted over the network. + +**Drift table definition (Dart):** + +```dart +// lib/database/tables/favorites_table.dart + +class Favorites extends Table { + IntColumn get id => integer().autoIncrement()(); + IntColumn get osmId => integer().nullable()(); + TextColumn get osmType => text().nullable()(); // 'N', 'W', or 'R' + TextColumn get name => text()(); + TextColumn get note => text().nullable()(); + TextColumn get groupName => text().withDefault(const Constant('Favorites'))(); + RealColumn get latitude => real()(); + RealColumn get longitude => real()(); + TextColumn get addressJson => text().nullable()(); // JSON-encoded address + IntColumn get createdAt => integer()(); // Unix epoch seconds + IntColumn get updatedAt => integer()(); // Unix epoch seconds +} +``` + +**Generated SQLite schema:** + +```sql +CREATE TABLE favorites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + osm_id INTEGER, + osm_type TEXT, + name TEXT NOT NULL, + note TEXT, + group_name TEXT NOT NULL DEFAULT 'Favorites', + latitude REAL NOT NULL, + longitude REAL NOT NULL, + address_json TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +``` + +**Drift DAO:** + +```dart +// lib/database/daos/favorites_dao.dart + +@DriftAccessor(tables: [Favorites]) +class FavoritesDao extends DatabaseAccessor + with _$FavoritesDaoMixin { + FavoritesDao(AppDatabase db) : super(db); + + /// Watches all favorites, grouped by group_name, ordered by name. + Stream> watchAllFavorites() { + return (select(favorites) + ..orderBy([ + (t) => OrderingTerm.asc(t.groupName), + (t) => OrderingTerm.asc(t.name), + ])) + .watch(); + } + + /// Watches favorites in a specific group. + Stream> watchFavoritesByGroup(String group) { + return (select(favorites) + ..where((t) => t.groupName.equals(group)) + ..orderBy([(t) => OrderingTerm.asc(t.name)])) + .watch(); + } + + /// Returns all distinct group names. + Future> getGroups() async { + final query = selectOnly(favorites, distinct: true) + ..addColumns([favorites.groupName]); + final rows = await query.get(); + return rows.map((row) => row.read(favorites.groupName)!).toList(); + } + + /// Inserts a new favorite. + Future addFavorite(FavoritesCompanion entry) { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + return into(favorites).insert(entry.copyWith( + createdAt: Value(now), + updatedAt: Value(now), + )); + } + + /// Updates an existing favorite (name, note, group). + Future updateFavorite(FavoriteData entry) { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + return update(favorites).replace(entry.copyWith(updatedAt: now)); + } + + /// Deletes a favorite by ID. + Future deleteFavorite(int id) { + return (delete(favorites)..where((t) => t.id.equals(id))).go(); + } +} +``` + +### 2.3 `offline_regions` Table + +Tracks downloaded offline regions on the device. Stores bounding box as individual coordinate columns (SQLite has no geometry type). Never transmitted over the network. + +**Drift table definition (Dart):** + +```dart +// lib/database/tables/offline_regions_table.dart + +class OfflineRegions extends Table { + TextColumn get id => text()(); // Matches backend region ID + TextColumn get name => text()(); + RealColumn get minLat => real()(); + RealColumn get minLon => real()(); + RealColumn get maxLat => real()(); + RealColumn get maxLon => real()(); + IntColumn get tilesSizeBytes => integer()(); + IntColumn get routingSizeBytes => integer()(); + IntColumn get poisSizeBytes => integer()(); + IntColumn get downloadedAt => integer()(); // Unix epoch seconds + IntColumn get lastUpdated => integer()(); // Unix epoch seconds (from backend) + + @override + Set get primaryKey => {id}; +} +``` + +**Generated SQLite schema:** + +```sql +CREATE TABLE offline_regions ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + min_lat REAL NOT NULL, + min_lon REAL NOT NULL, + max_lat REAL NOT NULL, + max_lon REAL NOT NULL, + tiles_size_bytes INTEGER NOT NULL, + routing_size_bytes INTEGER NOT NULL, + pois_size_bytes INTEGER NOT NULL, + downloaded_at INTEGER NOT NULL, + last_updated INTEGER NOT NULL +); +``` + +**Drift DAO:** + +```dart +// lib/database/daos/offline_regions_dao.dart + +@DriftAccessor(tables: [OfflineRegions]) +class OfflineRegionsDao extends DatabaseAccessor + with _$OfflineRegionsDaoMixin { + OfflineRegionsDao(AppDatabase db) : super(db); + + /// Watches all downloaded regions. + Stream> watchAll() { + return (select(offlineRegions) + ..orderBy([(t) => OrderingTerm.asc(t.name)])) + .watch(); + } + + /// Returns a single region by ID, or null if not downloaded. + Future getById(String regionId) { + return (select(offlineRegions)..where((t) => t.id.equals(regionId))) + .getSingleOrNull(); + } + + /// Checks if a point falls within any downloaded region. + Future findRegionContaining(double lat, double lon) { + return (select(offlineRegions) + ..where((t) => + t.minLat.isSmallerOrEqualValue(lat) & + t.maxLat.isBiggerOrEqualValue(lat) & + t.minLon.isSmallerOrEqualValue(lon) & + t.maxLon.isBiggerOrEqualValue(lon))) + .getSingleOrNull(); + } + + /// Inserts or replaces a downloaded region record. + Future upsertRegion(OfflineRegionsCompanion entry) { + return into(offlineRegions).insertOnConflictUpdate(entry); + } + + /// Deletes a region record. Caller is responsible for deleting the + /// associated files (MBTiles, OSRM data, POI database) from disk. + Future deleteRegion(String regionId) { + return (delete(offlineRegions)..where((t) => t.id.equals(regionId))).go(); + } + + /// Returns total storage used by all downloaded regions in bytes. + Future getTotalStorageBytes() async { + final query = selectOnly(offlineRegions) + ..addColumns([ + offlineRegions.tilesSizeBytes.sum(), + offlineRegions.routingSizeBytes.sum(), + offlineRegions.poisSizeBytes.sum(), + ]); + final row = await query.getSingle(); + final tiles = row.read(offlineRegions.tilesSizeBytes.sum()) ?? 0; + final routing = row.read(offlineRegions.routingSizeBytes.sum()) ?? 0; + final pois = row.read(offlineRegions.poisSizeBytes.sum()) ?? 0; + return tiles + routing + pois; + } +} +``` + +### 2.4 `settings` Table + +Stores key-value user preferences (theme, units, cache size limit, backend URL). Read at app startup by the `settingsProvider`. + +**Drift table definition (Dart):** + +```dart +// lib/database/tables/settings_table.dart + +class Settings extends Table { + TextColumn get key => text()(); + TextColumn get value => text()(); + + @override + Set get primaryKey => {key}; +} +``` + +**Generated SQLite schema:** + +```sql +CREATE TABLE settings ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL +); +``` + +**Known settings keys:** + +| Key | Default Value | Description | +|---|---|---| +| `backend_url` | (set at first launch) | Base URL of the self-hosted backend | +| `theme` | `auto` | Map theme: `day`, `night`, `terrain`, `auto` | +| `units` | `metric` | Distance units: `metric`, `imperial` | +| `tile_cache_size_mb` | `500` | Maximum tile cache size in MB | +| `last_viewport_lat` | `52.3676` | Last viewed map center latitude | +| `last_viewport_lon` | `4.9041` | Last viewed map center longitude | +| `last_viewport_zoom` | `12.0` | Last viewed map zoom level | + +### 2.5 Tile Cache (MBTiles) + +The tile cache is a separate SQLite database (`tile_cache.db`) following the [MBTiles 1.3 specification](https://github.com/mapbox/mbtiles-spec). It is managed independently of the Drift database. MapLibre GL Native reads from this database directly. + +**Schema:** + +```sql +-- MBTiles metadata table +CREATE TABLE metadata ( + name TEXT NOT NULL, + value TEXT NOT NULL +); + +-- MBTiles tiles table +CREATE TABLE tiles ( + zoom_level INTEGER NOT NULL, + tile_column INTEGER NOT NULL, + tile_row INTEGER NOT NULL, + tile_data BLOB NOT NULL +); + +CREATE UNIQUE INDEX idx_tiles ON tiles (zoom_level, tile_column, tile_row); +``` + +**LRU eviction strategy:** + +The app extends the MBTiles schema with a timestamp column to support LRU eviction: + +```sql +-- Extension: access tracking for LRU eviction +ALTER TABLE tiles ADD COLUMN last_accessed INTEGER NOT NULL DEFAULT 0; + +CREATE INDEX idx_tiles_lru ON tiles (last_accessed ASC); +``` + +When the cache exceeds the configured size limit (default 500 MB, max 2 GB), the oldest-accessed tiles are evicted: + +```sql +-- Evict oldest tiles until the database is under the size limit. +-- Run periodically or when inserting a new tile that exceeds the limit. +DELETE FROM tiles +WHERE rowid IN ( + SELECT rowid FROM tiles + ORDER BY last_accessed ASC + LIMIT ? -- number of tiles to evict, calculated by the app +); +``` + +### 2.6 Offline POI Database + +Each downloaded region includes a standalone SQLite POI database (`{region_id}_pois.db`) with the following schema. This is generated on the backend and downloaded by the app as-is. + +```sql +CREATE TABLE pois ( + osm_id INTEGER NOT NULL, + osm_type TEXT NOT NULL, + name TEXT NOT NULL, + category TEXT NOT NULL, + latitude REAL NOT NULL, + longitude REAL NOT NULL, + address_json TEXT, + tags_json TEXT, + opening_hours TEXT, + phone TEXT, + website TEXT, + wheelchair TEXT, + PRIMARY KEY (osm_type, osm_id) +); + +-- FTS5 full-text search index on name and address for offline search +CREATE VIRTUAL TABLE pois_fts USING fts5( + name, + address_text, + content='pois', + content_rowid='rowid', + tokenize='unicode61' +); + +-- Triggers to keep FTS index in sync (populated at build time on the backend) +CREATE TRIGGER pois_ai AFTER INSERT ON pois BEGIN + INSERT INTO pois_fts(rowid, name, address_text) + VALUES (new.rowid, new.name, COALESCE(json_extract(new.address_json, '$.street'), '') || ' ' || COALESCE(json_extract(new.address_json, '$.city'), '')); +END; + +-- Spatial-like index for bounding-box queries (SQLite has no native spatial index) +CREATE INDEX idx_pois_coords ON pois (latitude, longitude); +CREATE INDEX idx_pois_category ON pois (category); +``` + +### 2.7 Drift Database Definition + +All tables are combined in a single Drift database class: + +```dart +// lib/database/app_database.dart + +@DriftDatabase( + tables: [SearchHistory, Favorites, OfflineRegions, Settings], + daos: [SearchHistoryDao, FavoritesDao, OfflineRegionsDao], +) +class AppDatabase extends _$AppDatabase { + AppDatabase() : super(_openConnection()); + + @override + int get schemaVersion => 1; + + @override + MigrationStrategy get migration => MigrationStrategy( + onCreate: (Migrator m) async { + await m.createAll(); + }, + onUpgrade: (Migrator m, int from, int to) async { + // Future schema migrations go here + }, + ); + + static QueryExecutor _openConnection() { + return NativeDatabase.createInBackground( + File(join(appDocumentsDir, 'app.db')), + ); + } +} +``` + +--- + +## 3. Redis Caching Strategy + +Redis serves as an in-memory response cache for the Rust API gateway, reducing load on PostGIS and upstream services (Martin, Photon, OSRM). Redis is configured with `maxmemory 2gb` and `maxmemory-policy allkeys-lru`. + +### 3.1 Tile Cache + +Caches raw vector tile protobuf bytes returned by Martin. Tiles are immutable between OSM data imports (weekly), so a 24-hour TTL is appropriate. + +| Property | Value | +|---|---| +| **Key pattern** | `tile:{layer}:{z}:{x}:{y}` | +| **Example key** | `tile:openmaptiles:14:8425:5404` | +| **Value** | Raw gzip-compressed protobuf bytes (binary-safe Redis string) | +| **TTL** | 86400 seconds (24 hours) | +| **Eviction** | LRU at 2 GB `maxmemory` | + +**Rust pseudocode:** + +```rust +async fn get_tile(layer: &str, z: u32, x: u32, y: u32) -> Result { + let cache_key = format!("tile:{layer}:{z}:{x}:{y}"); + + // Check cache + if let Some(cached) = redis.get_bytes(&cache_key).await? { + return Ok(cached); + } + + // Fetch from Martin + let tile_data = martin_proxy.get_tile(layer, z, x, y).await?; + + // Store in cache with 24h TTL + redis.set_ex(&cache_key, &tile_data, 86400).await?; + + Ok(tile_data) +} +``` + +### 3.2 Search Cache + +Caches JSON responses from Photon for identical search queries. The cache key is derived from a hash of all query parameters to ensure that different proximity biases produce distinct cache entries. + +| Property | Value | +|---|---| +| **Key pattern** | `search:{sha256_hex(q + lat + lon + limit + lang)}` | +| **Example key** | `search:a3f2b8c1d4e5...` (64-char hex digest) | +| **Value** | JSON response body (GeoJSON FeatureCollection) | +| **TTL** | 3600 seconds (1 hour) | +| **Eviction** | LRU (shared maxmemory pool) | + +**Hash construction:** + +```rust +fn search_cache_key(q: &str, lat: Option, lon: Option, limit: u32, lang: &str) -> String { + let input = format!( + "q={}&lat={}&lon={}&limit={}&lang={}", + q, + lat.map(|v| format!("{:.6}", v)).unwrap_or_default(), + lon.map(|v| format!("{:.6}", v)).unwrap_or_default(), + limit, + lang, + ); + let hash = sha256::digest(input); + format!("search:{hash}") +} +``` + +### 3.3 Route Cache + +Caches JSON responses from OSRM. Routes are less likely to be cache hits (coordinates vary continuously), but repeated queries for the same origin/destination pair benefit from caching. Short TTL because road conditions can change. + +| Property | Value | +|---|---| +| **Key pattern** | `route:{sha256_hex(profile + coordinates + alternatives + steps + geometries + overview)}` | +| **Example key** | `route:b7e9d1f3c2a4...` (64-char hex digest) | +| **Value** | JSON response body (OSRM route response) | +| **TTL** | 1800 seconds (30 minutes) | +| **Eviction** | LRU (shared maxmemory pool) | + +**Hash construction:** + +```rust +fn route_cache_key( + profile: &str, + coordinates: &str, + alternatives: u32, + steps: bool, + geometries: &str, + overview: &str, +) -> String { + let input = format!( + "profile={}&coords={}&alt={}&steps={}&geom={}&overview={}", + profile, coordinates, alternatives, steps, geometries, overview, + ); + let hash = sha256::digest(input); + format!("route:{hash}") +} +``` + +### 3.4 Health Status Cache + +Caches the result of upstream health probes to avoid overwhelming upstream services with health checks. The `/api/health` endpoint reads from cache; a background task refreshes the cache every 30 seconds. + +| Property | Value | +|---|---| +| **Key pattern** | `health:{service}` | +| **Example keys** | `health:martin`, `health:photon`, `health:osrm_driving`, `health:osrm_walking`, `health:osrm_cycling`, `health:postgres`, `health:redis` | +| **Value** | JSON object: `{"status": "ok", "latency_ms": 12}` | +| **TTL** | 30 seconds | +| **Eviction** | TTL-based (entries are small, never hit LRU) | + +### 3.5 Redis Configuration + +```conf +# /etc/redis/redis.conf (relevant settings) + +maxmemory 2gb +maxmemory-policy allkeys-lru +save "" # Disable RDB snapshots (cache is ephemeral) +appendonly no # Disable AOF persistence +protected-mode yes +bind 127.0.0.1 # Listen only on Docker network interface +``` + +### 3.6 Cache Invalidation + +| Event | Invalidation Strategy | +|---|---| +| Weekly OSM data import | Flush all `tile:*` keys (`SCAN` + `UNLINK` in batches). Search and route caches expire naturally via TTL. | +| Manual rebuild | `FLUSHDB` to clear all cached data. The cache warms up organically from incoming requests. | +| Service restart | Redis is configured without persistence; cache starts empty. This is acceptable because the cache is a performance optimization, not a data store. | + +--- + +## 4. OSM Data Import Pipeline + +The backend imports OpenStreetMap data from [Geofabrik](https://download.geofabrik.de/) PBF extracts. The import pipeline produces four outputs: vector tile data in PostGIS, POI data in PostGIS, a Photon geocoding index, and OSRM routing graphs. A weekly cron job re-runs the pipeline to incorporate OSM edits. + +### 4.1 Pipeline Overview + +``` + +------------------+ + | Geofabrik PBF | + | (e.g. netherlands| + | -latest.osm.pbf)| + +--------+---------+ + | + +---------------+---------------+ + | | | + v v v + +-------+------+ +-----+------+ +------+------+ + | osm2pgsql | | Nominatim | | osrm-extract| + | (tiles + | | import | | osrm-partition + | POIs) | | | | osrm-customize + +-------+------+ +-----+------+ +------+------+ + | | | + v v v + +-------+------+ +-----+------+ +------+------+ + | PostGIS | | Nominatim | | .osrm files | + | (openmaptiles| | database | | (per profile)| + | schema + | | | | | + | pois table) | +-----+------+ +------+------+ + +-------+------+ | | + | v | + | +-----+------+ | + v | Photon | v + +-------+------+ | (reads | +------+------+ + | Martin | | Nominatim) | | OSRM server | + | (serves | +------------+ | (serves | + | tiles from | | .osrm files)| + | PostGIS) | +-------------+ + +--------------+ +``` + +### 4.2 Step 1: Download OSM PBF Extract + +Download the latest PBF extract from Geofabrik for the target region. + +```bash +#!/bin/bash +# scripts/01_download.sh + +REGION="europe/netherlands" +DATA_DIR="/data/osm" +GEOFABRIK_BASE="https://download.geofabrik.de" + +mkdir -p "$DATA_DIR" + +# Download PBF extract (or update if already present) +wget -N "${GEOFABRIK_BASE}/${REGION}-latest.osm.pbf" \ + -O "${DATA_DIR}/region.osm.pbf" + +# Download the corresponding state file for future diff updates +wget -N "${GEOFABRIK_BASE}/${REGION}-updates/state.txt" \ + -O "${DATA_DIR}/state.txt" +``` + +### 4.3 Step 2: Tile Data (osm2pgsql + openmaptiles + Martin) + +Import OSM data into PostGIS using the openmaptiles schema, which Martin then serves as vector tiles. + +```bash +#!/bin/bash +# scripts/02_import_tiles.sh + +PBF_FILE="/data/osm/region.osm.pbf" +PG_CONN="postgresql://maps:maps@postgres:5432/maps" + +# Clone openmaptiles toolchain (once) +if [ ! -d "/opt/openmaptiles" ]; then + git clone https://github.com/openmaptiles/openmaptiles.git /opt/openmaptiles +fi + +# Import OSM data into PostGIS using openmaptiles schema +# This creates the tables that Martin reads for tile generation +cd /opt/openmaptiles + +# osm2pgsql import with openmaptiles mapping +osm2pgsql \ + --create \ + --slim \ + --database "$PG_CONN" \ + --style openmaptiles.style \ + --tag-transform-script lua/tagtransform.lua \ + --number-processes 4 \ + --cache 4096 \ + --flat-nodes /data/osm/nodes.cache \ + "$PBF_FILE" + +# Run openmaptiles SQL post-processing to create materialized views +# that Martin serves as tile layers +psql "$PG_CONN" -f build/openmaptiles.sql + +echo "Tile data import complete. Martin will serve tiles from PostGIS." +``` + +Martin is configured to read from PostGIS and serve tiles: + +```yaml +# martin/config.yaml +postgres: + connection_string: postgresql://maps:maps@postgres:5432/maps + default_srid: 4326 + pool_size: 20 + + tables: + openmaptiles: + schema: public + table: planet_osm_polygon + srid: 3857 + geometry_column: way + geometry_type: GEOMETRY + properties: + name: name + class: class + subclass: subclass +``` + +### 4.4 Step 3: POI Data (osm2pgsql with Custom Style) + +Import POIs into the `pois` table using `osm2pgsql` with a custom Lua tag transform script that normalizes categories and extracts address fields. + +```bash +#!/bin/bash +# scripts/03_import_pois.sh + +PBF_FILE="/data/osm/region.osm.pbf" +PG_CONN="postgresql://maps:maps@postgres:5432/maps" + +# Run the initial migration to create the pois table +psql "$PG_CONN" -f /app/migrations/001_create_pois.sql + +# Import POIs using osm2pgsql with a custom Lua transform +osm2pgsql \ + --create \ + --output=flex \ + --style /app/scripts/poi_flex.lua \ + --database "$PG_CONN" \ + --cache 2048 \ + --number-processes 4 \ + --flat-nodes /data/osm/nodes.cache \ + "$PBF_FILE" + +echo "POI import complete." +``` + +**Custom osm2pgsql Lua flex output script:** + +```lua +-- scripts/poi_flex.lua +-- osm2pgsql flex output for POI extraction + +local pois = osm2pgsql.define_table({ + name = 'pois', + ids = { type = 'any', type_column = 'osm_type', id_column = 'osm_id' }, + columns = { + { column = 'name', type = 'text', not_null = true }, + { column = 'category', type = 'text', not_null = true }, + { column = 'geometry', type = 'point', projection = 4326, not_null = true }, + { column = 'address', type = 'jsonb' }, + { column = 'tags', type = 'jsonb' }, + { column = 'opening_hours', type = 'text' }, + { column = 'phone', type = 'text' }, + { column = 'website', type = 'text' }, + { column = 'wheelchair', type = 'text' }, + }, +}) + +-- Maps OSM amenity/shop/tourism/leisure tags to normalized categories +local category_map = { + -- amenity + restaurant = 'restaurant', + fast_food = 'restaurant', + cafe = 'cafe', + pharmacy = 'pharmacy', + hospital = 'hospital', + clinic = 'hospital', + fuel = 'fuel', + parking = 'parking', + atm = 'atm', + bank = 'atm', + bus_station = 'public_transport', + hotel = 'hotel', + -- shop + supermarket = 'supermarket', + convenience = 'shop', + clothes = 'shop', + hairdresser = 'shop', + bakery = 'shop', + -- tourism + attraction = 'tourist_attraction', + museum = 'tourist_attraction', + viewpoint = 'tourist_attraction', + -- leisure + park = 'park', + garden = 'park', + playground = 'park', +} + +local function get_category(tags) + for _, key in ipairs({'amenity', 'shop', 'tourism', 'leisure'}) do + local val = tags[key] + if val and category_map[val] then + return category_map[val] + end + end + return nil +end + +local function build_address(tags) + local addr = {} + if tags['addr:street'] then addr.street = tags['addr:street'] end + if tags['addr:housenumber'] then addr.housenumber = tags['addr:housenumber'] end + if tags['addr:postcode'] then addr.postcode = tags['addr:postcode'] end + if tags['addr:city'] then addr.city = tags['addr:city'] end + if next(addr) then return addr end + return nil +end + +local function build_extra_tags(tags) + local extra = {} + local dominated = { + 'name', 'amenity', 'shop', 'tourism', 'leisure', + 'addr:street', 'addr:housenumber', 'addr:postcode', 'addr:city', + 'opening_hours', 'phone', 'contact:phone', + 'website', 'contact:website', 'wheelchair', + } + local skip = {} + for _, k in ipairs(dominated) do skip[k] = true end + for k, v in pairs(tags) do + if not skip[k] and not k:match('^addr:') then + extra[k] = v + end + end + if next(extra) then return extra end + return nil +end + +function osm2pgsql.process_node(object) + local tags = object.tags + if not tags.name then return end + local category = get_category(tags) + if not category then return end + + pois:insert({ + name = tags.name, + category = category, + geometry = object:as_point(), + address = build_address(tags), + tags = build_extra_tags(tags), + opening_hours = tags.opening_hours, + phone = tags.phone or tags['contact:phone'], + website = tags.website or tags['contact:website'], + wheelchair = tags.wheelchair, + }) +end + +function osm2pgsql.process_way(object) + local tags = object.tags + if not tags.name then return end + local category = get_category(tags) + if not category then return end + if not object.is_closed then return end + + pois:insert({ + name = tags.name, + category = category, + geometry = object:as_polygon():centroid(), + address = build_address(tags), + tags = build_extra_tags(tags), + opening_hours = tags.opening_hours, + phone = tags.phone or tags['contact:phone'], + website = tags.website or tags['contact:website'], + wheelchair = tags.wheelchair, + }) +end + +function osm2pgsql.process_relation(object) + local tags = object.tags + if not tags.name then return end + local category = get_category(tags) + if not category then return end + if tags.type ~= 'multipolygon' then return end + + pois:insert({ + name = tags.name, + category = category, + geometry = object:as_multipolygon():centroid(), + address = build_address(tags), + tags = build_extra_tags(tags), + opening_hours = tags.opening_hours, + phone = tags.phone or tags['contact:phone'], + website = tags.website or tags['contact:website'], + wheelchair = tags.wheelchair, + }) +end +``` + +### 4.5 Step 4: Geocoding (Nominatim + Photon) + +Build a Nominatim database from the PBF extract, then point Photon at it to serve geocoding queries. + +```bash +#!/bin/bash +# scripts/04_import_geocoding.sh + +PBF_FILE="/data/osm/region.osm.pbf" +NOMINATIM_DATA="/data/nominatim" +PHOTON_DATA="/data/photon" + +# --- Nominatim Import --- +# Nominatim builds a PostgreSQL database with geocoding data. +# Photon reads from this database to build its Elasticsearch index. + +nominatim import \ + --osm-file "$PBF_FILE" \ + --project-dir "$NOMINATIM_DATA" \ + --threads 4 + +# --- Photon Import --- +# Photon reads the Nominatim database and builds an Elasticsearch index. +# This index is what Photon uses to serve search queries. + +java -jar /opt/photon/photon.jar \ + -nominatim-import \ + -host localhost \ + -port 5432 \ + -database nominatim \ + -user nominatim \ + -password nominatim \ + -data-dir "$PHOTON_DATA" \ + -languages en,nl,de,fr + +echo "Geocoding index built. Photon is ready to serve." +``` + +### 4.6 Step 5: Routing (OSRM) + +Preprocess the PBF extract into OSRM routing graphs, one per travel profile. + +```bash +#!/bin/bash +# scripts/05_import_routing.sh + +PBF_FILE="/data/osm/region.osm.pbf" +OSRM_DATA="/data/osrm" + +# Process each profile: driving, walking, cycling +for PROFILE in car foot bicycle; do + PROFILE_DIR="${OSRM_DATA}/${PROFILE}" + mkdir -p "$PROFILE_DIR" + cp "$PBF_FILE" "${PROFILE_DIR}/region.osm.pbf" + + # Step 1: Extract — parse the PBF and produce an .osrm file + # Uses the appropriate profile from OSRM's bundled profiles + osrm-extract \ + --profile /opt/osrm-profiles/${PROFILE}.lua \ + --threads 4 \ + "${PROFILE_DIR}/region.osm.pbf" + + # Step 2: Partition — create a recursive multi-level partition + osrm-partition \ + "${PROFILE_DIR}/region.osrm" + + # Step 3: Customize — compute edge weights for the partition + osrm-customize \ + "${PROFILE_DIR}/region.osrm" + + echo "OSRM ${PROFILE} profile ready." +done + +echo "All OSRM profiles processed." +``` + +The OSRM Docker containers are configured to load the preprocessed data: + +```yaml +# docker-compose.yml (OSRM services excerpt) +services: + osrm-driving: + image: osrm/osrm-backend:latest + command: osrm-routed --algorithm mld /data/region.osrm + volumes: + - ./data/osrm/car:/data + ports: + - "5001:5000" + + osrm-walking: + image: osrm/osrm-backend:latest + command: osrm-routed --algorithm mld /data/region.osrm + volumes: + - ./data/osrm/foot:/data + ports: + - "5002:5000" + + osrm-cycling: + image: osrm/osrm-backend:latest + command: osrm-routed --algorithm mld /data/region.osrm + volumes: + - ./data/osrm/bicycle:/data + ports: + - "5003:5000" +``` + +### 4.7 Step 6: Build Offline Packages + +After the import, generate downloadable offline packages for each configured region. + +```bash +#!/bin/bash +# scripts/06_build_offline_packages.sh + +PG_CONN="postgresql://maps:maps@postgres:5432/maps" +PACKAGES_DIR="/data/offline_packages" +REGION_ID="amsterdam" +BBOX="4.7288,52.2783,5.0796,52.4311" # minLon,minLat,maxLon,maxLat + +mkdir -p "${PACKAGES_DIR}/${REGION_ID}" + +# --- Tiles: extract MBTiles for the bounding box --- +# Use martin-cp (Martin's CLI tool) to export tiles from PostGIS to MBTiles +martin-cp \ + --output-file "${PACKAGES_DIR}/${REGION_ID}/tiles.mbtiles" \ + --mbtiles-type flat \ + --bbox "$BBOX" \ + --min-zoom 0 \ + --max-zoom 16 \ + --source openmaptiles \ + --connect "$PG_CONN" + +# --- POIs: export to SQLite with FTS5 index --- +# Custom Rust tool or Python script that queries PostGIS and writes SQLite +/app/tools/export_pois_sqlite \ + --bbox "$BBOX" \ + --pg-conn "$PG_CONN" \ + --output "${PACKAGES_DIR}/${REGION_ID}/pois.db" + +# --- Routing: tar the OSRM files per profile --- +for PROFILE in car foot bicycle; do + tar -cf "${PACKAGES_DIR}/${REGION_ID}/routing-${PROFILE}.tar" \ + -C "/data/osrm/${PROFILE}" \ + region.osrm region.osrm.cell_metrics region.osrm.cells \ + region.osrm.datasource_names region.osrm.ebg region.osrm.ebg_nodes \ + region.osrm.edges region.osrm.fileIndex region.osrm.geometry \ + region.osrm.icd region.osrm.maneuver_overrides \ + region.osrm.mldgr region.osrm.names region.osrm.nbg_nodes \ + region.osrm.partition region.osrm.properties \ + region.osrm.ramIndex region.osrm.timestamp \ + region.osrm.tld region.osrm.tls region.osrm.turn_duration_penalties \ + region.osrm.turn_penalties_index region.osrm.turn_weight_penalties +done + +# --- Update offline_regions table with file sizes --- +TILES_SIZE=$(stat -f%z "${PACKAGES_DIR}/${REGION_ID}/tiles.mbtiles" 2>/dev/null || stat -c%s "${PACKAGES_DIR}/${REGION_ID}/tiles.mbtiles") +ROUTING_SIZE=0 +for PROFILE in car foot bicycle; do + SIZE=$(stat -f%z "${PACKAGES_DIR}/${REGION_ID}/routing-${PROFILE}.tar" 2>/dev/null || stat -c%s "${PACKAGES_DIR}/${REGION_ID}/routing-${PROFILE}.tar") + ROUTING_SIZE=$((ROUTING_SIZE + SIZE)) +done +POIS_SIZE=$(stat -f%z "${PACKAGES_DIR}/${REGION_ID}/pois.db" 2>/dev/null || stat -c%s "${PACKAGES_DIR}/${REGION_ID}/pois.db") + +psql "$PG_CONN" <> /var/log/maps-update.log 2>&1 +``` + +```bash +#!/bin/bash +# scripts/update_all.sh +# Full weekly data update pipeline + +set -euo pipefail + +LOGFILE="/var/log/maps-update.log" +exec > >(tee -a "$LOGFILE") 2>&1 + +echo "=== OSM data update started at $(date -u) ===" + +# Step 1: Download latest PBF +/app/scripts/01_download.sh + +# Step 2: Import tile data +/app/scripts/02_import_tiles.sh + +# Step 3: Import POI data +/app/scripts/03_import_pois.sh + +# Step 4: Update geocoding index +/app/scripts/04_import_geocoding.sh + +# Step 5: Rebuild OSRM routing graphs +/app/scripts/05_import_routing.sh + +# Step 6: Rebuild offline packages +/app/scripts/06_build_offline_packages.sh + +# Step 7: Flush tile cache in Redis (tiles have changed) +redis-cli -h redis FLUSHDB + +# Step 8: Restart services to pick up new data +docker compose restart martin osrm-driving osrm-walking osrm-cycling + +echo "=== OSM data update completed at $(date -u) ===" +``` + +**Update frequency rationale:** Weekly updates balance data freshness against the computational cost of a full re-import. The Netherlands PBF is approximately 1.2 GB and takes roughly 30-45 minutes to process through all pipeline stages on an 8-core server with 16 GB RAM. More frequent updates (daily) are possible but increase server load. Less frequent updates (monthly) risk stale data, particularly for business opening hours and new roads. diff --git a/docs/SPECS.md b/docs/SPECS.md new file mode 100644 index 0000000..e999ed5 --- /dev/null +++ b/docs/SPECS.md @@ -0,0 +1,847 @@ +# Product Specification: Privacy-First Maps Application + +**Version:** 1.0.0-draft +**Date:** 2026-03-29 +**Status:** Ready for architecture & development review + +--- + +## Table of Contents + +1. [Product Vision & Differentiators](#1-product-vision--differentiators) +2. [Core Features](#2-core-features) +3. [User Flows & Acceptance Criteria](#3-user-flows--acceptance-criteria) +4. [Non-Functional Requirements](#4-non-functional-requirements) +5. [API Contract Outlines](#5-api-contract-outlines) +6. [Data Sources & Licensing](#6-data-sources--licensing) +7. [Tech Stack Summary](#7-tech-stack-summary) + +--- + +## 1. Product Vision & Differentiators + +### 1.1 Vision Statement + +A fully-featured maps and navigation application that provides the utility users expect from Google Maps while making an absolute guarantee: **the application never transmits, stores, or processes any user data outside the user's own device and their own self-hosted backend.** + +### 1.2 Privacy-First Philosophy — Concrete Commitments + +The term "privacy-first" is defined by the following non-negotiable constraints: + +| Commitment | Implementation | +|---|---| +| **No accounts** | The app has no sign-up, login, or authentication flow. There is no user identity. | +| **No telemetry** | Zero analytics SDKs, no crash reporting services, no usage metrics sent anywhere. | +| **No third-party network calls** | Every network request the app makes goes to the user's own self-hosted backend. The app binary contains no hardcoded URLs to Google, Apple, Facebook, Sentry, Firebase, Mapbox, or any other third-party service. | +| **On-device history** | Search history, recent routes, and favorites are stored in a local SQLite database on the device. They are never transmitted over the network. | +| **Self-hosted backend** | Tile serving (Martin), geocoding (Photon), and routing (OSRM) all run on infrastructure the deployer controls. No SaaS dependencies. | +| **Auditable** | The application is open-source. Any user can verify the above claims by inspecting the codebase and monitoring network traffic. | + +### 1.3 Competitive Comparison + +| Capability | Google Maps | Apple Maps | OsmAnd | **This App** | +|---|---|---|---|---| +| Account required | Yes (for full features) | Apple ID | No | **No** | +| Tracks location history | Yes (opt-out) | Yes (opt-out) | No | **No** | +| Analytics/telemetry | Extensive | Moderate | Minimal | **None** | +| Third-party API calls | N/A (is the third party) | Apple services | Some (optional) | **None** | +| Search quality | Excellent | Good | Fair | **Good** (Photon/OSM) | +| Offline maps | Limited | Limited | Full | **Full** | +| Self-hostable backend | No | No | Partial | **Fully** | +| Open-source | No | No | Yes (client) | **Yes (client + backend)** | +| Routing quality | Excellent | Good | Good (OSRM-based) | **Good** (OSRM) | +| UI/UX polish | Excellent | Excellent | Fair | **Target: Good** | + +### 1.4 Target Users + +- Privacy-conscious individuals who want a usable maps app without surveillance. +- Organizations (NGOs, journalists, activists) operating in environments where location privacy is critical. +- Self-hosting enthusiasts who want full control over their infrastructure. +- Users in regions where Google services are unavailable or undesirable. + +--- + +## 2. Core Features + +### 2.1 Map Rendering + +**Description:** The primary map view renders vector tiles served by a self-hosted Martin tile server. Vector tiles are styled client-side, enabling theme switching without re-downloading data. + +**Requirements:** + +- Vector tile rendering using Martin-served Mapbox Vector Tiles (MVT) format. +- Smooth pan and zoom with 60fps target on mid-range devices (2023+). +- Pinch-to-zoom, double-tap zoom, rotation, and tilt gestures. +- Zoom levels 0 (world) through 18 (building-level). +- **Day theme:** Light background, high-contrast roads and labels. +- **Night theme:** Dark background, reduced brightness, suitable for driving at night. Activates automatically based on device time or manual toggle. +- **Terrain layer:** Optional hillshade/contour overlay for hiking and outdoor use. +- Map attribution displayed per ODbL requirements (persistent "© OpenStreetMap contributors" in corner). +- Current location indicator (blue dot) with heading indicator when moving. +- Compass indicator; tap to re-orient north-up. + +**Tile Caching:** + +- On-device tile cache using SQLite (MBTiles format). +- Cache size configurable, default 500 MB, maximum 2 GB. +- LRU eviction policy when cache is full. +- Tiles served from cache when available, network fetch only on cache miss. + +### 2.2 Search / Geocoding + +**Description:** Users search for addresses, place names, and points of interest. Search queries are sent to a self-hosted Photon instance. Recent searches are stored only on-device. + +**Requirements:** + +- Single search bar at top of map view. +- As-you-type suggestions with debounce (300ms after last keystroke). +- Results ranked by relevance and proximity to current map viewport center. +- Each result shows: name, address, category icon, distance from current location. +- Tapping a result centers the map on that location and shows a place card. +- **Recent searches:** Last 50 searches stored in local SQLite. Displayed when search bar is focused and query is empty. User can clear individual items or all history. +- **No search logging on the backend.** Photon queries are stateless; the backend does not log query strings. + +### 2.3 Routing / Navigation + +**Description:** Turn-by-turn directions between two or more points, powered by a self-hosted OSRM instance. + +**Requirements:** + +- **Profiles:** Driving, walking, cycling. Each profile uses a separate OSRM dataset optimized for that mode. +- **Route request flow:** + 1. User selects origin (current location or search result) and destination. + 2. App requests route from OSRM. + 3. Up to 3 alternative routes displayed on map with estimated time and distance. + 4. User selects a route; turn-by-turn instruction list is shown. +- **Turn-by-turn display:** + - Next maneuver shown prominently at top of screen (icon + distance + street name). + - Subsequent maneuver shown in smaller text below. + - Full instruction list accessible by swiping up. + - Voice guidance is out of scope for v1.0 (planned for v1.1). +- **Waypoints:** User can add up to 5 intermediate stops by long-pressing on the map or searching. +- **Re-routing:** If the user deviates more than 50 meters from the active route, the app automatically requests a new route from the current position. +- **Route summary:** Total distance, estimated duration, and arrival time. +- **Offline routing:** If the user has downloaded the relevant region's OSRM data, routing works without network access (see Section 2.6). + +### 2.4 Points of Interest (POIs) + +**Description:** POI data is sourced from OpenStreetMap and served through the backend. Users can browse POIs on the map and view detail pages. + +**Requirements:** + +- POI categories rendered as icons on the map at appropriate zoom levels (zoom 14+ for most, zoom 12+ for major landmarks). +- **Categories include:** Restaurants, cafes, shops, supermarkets, pharmacies, hospitals, fuel stations, parking, ATMs, public transport stops, hotels, tourist attractions, parks. +- **Detail view** (shown when tapping a POI marker or search result): + - Name + - Category / type + - Address + - Opening hours (parsed from OSM `opening_hours` tag, displayed in human-readable format with current open/closed status) + - Phone number (if available) + - Website (if available, opened in external browser) + - Wheelchair accessibility (if tagged) + - "Get Directions" button + - "Save to Favorites" button +- POIs at the current viewport are fetched from the backend's POI endpoint (see Section 5.5). + +### 2.5 Bookmarks / Favorites + +**Description:** Users can save places to a local favorites list for quick access. No cloud sync. + +**Requirements:** + +- Save any location (POI, search result, or arbitrary map point via long-press) as a favorite. +- Each favorite stores: name (editable), coordinates, address, optional note, timestamp saved. +- Favorites list accessible from main menu. +- Favorites displayed as distinct markers on the map. +- User can organize favorites into custom groups (e.g., "Work", "Travel", "Restaurants"). +- Import/export favorites as GeoJSON file for manual backup or transfer between devices. +- All data stored in local SQLite. No network calls involved. + +### 2.6 Offline Maps + +**Description:** Users can download map regions for use without network access. A downloaded region includes vector tiles, OSRM routing data, and POI data. + +**Requirements:** + +- **Region selection:** User selects a rectangular area on the map, or chooses from a list of predefined regions (cities, states/provinces, countries). +- **Download package contents:** + - Vector tiles for the selected area (zoom levels 0-16). + - OSRM routing graph for the selected area (driving + walking + cycling). + - POI data for the selected area (as SQLite database). +- **Storage estimates:** + + | Region Size | Tiles | Routing | POIs | Total | + |---|---|---|---|---| + | City (~30km radius) | 30-80 MB | 15-50 MB | 5-20 MB | **50-150 MB** | + | State/Province | 200-500 MB | 100-300 MB | 20-80 MB | **320-880 MB** | + | Country (medium) | 1-3 GB | 0.5-1.5 GB | 50-200 MB | **1.5-4.7 GB** | + +- **Download management:** + - Progress indicator with percentage and estimated time remaining. + - Pause and resume support. + - Background download (continues when app is backgrounded). + - List of downloaded regions with size and last-updated date. + - Update a downloaded region (delta updates where possible). + - Delete a downloaded region. +- **Offline behavior:** + - When offline, the app uses cached/downloaded tiles, local OSRM data, and local POI database. + - Search uses a local Photon index bundled with the download (or falls back to coordinate-based lookup from local POI data). + - A banner indicates "Offline mode" at the top of the screen. + +--- + +## 3. User Flows & Acceptance Criteria + +### 3.1 Open App and Browse Map + +**Precondition:** App is installed, location permission granted (optional). + +**Flow:** + +1. User launches the app. +2. Splash screen displays for no more than the cold start time. +3. Map renders centered on the user's current location (if permission granted) or on a default location (configurable, default: last viewed location, or Europe center on first launch). +4. User pans, zooms, rotates, and tilts the map. + +**Acceptance Criteria:** + +- [ ] Map is interactive within 2 seconds of launch (cold start). +- [ ] Tiles within the initial viewport load from cache in < 200ms or from network in < 500ms. +- [ ] Pan and zoom maintain 60fps on a mid-range device. +- [ ] No network request is made to any domain other than the configured backend. +- [ ] If location permission is denied, the app still functions with no error—map centers on default location. +- [ ] Map attribution ("© OpenStreetMap contributors") is visible at all times. + +### 3.2 Search for a Place + +**Precondition:** App is open, map is visible. + +**Flow:** + +1. User taps the search bar. +2. Recent searches appear (if any). +3. User types a query (e.g., "Vondelpark"). +4. After 300ms of no typing, suggestions appear. +5. User taps a result. +6. Map animates to the selected location, a marker appears, and a place card slides up from the bottom. + +**Acceptance Criteria:** + +- [ ] Recent searches load from local SQLite in < 50ms. +- [ ] Search suggestions appear within 500ms of the debounce firing. +- [ ] Results are ordered by relevance, with proximity to viewport as a secondary signal. +- [ ] Tapping a result dismisses the keyboard and animates the map smoothly. +- [ ] The query is saved to local search history. +- [ ] The query is NOT logged or stored on the backend. +- [ ] Searching while offline returns results from the local POI database (if region is downloaded). + +### 3.3 Get Directions Between Two Points + +**Precondition:** App is open. + +**Flow:** + +1. User taps "Directions" (either from a place card or from the main menu). +2. Origin defaults to current location; user can change it via search. +3. User enters or selects a destination. +4. User selects a travel profile (driving/walking/cycling). +5. App displays up to 3 route alternatives on the map with time/distance. +6. User taps a route to select it. +7. Turn-by-turn instruction list appears. +8. User taps "Start" to begin navigation mode. + +**Acceptance Criteria:** + +- [ ] Route response returns within 1 second for distances under 100 km. +- [ ] At least 1 route (and up to 3 alternatives) is displayed. +- [ ] Each route shows total distance (km/mi based on locale) and estimated duration. +- [ ] Route line is drawn on the map with clear visual distinction between alternatives. +- [ ] Selected route is visually highlighted; unselected routes are dimmed. +- [ ] Turn-by-turn instructions include maneuver type, street name, and distance to maneuver. +- [ ] Re-routing triggers automatically when user deviates > 50m from the active route. +- [ ] Routing works offline if the region's OSRM data is downloaded. + +### 3.4 Save a Place as Favorite + +**Precondition:** A place card is visible (from search result, POI tap, or long-press on map). + +**Flow:** + +1. User taps the "Save" / bookmark icon on the place card. +2. A dialog appears with the place name pre-filled (editable), an optional note field, and a group selector (default: "Favorites"). +3. User confirms. +4. The marker changes to a favorites icon. The place appears in the favorites list. + +**Acceptance Criteria:** + +- [ ] Favorite is persisted to local SQLite immediately. +- [ ] No network request is made. +- [ ] Favorite appears in the favorites list and on the map. +- [ ] User can edit the name and note after saving. +- [ ] User can delete a favorite. +- [ ] Favorites survive app restart and device reboot. + +### 3.5 Download Area for Offline Use + +**Precondition:** App is open, device is online. + +**Flow:** + +1. User navigates to Settings > Offline Maps > Download Region. +2. User either selects a predefined region from a list or drags a selection rectangle on the map. +3. App shows estimated download size and required storage. +4. User confirms download. +5. Progress bar shows download progress. User can background the app. +6. On completion, a notification appears: "Region X is ready for offline use." + +**Acceptance Criteria:** + +- [ ] Estimated size is shown before download begins. +- [ ] Download can be paused and resumed. +- [ ] Download continues when the app is backgrounded. +- [ ] Downloaded region is listed under "Downloaded Regions" with name, size, and date. +- [ ] After download, map tiles for that region load from local storage without network. +- [ ] After download, search within that region works offline. +- [ ] After download, routing within that region works offline. +- [ ] User can delete a downloaded region and storage is reclaimed. + +### 3.6 View POI Details + +**Precondition:** Map is zoomed in enough to show POI icons (zoom level 14+). + +**Flow:** + +1. User taps a POI icon on the map. +2. A place card slides up from the bottom with the POI name and category. +3. User swipes the card up to reveal full details: address, opening hours, phone, website, accessibility info. +4. User taps "Get Directions" or "Save" or dismisses the card. + +**Acceptance Criteria:** + +- [ ] POI data loads within 300ms (from cache or network). +- [ ] Opening hours display the current open/closed status with today's hours. +- [ ] Phone number is tappable (opens dialer). +- [ ] Website is tappable (opens external browser — the app itself makes no request to the website). +- [ ] "Get Directions" pre-fills the POI as the destination in the routing flow. +- [ ] POI details work offline if the region is downloaded. + +### 3.7 Share a Location + +**Precondition:** A location is selected (via search, POI tap, or long-press on map). + +**Flow:** + +1. User taps the "Share" icon on the place card. +2. A share sheet appears with options: + - **Coordinates:** Plain text, e.g., "52.3676, 4.9041" + - **geo: URI:** `geo:52.3676,4.9041` + - **OpenStreetMap link:** `https://www.openstreetmap.org/#map=17/52.3676/4.9041` +3. User selects an option. The system share sheet opens (or content is copied to clipboard). + +**Acceptance Criteria:** + +- [ ] Sharing does not make any network request. +- [ ] Shared content does not include any tracking parameters or unique identifiers. +- [ ] Coordinates are in WGS84 (latitude, longitude) with 4 decimal places (11m precision). +- [ ] The system share sheet is used (no custom sharing implementation that phones home). + +### 3.8 Switch Map Theme + +**Precondition:** App is open, map is visible. + +**Flow:** + +1. User taps the layers/theme button on the map. +2. A panel shows available themes: Day, Night, Terrain. +3. User selects a theme. +4. Map re-renders with the new style immediately (no tile re-download needed since vector tiles are styled client-side). + +**Acceptance Criteria:** + +- [ ] Theme switch completes in < 500ms (no network request required). +- [ ] Night theme has noticeably reduced brightness suitable for dark environments. +- [ ] Terrain theme shows hillshade/contour overlay. +- [ ] Selected theme persists across app restarts (stored locally). +- [ ] Auto night mode option: switches to night theme based on device clock (sunset/sunrise for current location, or a fixed schedule like 20:00-06:00). + +--- + +## 4. Non-Functional Requirements + +### 4.1 Performance + +| Metric | Target | Measurement Method | +|---|---|---| +| Cold start to interactive map | < 2 seconds | Time from process start to first rendered frame with interactive tiles, on a mid-range 2023 Android device | +| Tile load from cache | < 200ms | Time from tile request to rendered tile, tile present in SQLite cache | +| Tile load from network | < 500ms | Time from tile request to rendered tile, tile not cached, backend in same region | +| Search suggestions | < 500ms | Time from debounce trigger to suggestions rendered | +| Route calculation (< 100km) | < 1 second | Time from request sent to route drawn on map | +| Route calculation (< 500km) | < 3 seconds | Time from request sent to route drawn on map | +| Pan/zoom frame rate | 60fps | Sustained frame rate during continuous gesture on mid-range device | +| POI detail load | < 300ms | Time from tap to full detail card rendered | + +### 4.2 Battery + +- Map browsing (active panning/zooming) must not drain battery faster than Google Maps doing the same activity on the same device. Target: within 10% of Google Maps' battery consumption. +- Background navigation (screen off, GPS active) must use less than 5% battery per hour. +- When the app is not actively being used and no navigation is in progress, GPS must be fully released (zero location updates). + +### 4.3 Data Usage + +- Vector tiles are approximately 10x smaller than equivalent raster tiles. A typical browsing session (30 minutes, exploring a city) should use < 15 MB of data. +- Initial app install size: < 30 MB (no bundled tile data; tiles are fetched or downloaded on demand). +- Tile cache uses LRU eviction. Default limit: 500 MB. + +### 4.4 Offline Storage + +| Region Type | Storage Estimate | +|---|---| +| Single city (~30km radius) | 50-150 MB | +| State/Province | 320-880 MB | +| Medium-sized country | 1.5-4.7 GB | + +The user must be warned if a download would fill more than 80% of available device storage. + +### 4.5 Privacy — Hard Requirements + +These are non-negotiable and must be verified in CI and during code review. + +1. **Zero third-party network calls.** The app binary must not contain any hardcoded URLs to external services. Every network request goes to the single configured backend URL. CI pipeline must include a static analysis step that scans the compiled binary and dependency tree for third-party URLs. +2. **No analytics or crash reporting SDKs.** The dependency tree must not include Firebase, Sentry, Amplitude, Mixpanel, Google Analytics, or any similar library. Enforced via dependency allow-listing in CI. +3. **No device fingerprinting.** The app must not read or transmit: IMEI, advertising ID, MAC address, serial number, or any persistent device identifier. +4. **No data collection on the backend.** The backend must not log: IP addresses (beyond ephemeral connection-level), query strings, coordinates, or any data that could identify a user or session. Backend access logs must be configured to omit query parameters and client IPs by default. +5. **On-device data encrypted at rest.** The local SQLite databases (history, favorites, cache) must be stored in the platform's encrypted storage (Android: EncryptedSharedPreferences / encrypted file system; iOS: Data Protection Complete). +6. **TLS only.** All communication between app and backend must use TLS 1.2 or higher. The app must reject plaintext HTTP connections. Certificate pinning is recommended but optional (since the user controls the backend). + +### 4.6 Accessibility + +- All interactive elements must have accessible labels. +- Minimum touch target: 48x48dp (Android) / 44x44pt (iOS). +- Support for system-level font scaling (up to 200%). +- Screen reader support for search results, POI details, and navigation instructions. +- Sufficient color contrast (WCAG 2.1 AA) in both day and night themes. + +### 4.7 Supported Platforms + +| Platform | Minimum Version | +|---|---| +| Android | API 26 (Android 8.0) | +| iOS | iOS 15.0 | + +--- + +## 5. API Contract Outlines + +The mobile app communicates with a single self-hosted backend. The backend is a Rust/Actix-web service that proxies or wraps Martin, Photon, and OSRM. + +**Base URL:** Configured by the user at first launch (e.g., `https://maps.example.com`). + +All endpoints are unauthenticated (no API keys, no tokens, no cookies). + +### 5.1 Tile Serving + +Proxied from Martin tile server. + +**Endpoint:** `GET /tiles/{layer}/{z}/{x}/{y}.pbf` + +**Path Parameters:** + +| Param | Type | Description | +|---|---|---| +| `layer` | string | Tile layer name (e.g., `openmaptiles`, `terrain`, `hillshade`) | +| `z` | integer | Zoom level (0-18) | +| `x` | integer | Tile column | +| `y` | integer | Tile row | + +**Response:** +- `200 OK` — Body: Protobuf-encoded Mapbox Vector Tile. Headers: `Content-Type: application/x-protobuf`, `Content-Encoding: gzip`, `Cache-Control: public, max-age=86400`. +- `404 Not Found` — Tile does not exist for the given coordinates. + +**Style Endpoint:** `GET /tiles/style.json` + +Returns a Mapbox GL style JSON document referencing the tile endpoints. The app uses this to configure the renderer. + +### 5.2 Search / Geocoding + +Proxied from Photon. + +**Endpoint:** `GET /api/search` + +**Query Parameters:** + +| Param | Type | Required | Description | +|---|---|---|---| +| `q` | string | Yes | Search query | +| `lat` | float | No | Latitude for proximity bias | +| `lon` | float | No | Longitude for proximity bias | +| `limit` | integer | No | Max results (default: 10, max: 20) | +| `lang` | string | No | Preferred language for results (ISO 639-1) | +| `bbox` | string | No | Bounding box filter: `minLon,minLat,maxLon,maxLat` | + +**Response (200 OK):** + +```json +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [4.9041, 52.3676] + }, + "properties": { + "osm_id": 12345678, + "osm_type": "N", + "name": "Vondelpark", + "street": "Vondelpark", + "city": "Amsterdam", + "state": "North Holland", + "country": "Netherlands", + "postcode": "1071 AA", + "type": "park", + "extent": [4.8580, 52.3585, 4.8820, 52.3620] + } + } + ] +} +``` + +### 5.3 Reverse Geocoding + +Proxied from Photon. + +**Endpoint:** `GET /api/reverse` + +**Query Parameters:** + +| Param | Type | Required | Description | +|---|---|---|---| +| `lat` | float | Yes | Latitude | +| `lon` | float | Yes | Longitude | +| `limit` | integer | No | Max results (default: 1) | +| `lang` | string | No | Preferred language | + +**Response:** Same GeoJSON FeatureCollection format as the search endpoint. + +### 5.4 Routing + +Proxied from OSRM. + +**Endpoint:** `GET /api/route/{profile}/{coordinates}` + +**Path Parameters:** + +| Param | Type | Description | +|---|---|---| +| `profile` | string | One of: `driving`, `walking`, `cycling` | +| `coordinates` | string | Semicolon-separated coordinate pairs: `{lon},{lat};{lon},{lat}[;...]` | + +**Query Parameters:** + +| Param | Type | Required | Description | +|---|---|---|---| +| `alternatives` | integer | No | Number of alternative routes (default: 0, max: 3) | +| `steps` | boolean | No | Include turn-by-turn steps (default: `true`) | +| `geometries` | string | No | Response geometry format: `polyline`, `polyline6`, or `geojson` (default: `geojson`) | +| `overview` | string | No | Geometry detail: `full`, `simplified`, or `false` (default: `full`) | +| `language` | string | No | Language for turn instructions (ISO 639-1) | + +**Response (200 OK):** + +```json +{ + "code": "Ok", + "routes": [ + { + "distance": 12456.7, + "duration": 1823.4, + "geometry": { + "type": "LineString", + "coordinates": [[4.9041, 52.3676], [4.9100, 52.3700]] + }, + "legs": [ + { + "distance": 12456.7, + "duration": 1823.4, + "steps": [ + { + "distance": 234.5, + "duration": 32.1, + "geometry": { + "type": "LineString", + "coordinates": [[4.9041, 52.3676], [4.9060, 52.3680]] + }, + "maneuver": { + "type": "turn", + "modifier": "left", + "location": [4.9041, 52.3676], + "bearing_before": 90, + "bearing_after": 0, + "instruction": "Turn left onto Keizersgracht" + }, + "name": "Keizersgracht", + "mode": "driving" + } + ] + } + ] + } + ], + "waypoints": [ + { + "name": "Vondelstraat", + "location": [4.9041, 52.3676] + }, + { + "name": "Dam Square", + "location": [4.8952, 52.3732] + } + ] +} +``` + +### 5.5 POI Details + +Custom endpoint served by the Rust/Actix-web backend. Queries PostGIS for OSM-sourced POI data. + +**List POIs in Bounding Box:** + +`GET /api/pois` + +**Query Parameters:** + +| Param | Type | Required | Description | +|---|---|---|---| +| `bbox` | string | Yes | `minLon,minLat,maxLon,maxLat` | +| `category` | string | No | Filter by category (comma-separated, e.g., `restaurant,cafe`) | +| `limit` | integer | No | Max results (default: 100, max: 500) | + +**Response (200 OK):** + +```json +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [4.9041, 52.3676] + }, + "properties": { + "osm_id": 987654321, + "osm_type": "N", + "name": "Cafe de Jaren", + "category": "cafe", + "address": { + "street": "Nieuwe Doelenstraat", + "housenumber": "20", + "postcode": "1012 CP", + "city": "Amsterdam" + }, + "opening_hours": "Mo-Th 09:30-01:00; Fr-Sa 09:30-02:00; Su 10:00-01:00", + "opening_hours_parsed": { + "is_open": true, + "today": "09:30 - 01:00", + "next_change": "01:00" + }, + "phone": "+31 20 625 5771", + "website": "https://www.cafedejaren.nl", + "wheelchair": "yes" + } + } + ] +} +``` + +**Single POI Detail:** + +`GET /api/pois/{osm_type}/{osm_id}` + +**Path Parameters:** + +| Param | Type | Description | +|---|---|---| +| `osm_type` | string | `N` (node), `W` (way), or `R` (relation) | +| `osm_id` | integer | OpenStreetMap element ID | + +**Response:** Single GeoJSON Feature with the same structure as above. + +### 5.6 Offline Data Packages + +Custom endpoint for downloading offline regions. + +**List Available Regions:** + +`GET /api/offline/regions` + +**Response (200 OK):** + +```json +{ + "regions": [ + { + "id": "amsterdam", + "name": "Amsterdam", + "bbox": [4.7288, 52.2783, 5.0796, 52.4311], + "size_mb": 95, + "last_updated": "2026-03-25T00:00:00Z", + "components": { + "tiles_mb": 55, + "routing_mb": 30, + "pois_mb": 10 + } + } + ] +} +``` + +**Download Region Component:** + +`GET /api/offline/regions/{region_id}/{component}` + +**Path Parameters:** + +| Param | Type | Description | +|---|---|---| +| `region_id` | string | Region identifier | +| `component` | string | One of: `tiles`, `routing-driving`, `routing-walking`, `routing-cycling`, `pois` | + +**Response:** +- `200 OK` — Binary file download. `Content-Type: application/octet-stream`. Supports `Range` headers for pause/resume. +- `Accept-Ranges: bytes` header included. + +### 5.7 Health Check + +`GET /api/health` + +**Response (200 OK):** + +```json +{ + "status": "ok", + "version": "1.0.0", + "services": { + "martin": "ok", + "photon": "ok", + "osrm_driving": "ok", + "osrm_walking": "ok", + "osrm_cycling": "ok", + "postgres": "ok" + } +} +``` + +--- + +## 6. Data Sources & Licensing + +### 6.1 OpenStreetMap + +- **Use:** Base map data for tiles, POIs, addresses. +- **License:** Open Data Commons Open Database License (ODbL) 1.0. +- **Obligations:** + - Attribution: "© OpenStreetMap contributors" must be displayed on the map at all times. + - Share-Alike: If the database is modified and redistributed, the modified database must be released under ODbL. + - The application's own source code is not affected by ODbL (it is not a "derivative database"). +- **Update frequency:** Backend imports OSM data weekly via `osm2pgsql` or `imposm3`. + +### 6.2 OSRM (Open Source Routing Machine) + +- **Use:** Route calculation and turn-by-turn navigation. +- **License:** BSD 2-Clause License. +- **Obligations:** Include copyright notice and license text in documentation / about screen. +- **Deployment:** Self-hosted OSRM backend, one instance per profile (driving, walking, cycling). Data preprocessed from OSM PBF files using `osrm-extract`, `osrm-partition`, `osrm-customize`. + +### 6.3 Photon + +- **Use:** Forward and reverse geocoding (search). +- **License:** Apache License 2.0. +- **Obligations:** Include copyright notice and license text. State any modifications. +- **Deployment:** Self-hosted Photon instance. Data imported from Nominatim database (itself built from OSM data). Updated weekly alongside the OSM data import. + +### 6.4 Martin + +- **Use:** Vector tile serving. +- **License:** Dual-licensed: MIT License and Apache License 2.0. +- **Obligations:** Include copyright notice and license text (either MIT or Apache 2.0, at our choice). +- **Deployment:** Self-hosted Martin instance connected to PostGIS database containing tile data generated from OSM via `openmaptiles` tools. + +### 6.5 License Attribution in App + +The "About" screen in the app must display: +- OpenStreetMap attribution and ODbL license summary with link. +- OSRM copyright and BSD-2 license notice. +- Photon copyright and Apache 2.0 license notice. +- Martin copyright and MIT/Apache 2.0 license notice. +- Any additional open-source libraries used (auto-generated from dependency metadata). + +--- + +## 7. Tech Stack Summary + +### 7.1 Mobile App + +| Component | Technology | +|---|---| +| Framework | Flutter (latest stable) | +| Language | Dart | +| Map renderer | `maplibre_gl` (Flutter plugin for MapLibre GL Native) | +| Local storage | SQLite via `sqflite` / `drift` | +| HTTP client | `dio` (with TLS enforcement, no third-party interceptors) | +| State management | `riverpod` or `bloc` (to be decided during architecture) | +| Tile cache | MBTiles in SQLite | +| Offline routing | Embedded OSRM data files with Dart FFI bindings (or pre-calculated route graphs) | + +### 7.2 Backend + +| Component | Technology | +|---|---| +| API gateway / proxy | Rust + Actix-web | +| Tile server | Martin (connected to PostGIS) | +| Geocoding | Photon (Java, self-hosted) | +| Routing | OSRM (C++, self-hosted, one instance per profile) | +| Database | PostgreSQL 16+ with PostGIS 3.4+ | +| OSM data import | `osm2pgsql` or `imposm3` | +| Tile generation | `openmaptiles` toolchain | +| Containerization | Docker Compose for all services | +| Reverse proxy / TLS | Caddy or nginx (user-configured) | + +### 7.3 Infrastructure Requirements (Minimum) + +For serving a single country (e.g., Netherlands): + +| Resource | Minimum | Recommended | +|---|---|---| +| CPU | 4 cores | 8 cores | +| RAM | 8 GB | 16 GB | +| Disk | 50 GB SSD | 100 GB SSD | +| Network | 100 Mbps | 1 Gbps | + +For global coverage, significantly more resources are needed (primarily for OSRM preprocessing and Photon index size). + +--- + +## Appendix A: Glossary + +| Term | Definition | +|---|---| +| **MVT** | Mapbox Vector Tile — a compact binary format for encoding tiled map data as vector geometry | +| **PBF** | Protocolbuffer Binary Format — the compact binary format used for OSM data extracts and vector tiles | +| **ODbL** | Open Data Commons Open Database License — the license governing OpenStreetMap data | +| **PostGIS** | Spatial extension for PostgreSQL enabling geographic queries | +| **MBTiles** | A specification for storing tiled map data in SQLite databases | +| **OSRM** | Open Source Routing Machine — a C++ routing engine for shortest paths in road networks | +| **Photon** | A geocoder built for OpenStreetMap data, powered by Elasticsearch | +| **Martin** | A PostGIS/MBTiles vector tile server written in Rust | +| **WGS84** | World Geodetic System 1984 — the coordinate reference system used by GPS (EPSG:4326) | + +## Appendix B: Open Questions + +1. **Voice navigation (v1.1):** Use platform TTS APIs (on-device, no network) or bundle an open-source TTS engine? Platform TTS is simpler but quality varies. To be decided before v1.1 planning. +2. **Public transit routing:** OSRM does not support public transit. Options include integrating OpenTripPlanner (AGPL — license implications need review) or deferring to a future version. +3. **Map style customization:** Should users be able to load custom Mapbox GL styles, or do we provide only the bundled Day/Night/Terrain themes? Custom styles add flexibility but increase support surface. +4. **Multi-backend support:** Should the app support configuring multiple backend URLs (e.g., one for tiles, one for routing) or require a single unified backend? Single URL is simpler; multiple URLs enable distributed setups. +5. **Delta updates for offline maps:** Implementing true delta updates (only download changed tiles) is complex. v1.0 may use full re-download of components. Delta updates can be a v1.1 optimization. diff --git a/mobile/.gitignore b/mobile/.gitignore new file mode 100644 index 0000000..79c113f --- /dev/null +++ b/mobile/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/mobile/.metadata b/mobile/.metadata new file mode 100644 index 0000000..e8f7bf9 --- /dev/null +++ b/mobile/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "ea121f8859e4b13e47a8f845e4586164519588bc" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: ea121f8859e4b13e47a8f845e4586164519588bc + base_revision: ea121f8859e4b13e47a8f845e4586164519588bc + - platform: android + create_revision: ea121f8859e4b13e47a8f845e4586164519588bc + base_revision: ea121f8859e4b13e47a8f845e4586164519588bc + - platform: ios + create_revision: ea121f8859e4b13e47a8f845e4586164519588bc + base_revision: ea121f8859e4b13e47a8f845e4586164519588bc + - platform: linux + create_revision: ea121f8859e4b13e47a8f845e4586164519588bc + base_revision: ea121f8859e4b13e47a8f845e4586164519588bc + - platform: macos + create_revision: ea121f8859e4b13e47a8f845e4586164519588bc + base_revision: ea121f8859e4b13e47a8f845e4586164519588bc + - platform: web + create_revision: ea121f8859e4b13e47a8f845e4586164519588bc + base_revision: ea121f8859e4b13e47a8f845e4586164519588bc + - platform: windows + create_revision: ea121f8859e4b13e47a8f845e4586164519588bc + base_revision: ea121f8859e4b13e47a8f845e4586164519588bc + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/mobile/README.md b/mobile/README.md new file mode 100644 index 0000000..80ea61e --- /dev/null +++ b/mobile/README.md @@ -0,0 +1,16 @@ +# privacy_maps + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/mobile/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/mobile/android/.gitignore b/mobile/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/mobile/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts new file mode 100644 index 0000000..643d40d --- /dev/null +++ b/mobile/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.privacymaps.privacy_maps" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.privacymaps.privacy_maps" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/mobile/android/app/src/debug/AndroidManifest.xml b/mobile/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/mobile/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..566cab3 --- /dev/null +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/android/app/src/main/kotlin/com/privacymaps/privacy_maps/MainActivity.kt b/mobile/android/app/src/main/kotlin/com/privacymaps/privacy_maps/MainActivity.kt new file mode 100644 index 0000000..952b96f --- /dev/null +++ b/mobile/android/app/src/main/kotlin/com/privacymaps/privacy_maps/MainActivity.kt @@ -0,0 +1,5 @@ +package com.privacymaps.privacy_maps + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/mobile/android/app/src/main/res/drawable-v21/launch_background.xml b/mobile/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/mobile/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/mobile/android/app/src/main/res/drawable/launch_background.xml b/mobile/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/mobile/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/values-night/styles.xml b/mobile/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/mobile/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/mobile/android/app/src/main/res/values/styles.xml b/mobile/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/mobile/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/mobile/android/app/src/profile/AndroidManifest.xml b/mobile/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/mobile/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/mobile/android/build.gradle.kts b/mobile/android/build.gradle.kts new file mode 100644 index 0000000..89176ef --- /dev/null +++ b/mobile/android/build.gradle.kts @@ -0,0 +1,21 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/mobile/android/gradle.properties b/mobile/android/gradle.properties new file mode 100644 index 0000000..f018a61 --- /dev/null +++ b/mobile/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/mobile/android/gradle/wrapper/gradle-wrapper.properties b/mobile/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..afa1e8e --- /dev/null +++ b/mobile/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip diff --git a/mobile/android/settings.gradle.kts b/mobile/android/settings.gradle.kts new file mode 100644 index 0000000..a439442 --- /dev/null +++ b/mobile/android/settings.gradle.kts @@ -0,0 +1,25 @@ +pluginManagement { + val flutterSdkPath = run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.7.0" apply false + id("org.jetbrains.kotlin.android") version "1.8.22" apply false +} + +include(":app") diff --git a/mobile/ios/.gitignore b/mobile/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/mobile/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/mobile/ios/Flutter/AppFrameworkInfo.plist b/mobile/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..7c56964 --- /dev/null +++ b/mobile/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 12.0 + + diff --git a/mobile/ios/Flutter/Debug.xcconfig b/mobile/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..ec97fc6 --- /dev/null +++ b/mobile/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/mobile/ios/Flutter/Release.xcconfig b/mobile/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..c4855bf --- /dev/null +++ b/mobile/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/mobile/ios/Podfile b/mobile/ios/Podfile new file mode 100644 index 0000000..c9339a0 --- /dev/null +++ b/mobile/ios/Podfile @@ -0,0 +1,41 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '12.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..1bde15a --- /dev/null +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,616 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.privacymaps.privacyMaps; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.privacymaps.privacyMaps.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.privacymaps.privacyMaps.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.privacymaps.privacyMaps.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.privacymaps.privacyMaps; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.privacymaps.privacyMaps; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..15cada4 --- /dev/null +++ b/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata b/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6266644 --- /dev/null +++ b/mobile/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "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" + } +} diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..dc9ada4725e9b0ddb1deab583e5b5102493aa332 GIT binary patch literal 10932 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 literal 0 HcmV?d00001 diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..797d452e458972bab9d994556c8305db4c827017 GIT binary patch literal 406 zcmV;H0crk;P))>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c literal 0 HcmV?d00001 diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..6ed2d933e1120817fe9182483a228007b18ab6ae GIT binary patch literal 450 zcmV;z0X_bSP)iGWQ_5NJQ_~rNh*z)}eT%KUb z`7gNk0#AwF^#0T0?hIa^`~Ck;!}#m+_uT050aTR(J!bU#|IzRL%^UsMS#KsYnTF*!YeDOytlP4VhV?b} z%rz_<=#CPc)tU1MZTq~*2=8~iZ!lSa<{9b@2Jl;?IEV8)=fG217*|@)CCYgFze-x? zIFODUIA>nWKpE+bn~n7;-89sa>#DR>TSlqWk*!2hSN6D~Qb#VqbP~4Fk&m`@1$JGr zXPIdeRE&b2Thd#{MtDK$px*d3-Wx``>!oimf%|A-&-q*6KAH)e$3|6JV%HX{Hig)k suLT-RhftRq8b9;(V=235Wa|I=027H2wCDra;{X5v07*qoM6N<$f;9x^2LJ#7 literal 0 HcmV?d00001 diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..4cd7b0099ca80c806f8fe495613e8d6c69460d76 GIT binary patch literal 282 zcmV+#0p(^bcu7P-R4C8Q z&e;xxFbF_Vrezo%_kH*OKhshZ6BFpG-Y1e10`QXJKbND7AMQ&cMj60B5TNObaZxYybcN07*qoM6N<$g3m;S%K!iX literal 0 HcmV?d00001 diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..fe730945a01f64a61e2235dbe3f45b08f7729182 GIT binary patch literal 462 zcmV;<0WtoGP)-}iV`2<;=$?g5M=KQbZ{F&YRNy7Nn@%_*5{gvDM0aKI4?ESmw z{NnZg)A0R`+4?NF_RZexyVB&^^ZvN!{I28tr{Vje;QNTz`dG&Jz0~Ek&f2;*Z7>B|cg}xYpxEFY+0YrKLF;^Q+-HreN0P{&i zK~zY`?b7ECf-n?@;d<&orQ*Q7KoR%4|C>{W^h6@&01>0SKS`dn{Q}GT%Qj_{PLZ_& zs`MFI#j-(>?bvdZ!8^xTwlY{qA)T4QLbY@j(!YJ7aXJervHy6HaG_2SB`6CC{He}f zHVw(fJWApwPq!6VY7r1w-Fs)@ox~N+q|w~e;JI~C4Vf^@d>Wvj=fl`^u9x9wd9 zR%3*Q+)t%S!MU_`id^@&Y{y7-r98lZX0?YrHlfmwb?#}^1b{8g&KzmkE(L>Z&)179 zp<)v6Y}pRl100G2FL_t(o!|l{-Q-VMg#&MKg7c{O0 z2wJImOS3Gy*Z2Qifdv~JYOp;v+U)a|nLoc7hNH;I$;lzDt$}rkaFw1mYK5_0Q(Sut zvbEloxON7$+HSOgC9Z8ltuC&0OSF!-mXv5caV>#bc3@hBPX@I$58-z}(ZZE!t-aOG zpjNkbau@>yEzH(5Yj4kZiMH32XI!4~gVXNnjAvRx;Sdg^`>2DpUEwoMhTs_st8pKG z(%SHyHdU&v%f36~uERh!bd`!T2dw;z6PrOTQ7Vt*#9F2uHlUVnb#ev_o^fh}Dzmq} zWtlk35}k=?xj28uO|5>>$yXadTUE@@IPpgH`gJ~Ro4>jd1IF|(+IX>8M4Ps{PNvmI zNj4D+XgN83gPt_Gm}`Ybv{;+&yu-C(Grdiahmo~BjG-l&mWM+{e5M1sm&=xduwgM9 z`8OEh`=F3r`^E{n_;%9weN{cf2%7=VzC@cYj+lg>+3|D|_1C@{hcU(DyQG_BvBWe? zvTv``=%b1zrol#=R`JB)>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c literal 0 HcmV?d00001 diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..502f463a9bc882b461c96aadf492d1729e49e725 GIT binary patch literal 586 zcmV-Q0=4~#P)+}#`wDE{8-2Mebf5<{{PqV{TgVcv*r8?UZ3{-|G?_}T*&y;@cqf{ z{Q*~+qr%%p!1pS*_Uicl#q9lc(D`!D`LN62sNwq{oYw(Wmhk)k<@f$!$@ng~_5)Ru z0Z)trIA5^j{DIW^c+vT2%lW+2<(RtE2wR;4O@)Tm`Xr*?A(qYoM}7i5Yxw>D(&6ou zxz!_Xr~yNF+waPe00049Nkl*;a!v6h%{rlvIH#gW3s8p;bFr=l}mRqpW2h zw=OA%hdyL~z+UHOzl0eKhEr$YYOL-c-%Y<)=j?(bzDweB7{b+%_ypvm_cG{SvM=DK zhv{K@m>#Bw>2W$eUI#iU)Wdgs8Y3U+A$Gd&{+j)d)BmGKx+43U_!tik_YlN)>$7G! zhkE!s;%oku3;IwG3U^2kw?z+HM)jB{@zFhK8P#KMSytSthr+4!c(5c%+^UBn`0X*2 zy3(k600_CSZj?O$Qu%&$;|TGUJrptR(HzyIx>5E(2r{eA(<6t3e3I0B)7d6s7?Z5J zZ!rtKvA{MiEBm&KFtoifx>5P^Z=vl)95XJn()aS5%ad(s?4-=Tkis9IGu{`Fy8r+H07*qoM6N<$f20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU literal 0 HcmV?d00001 diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0ec303439225b78712f49115768196d8d76f6790 GIT binary patch literal 862 zcmV-k1EKthP)20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU literal 0 HcmV?d00001 diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..e9f5fea27c705180eb716271f41b582e76dcbd90 GIT binary patch literal 1674 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 literal 0 HcmV?d00001 diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0467bf12aa4d28f374bb26596605a46dcbb3e7c8 GIT binary patch literal 1418 zcmV;51$Fv~P)q zKfU)WzW*n(@|xWGCA9ScMt*e9`2kdxPQ&&>|-UCa7_51w+ zLUsW@ZzZSW0y$)Hp~e9%PvP|a03ks1`~K?q{u;6NC8*{AOqIUq{CL&;p56Lf$oQGq z^={4hPQv)y=I|4n+?>7Fim=dxt1 z2H+Dm+1+fh+IF>G0SjJMkQQre1x4|G*Z==(Ot&kCnUrL4I(rf(ucITwmuHf^hXiJT zkdTm&kdTm&kdTm&kdP`esgWG0BcWCVkVZ&2dUwN`cgM8QJb`Z7Z~e<&Yj2(}>Tmf` zm1{eLgw!b{bXkjWbF%dTkTZEJWyWOb##Lfw4EK2}<0d6%>AGS{po>WCOy&f$Tay_> z?NBlkpo@s-O;0V%Y_Xa-G#_O08q5LR*~F%&)}{}r&L%Sbs8AS4t7Y0NEx*{soY=0MZExqA5XHQkqi#4gW3 zqODM^iyZl;dvf)-bOXtOru(s)Uc7~BFx{w-FK;2{`VA?(g&@3z&bfLFyctOH!cVsF z7IL=fo-qBndRUm;kAdXR4e6>k-z|21AaN%ubeVrHl*<|s&Ax@W-t?LR(P-24A5=>a z*R9#QvjzF8n%@1Nw@?CG@6(%>+-0ASK~jEmCV|&a*7-GKT72W<(TbSjf)&Eme6nGE z>Gkj4Sq&2e+-G%|+NM8OOm5zVl9{Z8Dd8A5z3y8mZ=4Bv4%>as_{9cN#bm~;h>62( zdqY93Zy}v&c4n($Vv!UybR8ocs7#zbfX1IY-*w~)p}XyZ-SFC~4w>BvMVr`dFbelV{lLL0bx7@*ZZdebr3`sP;? zVImji)kG)(6Juv0lz@q`F!k1FE;CQ(D0iG$wchPbKZQELlsZ#~rt8#90Y_Xh&3U-< z{s<&cCV_1`^TD^ia9!*mQDq& zn2{r`j};V|uV%_wsP!zB?m%;FeaRe+X47K0e+KE!8C{gAWF8)lCd1u1%~|M!XNRvw zvtqy3iz0WSpWdhn6$hP8PaRBmp)q`#PCA`Vd#Tc$@f1tAcM>f_I@bC)hkI9|o(Iqv zo}Piadq!j76}004RBio<`)70k^`K1NK)q>w?p^C6J2ZC!+UppiK6&y3Kmbv&O!oYF z34$0Z;QO!JOY#!`qyGH<3Pd}Pt@q*A0V=3SVtWKRR8d8Z&@)3qLPA19LPA19LPEUC YUoZo%k(ykuW&i*H07*qoM6N<$f+CH{y8r+H literal 0 HcmV?d00001 diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard b/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/ios/Runner/Base.lproj/Main.storyboard b/mobile/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/mobile/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist new file mode 100644 index 0000000..9cbc524 --- /dev/null +++ b/mobile/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Privacy Maps + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + privacy_maps + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/mobile/ios/Runner/Runner-Bridging-Header.h b/mobile/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/mobile/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/mobile/lib/app/app.dart b/mobile/lib/app/app.dart new file mode 100644 index 0000000..bfb14c7 --- /dev/null +++ b/mobile/lib/app/app.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'router.dart'; +import 'theme.dart'; +import '../features/settings/presentation/screens/settings_screen.dart'; + +class PrivacyMapsApp extends ConsumerWidget { + const PrivacyMapsApp({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final themeMode = ref.watch(themeModeProvider); + return MaterialApp.router( + title: 'Privacy Maps', + debugShowCheckedModeBanner: false, + theme: AppTheme.dayTheme, + darkTheme: AppTheme.nightTheme, + themeMode: themeMode, + routerConfig: routerConfig, + ); + } +} diff --git a/mobile/lib/app/router.dart b/mobile/lib/app/router.dart new file mode 100644 index 0000000..6977cc4 --- /dev/null +++ b/mobile/lib/app/router.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import '../features/map/presentation/screens/map_screen.dart'; +import '../features/search/presentation/screens/search_screen.dart'; +import '../features/routing/presentation/screens/route_screen.dart'; +import '../features/settings/presentation/screens/settings_screen.dart'; +import '../features/offline/presentation/screens/offline_screen.dart'; +import '../features/places/presentation/screens/place_detail_screen.dart'; + +final routerConfig = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (context, state) => const MapScreen(), + ), + GoRoute( + path: '/search', + pageBuilder: (context, state) => CustomTransitionPage( + key: state.pageKey, + child: const SearchScreen(), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition(opacity: animation, child: child); + }, + ), + ), + GoRoute( + path: '/route', + builder: (context, state) { + final extra = state.extra as Map?; + return RouteScreen( + destinationLat: extra?['destLat'] as double?, + destinationLon: extra?['destLon'] as double?, + destinationName: extra?['destName'] as String?, + ); + }, + ), + GoRoute( + path: '/settings', + builder: (context, state) => const SettingsScreen(), + ), + GoRoute( + path: '/offline', + builder: (context, state) => const OfflineScreen(), + ), + GoRoute( + path: '/place/:osmType/:osmId', + builder: (context, state) { + final osmType = state.pathParameters['osmType']!; + final osmId = int.parse(state.pathParameters['osmId']!); + return PlaceDetailScreen(osmType: osmType, osmId: osmId); + }, + ), + ], +); diff --git a/mobile/lib/app/theme.dart b/mobile/lib/app/theme.dart new file mode 100644 index 0000000..d287b26 --- /dev/null +++ b/mobile/lib/app/theme.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; + +class AppTheme { + static const _seedColor = Color(0xFF1B6B4A); + + static ThemeData get dayTheme { + return ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: _seedColor, + brightness: Brightness.light, + ), + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + cardTheme: CardThemeData( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + bottomSheetTheme: const BottomSheetThemeData( + showDragHandle: true, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + ), + ); + } + + static ThemeData get nightTheme { + return ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: _seedColor, + brightness: Brightness.dark, + ), + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + cardTheme: CardThemeData( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + bottomSheetTheme: const BottomSheetThemeData( + showDragHandle: true, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + ), + ); + } +} diff --git a/mobile/lib/core/api/api_client.dart b/mobile/lib/core/api/api_client.dart new file mode 100644 index 0000000..7261c40 --- /dev/null +++ b/mobile/lib/core/api/api_client.dart @@ -0,0 +1,115 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../constants.dart'; + +/// Standard error format returned by the API. +class ApiError { + final String code; + final String message; + final int statusCode; + + ApiError({ + required this.code, + required this.message, + required this.statusCode, + }); + + factory ApiError.fromResponse(Response response) { + final data = response.data; + if (data is Map && data.containsKey('error')) { + final error = data['error'] as Map; + return ApiError( + code: error['code'] as String? ?? 'UNKNOWN', + message: error['message'] as String? ?? 'Unknown error', + statusCode: response.statusCode ?? 500, + ); + } + return ApiError( + code: 'UNKNOWN', + message: 'Unexpected error', + statusCode: response.statusCode ?? 500, + ); + } + + @override + String toString() => 'ApiError($code: $message)'; +} + +class ApiException implements Exception { + final ApiError error; + ApiException(this.error); + + @override + String toString() => error.toString(); +} + +class ApiClient { + late final Dio _dio; + + String _baseUrl; + + ApiClient({String? baseUrl}) + : _baseUrl = baseUrl ?? AppConstants.defaultBackendUrl { + _dio = Dio(BaseOptions( + baseUrl: _baseUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 30), + responseType: ResponseType.json, + )); + + _dio.interceptors.add(InterceptorsWrapper( + onError: (error, handler) { + if (error.response != null) { + final apiError = ApiError.fromResponse(error.response!); + handler.reject(DioException( + requestOptions: error.requestOptions, + response: error.response, + error: ApiException(apiError), + )); + } else { + handler.next(error); + } + }, + )); + } + + String get baseUrl => _baseUrl; + + void updateBaseUrl(String newUrl) { + _baseUrl = newUrl; + _dio.options.baseUrl = newUrl; + } + + /// GET request returning parsed JSON. + Future get( + String path, { + Map? queryParameters, + }) async { + final response = await _dio.get(path, queryParameters: queryParameters); + return response.data; + } + + /// GET request returning raw Response (for downloads with progress). + Future getRaw( + String path, { + Map? queryParameters, + ResponseType? responseType, + void Function(int, int)? onReceiveProgress, + Options? options, + }) async { + return _dio.get( + path, + queryParameters: queryParameters, + options: options ?? Options(responseType: responseType), + onReceiveProgress: onReceiveProgress, + ); + } + + /// The underlying Dio instance, for advanced use cases. + Dio get dio => _dio; +} + +/// Riverpod provider for the API client. +final apiClientProvider = Provider((ref) { + return ApiClient(); +}); diff --git a/mobile/lib/core/constants.dart b/mobile/lib/core/constants.dart new file mode 100644 index 0000000..3ee3ab2 --- /dev/null +++ b/mobile/lib/core/constants.dart @@ -0,0 +1,30 @@ +import 'package:latlong2/latlong.dart'; + +class AppConstants { + AppConstants._(); + + /// Default backend URL when none is configured. + static const String defaultBackendUrl = 'http://localhost:8080'; + + /// Default map center: Amsterdam. + static const double defaultLat = 52.3676; + static const double defaultLon = 4.9041; + static final LatLng defaultCenter = LatLng(defaultLat, defaultLon); + + /// Zoom levels. + static const double minZoom = 0; + static const double maxZoom = 18; + static const double defaultZoom = 13; + static const double poiZoom = 16; + static const double cityZoom = 12; + + /// Search debounce duration in milliseconds. + static const int searchDebounceMs = 300; + + /// Maximum search history entries. + static const int maxSearchHistory = 50; + + /// Settings keys. + static const String settingBackendUrl = 'backend_url'; + static const String settingThemeMode = 'theme_mode'; +} diff --git a/mobile/lib/core/database/app_database.dart b/mobile/lib/core/database/app_database.dart new file mode 100644 index 0000000..9cd0d6f --- /dev/null +++ b/mobile/lib/core/database/app_database.dart @@ -0,0 +1,173 @@ +import 'dart:io'; + +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as p; + +import 'tables.dart'; + +part 'app_database.g.dart'; + +@DriftDatabase(tables: [SearchHistory, Favorites, OfflineRegions, Settings]) +class AppDatabase extends _$AppDatabase { + AppDatabase() : super(_openConnection()); + + AppDatabase.forTesting(super.e); + + @override + int get schemaVersion => 1; + + // --------------------------------------------------------------------------- + // Search History DAO methods + // --------------------------------------------------------------------------- + + /// Returns the most recent 50 search history items, newest first. + Future> getRecentSearches() { + return (select(searchHistory) + ..orderBy([(t) => OrderingTerm.desc(t.timestamp)]) + ..limit(50)) + .get(); + } + + /// Watches search history as a reactive stream. + Stream> watchRecentSearches() { + return (select(searchHistory) + ..orderBy([(t) => OrderingTerm.desc(t.timestamp)]) + ..limit(50)) + .watch(); + } + + /// Inserts a new search entry. Evicts entries beyond the 50-item limit. + Future addSearch(String query, {double? lat, double? lon}) async { + await into(searchHistory).insert(SearchHistoryCompanion.insert( + query: query, + latitude: Value(lat), + longitude: Value(lon), + timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + )); + await customStatement(''' + DELETE FROM search_history + WHERE id NOT IN ( + SELECT id FROM search_history ORDER BY timestamp DESC LIMIT 50 + ) + '''); + } + + /// Deletes a single history entry by ID. + Future deleteSearchEntry(int id) { + return (delete(searchHistory)..where((t) => t.id.equals(id))).go(); + } + + /// Deletes all search history. + Future clearSearchHistory() { + return delete(searchHistory).go(); + } + + // --------------------------------------------------------------------------- + // Favorites DAO methods + // --------------------------------------------------------------------------- + + /// Watches all favorites ordered by group then name. + Stream> watchAllFavorites() { + return (select(favorites) + ..orderBy([ + (t) => OrderingTerm.asc(t.groupName), + (t) => OrderingTerm.asc(t.name), + ])) + .watch(); + } + + /// Returns all favorites. + Future> getAllFavorites() { + return (select(favorites) + ..orderBy([ + (t) => OrderingTerm.asc(t.groupName), + (t) => OrderingTerm.asc(t.name), + ])) + .get(); + } + + /// Inserts a new favorite. + Future addFavorite(FavoritesCompanion entry) { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + return into(favorites).insert(entry.copyWith( + createdAt: Value(now), + updatedAt: Value(now), + )); + } + + /// Deletes a favorite by ID. + Future deleteFavorite(int id) { + return (delete(favorites)..where((t) => t.id.equals(id))).go(); + } + + /// Checks if a place is favorited by osmType and osmId. + Future findFavoriteByOsm(String osmType, int osmId) { + return (select(favorites) + ..where( + (t) => t.osmType.equals(osmType) & t.osmId.equals(osmId))) + .getSingleOrNull(); + } + + // --------------------------------------------------------------------------- + // Offline Regions DAO methods + // --------------------------------------------------------------------------- + + Stream> watchOfflineRegions() { + return (select(offlineRegions) + ..orderBy([(t) => OrderingTerm.asc(t.name)])) + .watch(); + } + + Future getOfflineRegionById(String regionId) { + return (select(offlineRegions)..where((t) => t.id.equals(regionId))) + .getSingleOrNull(); + } + + Future upsertOfflineRegion(OfflineRegionsCompanion entry) { + return into(offlineRegions).insertOnConflictUpdate(entry); + } + + Future deleteOfflineRegion(String regionId) { + return (delete(offlineRegions)..where((t) => t.id.equals(regionId))).go(); + } + + // --------------------------------------------------------------------------- + // Settings DAO methods + // --------------------------------------------------------------------------- + + Future getSetting(String key) async { + final row = await (select(settings)..where((t) => t.key.equals(key))) + .getSingleOrNull(); + return row?.value; + } + + Future setSetting(String key, String value) { + return into(settings).insertOnConflictUpdate( + SettingsCompanion.insert(key: key, value: value), + ); + } + + Stream watchSetting(String key) { + return (select(settings)..where((t) => t.key.equals(key))) + .watchSingleOrNull() + .map((row) => row?.value); + } +} + +LazyDatabase _openConnection() { + return LazyDatabase(() async { + final dbFolder = await getApplicationDocumentsDirectory(); + final file = File(p.join(dbFolder.path, 'app.db')); + return NativeDatabase.createInBackground(file); + }); +} + +/// Riverpod provider for the database. +final appDatabaseProvider = Provider((ref) { + final db = AppDatabase(); + ref.onDispose(() => db.close()); + return db; +}); diff --git a/mobile/lib/core/database/app_database.g.dart b/mobile/lib/core/database/app_database.g.dart new file mode 100644 index 0000000..fb464e8 --- /dev/null +++ b/mobile/lib/core/database/app_database.g.dart @@ -0,0 +1,2936 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'app_database.dart'; + +// ignore_for_file: type=lint +class $SearchHistoryTable extends SearchHistory + with TableInfo<$SearchHistoryTable, SearchHistoryData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $SearchHistoryTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _queryMeta = const VerificationMeta('query'); + @override + late final GeneratedColumn query = GeneratedColumn( + 'query', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _latitudeMeta = const VerificationMeta( + 'latitude', + ); + @override + late final GeneratedColumn latitude = GeneratedColumn( + 'latitude', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + static const VerificationMeta _longitudeMeta = const VerificationMeta( + 'longitude', + ); + @override + late final GeneratedColumn longitude = GeneratedColumn( + 'longitude', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + static const VerificationMeta _timestampMeta = const VerificationMeta( + 'timestamp', + ); + @override + late final GeneratedColumn timestamp = GeneratedColumn( + 'timestamp', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + query, + latitude, + longitude, + timestamp, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'search_history'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('query')) { + context.handle( + _queryMeta, + query.isAcceptableOrUnknown(data['query']!, _queryMeta), + ); + } else if (isInserting) { + context.missing(_queryMeta); + } + if (data.containsKey('latitude')) { + context.handle( + _latitudeMeta, + latitude.isAcceptableOrUnknown(data['latitude']!, _latitudeMeta), + ); + } + if (data.containsKey('longitude')) { + context.handle( + _longitudeMeta, + longitude.isAcceptableOrUnknown(data['longitude']!, _longitudeMeta), + ); + } + if (data.containsKey('timestamp')) { + context.handle( + _timestampMeta, + timestamp.isAcceptableOrUnknown(data['timestamp']!, _timestampMeta), + ); + } else if (isInserting) { + context.missing(_timestampMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + SearchHistoryData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return SearchHistoryData( + id: + attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + query: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}query'], + )!, + latitude: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}latitude'], + ), + longitude: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}longitude'], + ), + timestamp: + attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}timestamp'], + )!, + ); + } + + @override + $SearchHistoryTable createAlias(String alias) { + return $SearchHistoryTable(attachedDatabase, alias); + } +} + +class SearchHistoryData extends DataClass + implements Insertable { + final int id; + final String query; + final double? latitude; + final double? longitude; + final int timestamp; + const SearchHistoryData({ + required this.id, + required this.query, + this.latitude, + this.longitude, + required this.timestamp, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['query'] = Variable(query); + if (!nullToAbsent || latitude != null) { + map['latitude'] = Variable(latitude); + } + if (!nullToAbsent || longitude != null) { + map['longitude'] = Variable(longitude); + } + map['timestamp'] = Variable(timestamp); + return map; + } + + SearchHistoryCompanion toCompanion(bool nullToAbsent) { + return SearchHistoryCompanion( + id: Value(id), + query: Value(query), + latitude: + latitude == null && nullToAbsent + ? const Value.absent() + : Value(latitude), + longitude: + longitude == null && nullToAbsent + ? const Value.absent() + : Value(longitude), + timestamp: Value(timestamp), + ); + } + + factory SearchHistoryData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return SearchHistoryData( + id: serializer.fromJson(json['id']), + query: serializer.fromJson(json['query']), + latitude: serializer.fromJson(json['latitude']), + longitude: serializer.fromJson(json['longitude']), + timestamp: serializer.fromJson(json['timestamp']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'query': serializer.toJson(query), + 'latitude': serializer.toJson(latitude), + 'longitude': serializer.toJson(longitude), + 'timestamp': serializer.toJson(timestamp), + }; + } + + SearchHistoryData copyWith({ + int? id, + String? query, + Value latitude = const Value.absent(), + Value longitude = const Value.absent(), + int? timestamp, + }) => SearchHistoryData( + id: id ?? this.id, + query: query ?? this.query, + latitude: latitude.present ? latitude.value : this.latitude, + longitude: longitude.present ? longitude.value : this.longitude, + timestamp: timestamp ?? this.timestamp, + ); + SearchHistoryData copyWithCompanion(SearchHistoryCompanion data) { + return SearchHistoryData( + id: data.id.present ? data.id.value : this.id, + query: data.query.present ? data.query.value : this.query, + latitude: data.latitude.present ? data.latitude.value : this.latitude, + longitude: data.longitude.present ? data.longitude.value : this.longitude, + timestamp: data.timestamp.present ? data.timestamp.value : this.timestamp, + ); + } + + @override + String toString() { + return (StringBuffer('SearchHistoryData(') + ..write('id: $id, ') + ..write('query: $query, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude, ') + ..write('timestamp: $timestamp') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, query, latitude, longitude, timestamp); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is SearchHistoryData && + other.id == this.id && + other.query == this.query && + other.latitude == this.latitude && + other.longitude == this.longitude && + other.timestamp == this.timestamp); +} + +class SearchHistoryCompanion extends UpdateCompanion { + final Value id; + final Value query; + final Value latitude; + final Value longitude; + final Value timestamp; + const SearchHistoryCompanion({ + this.id = const Value.absent(), + this.query = const Value.absent(), + this.latitude = const Value.absent(), + this.longitude = const Value.absent(), + this.timestamp = const Value.absent(), + }); + SearchHistoryCompanion.insert({ + this.id = const Value.absent(), + required String query, + this.latitude = const Value.absent(), + this.longitude = const Value.absent(), + required int timestamp, + }) : query = Value(query), + timestamp = Value(timestamp); + static Insertable custom({ + Expression? id, + Expression? query, + Expression? latitude, + Expression? longitude, + Expression? timestamp, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (query != null) 'query': query, + if (latitude != null) 'latitude': latitude, + if (longitude != null) 'longitude': longitude, + if (timestamp != null) 'timestamp': timestamp, + }); + } + + SearchHistoryCompanion copyWith({ + Value? id, + Value? query, + Value? latitude, + Value? longitude, + Value? timestamp, + }) { + return SearchHistoryCompanion( + id: id ?? this.id, + query: query ?? this.query, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + timestamp: timestamp ?? this.timestamp, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (query.present) { + map['query'] = Variable(query.value); + } + if (latitude.present) { + map['latitude'] = Variable(latitude.value); + } + if (longitude.present) { + map['longitude'] = Variable(longitude.value); + } + if (timestamp.present) { + map['timestamp'] = Variable(timestamp.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('SearchHistoryCompanion(') + ..write('id: $id, ') + ..write('query: $query, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude, ') + ..write('timestamp: $timestamp') + ..write(')')) + .toString(); + } +} + +class $FavoritesTable extends Favorites + with TableInfo<$FavoritesTable, Favorite> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $FavoritesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _osmIdMeta = const VerificationMeta('osmId'); + @override + late final GeneratedColumn osmId = GeneratedColumn( + 'osm_id', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _osmTypeMeta = const VerificationMeta( + 'osmType', + ); + @override + late final GeneratedColumn osmType = GeneratedColumn( + 'osm_type', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _nameMeta = const VerificationMeta('name'); + @override + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _noteMeta = const VerificationMeta('note'); + @override + late final GeneratedColumn note = GeneratedColumn( + 'note', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _groupNameMeta = const VerificationMeta( + 'groupName', + ); + @override + late final GeneratedColumn groupName = GeneratedColumn( + 'group_name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('Favorites'), + ); + static const VerificationMeta _latitudeMeta = const VerificationMeta( + 'latitude', + ); + @override + late final GeneratedColumn latitude = GeneratedColumn( + 'latitude', + aliasedName, + false, + type: DriftSqlType.double, + requiredDuringInsert: true, + ); + static const VerificationMeta _longitudeMeta = const VerificationMeta( + 'longitude', + ); + @override + late final GeneratedColumn longitude = GeneratedColumn( + 'longitude', + aliasedName, + false, + type: DriftSqlType.double, + requiredDuringInsert: true, + ); + static const VerificationMeta _addressJsonMeta = const VerificationMeta( + 'addressJson', + ); + @override + late final GeneratedColumn addressJson = GeneratedColumn( + 'address_json', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _updatedAtMeta = const VerificationMeta( + 'updatedAt', + ); + @override + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + osmId, + osmType, + name, + note, + groupName, + latitude, + longitude, + addressJson, + createdAt, + updatedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'favorites'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('osm_id')) { + context.handle( + _osmIdMeta, + osmId.isAcceptableOrUnknown(data['osm_id']!, _osmIdMeta), + ); + } + if (data.containsKey('osm_type')) { + context.handle( + _osmTypeMeta, + osmType.isAcceptableOrUnknown(data['osm_type']!, _osmTypeMeta), + ); + } + if (data.containsKey('name')) { + context.handle( + _nameMeta, + name.isAcceptableOrUnknown(data['name']!, _nameMeta), + ); + } else if (isInserting) { + context.missing(_nameMeta); + } + if (data.containsKey('note')) { + context.handle( + _noteMeta, + note.isAcceptableOrUnknown(data['note']!, _noteMeta), + ); + } + if (data.containsKey('group_name')) { + context.handle( + _groupNameMeta, + groupName.isAcceptableOrUnknown(data['group_name']!, _groupNameMeta), + ); + } + if (data.containsKey('latitude')) { + context.handle( + _latitudeMeta, + latitude.isAcceptableOrUnknown(data['latitude']!, _latitudeMeta), + ); + } else if (isInserting) { + context.missing(_latitudeMeta); + } + if (data.containsKey('longitude')) { + context.handle( + _longitudeMeta, + longitude.isAcceptableOrUnknown(data['longitude']!, _longitudeMeta), + ); + } else if (isInserting) { + context.missing(_longitudeMeta); + } + if (data.containsKey('address_json')) { + context.handle( + _addressJsonMeta, + addressJson.isAcceptableOrUnknown( + data['address_json']!, + _addressJsonMeta, + ), + ); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + if (data.containsKey('updated_at')) { + context.handle( + _updatedAtMeta, + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta), + ); + } else if (isInserting) { + context.missing(_updatedAtMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + Favorite map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return Favorite( + id: + attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + osmId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}osm_id'], + ), + osmType: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}osm_type'], + ), + name: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + note: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}note'], + ), + groupName: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}group_name'], + )!, + latitude: + attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}latitude'], + )!, + longitude: + attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}longitude'], + )!, + addressJson: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}address_json'], + ), + createdAt: + attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}created_at'], + )!, + updatedAt: + attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}updated_at'], + )!, + ); + } + + @override + $FavoritesTable createAlias(String alias) { + return $FavoritesTable(attachedDatabase, alias); + } +} + +class Favorite extends DataClass implements Insertable { + final int id; + final int? osmId; + final String? osmType; + final String name; + final String? note; + final String groupName; + final double latitude; + final double longitude; + final String? addressJson; + final int createdAt; + final int updatedAt; + const Favorite({ + required this.id, + this.osmId, + this.osmType, + required this.name, + this.note, + required this.groupName, + required this.latitude, + required this.longitude, + this.addressJson, + required this.createdAt, + required this.updatedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + if (!nullToAbsent || osmId != null) { + map['osm_id'] = Variable(osmId); + } + if (!nullToAbsent || osmType != null) { + map['osm_type'] = Variable(osmType); + } + map['name'] = Variable(name); + if (!nullToAbsent || note != null) { + map['note'] = Variable(note); + } + map['group_name'] = Variable(groupName); + map['latitude'] = Variable(latitude); + map['longitude'] = Variable(longitude); + if (!nullToAbsent || addressJson != null) { + map['address_json'] = Variable(addressJson); + } + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + return map; + } + + FavoritesCompanion toCompanion(bool nullToAbsent) { + return FavoritesCompanion( + id: Value(id), + osmId: + osmId == null && nullToAbsent ? const Value.absent() : Value(osmId), + osmType: + osmType == null && nullToAbsent + ? const Value.absent() + : Value(osmType), + name: Value(name), + note: note == null && nullToAbsent ? const Value.absent() : Value(note), + groupName: Value(groupName), + latitude: Value(latitude), + longitude: Value(longitude), + addressJson: + addressJson == null && nullToAbsent + ? const Value.absent() + : Value(addressJson), + createdAt: Value(createdAt), + updatedAt: Value(updatedAt), + ); + } + + factory Favorite.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return Favorite( + id: serializer.fromJson(json['id']), + osmId: serializer.fromJson(json['osmId']), + osmType: serializer.fromJson(json['osmType']), + name: serializer.fromJson(json['name']), + note: serializer.fromJson(json['note']), + groupName: serializer.fromJson(json['groupName']), + latitude: serializer.fromJson(json['latitude']), + longitude: serializer.fromJson(json['longitude']), + addressJson: serializer.fromJson(json['addressJson']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'osmId': serializer.toJson(osmId), + 'osmType': serializer.toJson(osmType), + 'name': serializer.toJson(name), + 'note': serializer.toJson(note), + 'groupName': serializer.toJson(groupName), + 'latitude': serializer.toJson(latitude), + 'longitude': serializer.toJson(longitude), + 'addressJson': serializer.toJson(addressJson), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + }; + } + + Favorite copyWith({ + int? id, + Value osmId = const Value.absent(), + Value osmType = const Value.absent(), + String? name, + Value note = const Value.absent(), + String? groupName, + double? latitude, + double? longitude, + Value addressJson = const Value.absent(), + int? createdAt, + int? updatedAt, + }) => Favorite( + id: id ?? this.id, + osmId: osmId.present ? osmId.value : this.osmId, + osmType: osmType.present ? osmType.value : this.osmType, + name: name ?? this.name, + note: note.present ? note.value : this.note, + groupName: groupName ?? this.groupName, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + addressJson: addressJson.present ? addressJson.value : this.addressJson, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + Favorite copyWithCompanion(FavoritesCompanion data) { + return Favorite( + id: data.id.present ? data.id.value : this.id, + osmId: data.osmId.present ? data.osmId.value : this.osmId, + osmType: data.osmType.present ? data.osmType.value : this.osmType, + name: data.name.present ? data.name.value : this.name, + note: data.note.present ? data.note.value : this.note, + groupName: data.groupName.present ? data.groupName.value : this.groupName, + latitude: data.latitude.present ? data.latitude.value : this.latitude, + longitude: data.longitude.present ? data.longitude.value : this.longitude, + addressJson: + data.addressJson.present ? data.addressJson.value : this.addressJson, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ); + } + + @override + String toString() { + return (StringBuffer('Favorite(') + ..write('id: $id, ') + ..write('osmId: $osmId, ') + ..write('osmType: $osmType, ') + ..write('name: $name, ') + ..write('note: $note, ') + ..write('groupName: $groupName, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude, ') + ..write('addressJson: $addressJson, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + osmId, + osmType, + name, + note, + groupName, + latitude, + longitude, + addressJson, + createdAt, + updatedAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Favorite && + other.id == this.id && + other.osmId == this.osmId && + other.osmType == this.osmType && + other.name == this.name && + other.note == this.note && + other.groupName == this.groupName && + other.latitude == this.latitude && + other.longitude == this.longitude && + other.addressJson == this.addressJson && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt); +} + +class FavoritesCompanion extends UpdateCompanion { + final Value id; + final Value osmId; + final Value osmType; + final Value name; + final Value note; + final Value groupName; + final Value latitude; + final Value longitude; + final Value addressJson; + final Value createdAt; + final Value updatedAt; + const FavoritesCompanion({ + this.id = const Value.absent(), + this.osmId = const Value.absent(), + this.osmType = const Value.absent(), + this.name = const Value.absent(), + this.note = const Value.absent(), + this.groupName = const Value.absent(), + this.latitude = const Value.absent(), + this.longitude = const Value.absent(), + this.addressJson = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + }); + FavoritesCompanion.insert({ + this.id = const Value.absent(), + this.osmId = const Value.absent(), + this.osmType = const Value.absent(), + required String name, + this.note = const Value.absent(), + this.groupName = const Value.absent(), + required double latitude, + required double longitude, + this.addressJson = const Value.absent(), + required int createdAt, + required int updatedAt, + }) : name = Value(name), + latitude = Value(latitude), + longitude = Value(longitude), + createdAt = Value(createdAt), + updatedAt = Value(updatedAt); + static Insertable custom({ + Expression? id, + Expression? osmId, + Expression? osmType, + Expression? name, + Expression? note, + Expression? groupName, + Expression? latitude, + Expression? longitude, + Expression? addressJson, + Expression? createdAt, + Expression? updatedAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (osmId != null) 'osm_id': osmId, + if (osmType != null) 'osm_type': osmType, + if (name != null) 'name': name, + if (note != null) 'note': note, + if (groupName != null) 'group_name': groupName, + if (latitude != null) 'latitude': latitude, + if (longitude != null) 'longitude': longitude, + if (addressJson != null) 'address_json': addressJson, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + }); + } + + FavoritesCompanion copyWith({ + Value? id, + Value? osmId, + Value? osmType, + Value? name, + Value? note, + Value? groupName, + Value? latitude, + Value? longitude, + Value? addressJson, + Value? createdAt, + Value? updatedAt, + }) { + return FavoritesCompanion( + id: id ?? this.id, + osmId: osmId ?? this.osmId, + osmType: osmType ?? this.osmType, + name: name ?? this.name, + note: note ?? this.note, + groupName: groupName ?? this.groupName, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + addressJson: addressJson ?? this.addressJson, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (osmId.present) { + map['osm_id'] = Variable(osmId.value); + } + if (osmType.present) { + map['osm_type'] = Variable(osmType.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (note.present) { + map['note'] = Variable(note.value); + } + if (groupName.present) { + map['group_name'] = Variable(groupName.value); + } + if (latitude.present) { + map['latitude'] = Variable(latitude.value); + } + if (longitude.present) { + map['longitude'] = Variable(longitude.value); + } + if (addressJson.present) { + map['address_json'] = Variable(addressJson.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('FavoritesCompanion(') + ..write('id: $id, ') + ..write('osmId: $osmId, ') + ..write('osmType: $osmType, ') + ..write('name: $name, ') + ..write('note: $note, ') + ..write('groupName: $groupName, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude, ') + ..write('addressJson: $addressJson, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt') + ..write(')')) + .toString(); + } +} + +class $OfflineRegionsTable extends OfflineRegions + with TableInfo<$OfflineRegionsTable, OfflineRegion> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $OfflineRegionsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _nameMeta = const VerificationMeta('name'); + @override + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _minLatMeta = const VerificationMeta('minLat'); + @override + late final GeneratedColumn minLat = GeneratedColumn( + 'min_lat', + aliasedName, + false, + type: DriftSqlType.double, + requiredDuringInsert: true, + ); + static const VerificationMeta _minLonMeta = const VerificationMeta('minLon'); + @override + late final GeneratedColumn minLon = GeneratedColumn( + 'min_lon', + aliasedName, + false, + type: DriftSqlType.double, + requiredDuringInsert: true, + ); + static const VerificationMeta _maxLatMeta = const VerificationMeta('maxLat'); + @override + late final GeneratedColumn maxLat = GeneratedColumn( + 'max_lat', + aliasedName, + false, + type: DriftSqlType.double, + requiredDuringInsert: true, + ); + static const VerificationMeta _maxLonMeta = const VerificationMeta('maxLon'); + @override + late final GeneratedColumn maxLon = GeneratedColumn( + 'max_lon', + aliasedName, + false, + type: DriftSqlType.double, + requiredDuringInsert: true, + ); + static const VerificationMeta _tilesSizeBytesMeta = const VerificationMeta( + 'tilesSizeBytes', + ); + @override + late final GeneratedColumn tilesSizeBytes = GeneratedColumn( + 'tiles_size_bytes', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _routingSizeBytesMeta = const VerificationMeta( + 'routingSizeBytes', + ); + @override + late final GeneratedColumn routingSizeBytes = GeneratedColumn( + 'routing_size_bytes', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _poisSizeBytesMeta = const VerificationMeta( + 'poisSizeBytes', + ); + @override + late final GeneratedColumn poisSizeBytes = GeneratedColumn( + 'pois_size_bytes', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _downloadedAtMeta = const VerificationMeta( + 'downloadedAt', + ); + @override + late final GeneratedColumn downloadedAt = GeneratedColumn( + 'downloaded_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _lastUpdatedMeta = const VerificationMeta( + 'lastUpdated', + ); + @override + late final GeneratedColumn lastUpdated = GeneratedColumn( + 'last_updated', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + name, + minLat, + minLon, + maxLat, + maxLon, + tilesSizeBytes, + routingSizeBytes, + poisSizeBytes, + downloadedAt, + lastUpdated, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'offline_regions'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('name')) { + context.handle( + _nameMeta, + name.isAcceptableOrUnknown(data['name']!, _nameMeta), + ); + } else if (isInserting) { + context.missing(_nameMeta); + } + if (data.containsKey('min_lat')) { + context.handle( + _minLatMeta, + minLat.isAcceptableOrUnknown(data['min_lat']!, _minLatMeta), + ); + } else if (isInserting) { + context.missing(_minLatMeta); + } + if (data.containsKey('min_lon')) { + context.handle( + _minLonMeta, + minLon.isAcceptableOrUnknown(data['min_lon']!, _minLonMeta), + ); + } else if (isInserting) { + context.missing(_minLonMeta); + } + if (data.containsKey('max_lat')) { + context.handle( + _maxLatMeta, + maxLat.isAcceptableOrUnknown(data['max_lat']!, _maxLatMeta), + ); + } else if (isInserting) { + context.missing(_maxLatMeta); + } + if (data.containsKey('max_lon')) { + context.handle( + _maxLonMeta, + maxLon.isAcceptableOrUnknown(data['max_lon']!, _maxLonMeta), + ); + } else if (isInserting) { + context.missing(_maxLonMeta); + } + if (data.containsKey('tiles_size_bytes')) { + context.handle( + _tilesSizeBytesMeta, + tilesSizeBytes.isAcceptableOrUnknown( + data['tiles_size_bytes']!, + _tilesSizeBytesMeta, + ), + ); + } else if (isInserting) { + context.missing(_tilesSizeBytesMeta); + } + if (data.containsKey('routing_size_bytes')) { + context.handle( + _routingSizeBytesMeta, + routingSizeBytes.isAcceptableOrUnknown( + data['routing_size_bytes']!, + _routingSizeBytesMeta, + ), + ); + } else if (isInserting) { + context.missing(_routingSizeBytesMeta); + } + if (data.containsKey('pois_size_bytes')) { + context.handle( + _poisSizeBytesMeta, + poisSizeBytes.isAcceptableOrUnknown( + data['pois_size_bytes']!, + _poisSizeBytesMeta, + ), + ); + } else if (isInserting) { + context.missing(_poisSizeBytesMeta); + } + if (data.containsKey('downloaded_at')) { + context.handle( + _downloadedAtMeta, + downloadedAt.isAcceptableOrUnknown( + data['downloaded_at']!, + _downloadedAtMeta, + ), + ); + } else if (isInserting) { + context.missing(_downloadedAtMeta); + } + if (data.containsKey('last_updated')) { + context.handle( + _lastUpdatedMeta, + lastUpdated.isAcceptableOrUnknown( + data['last_updated']!, + _lastUpdatedMeta, + ), + ); + } else if (isInserting) { + context.missing(_lastUpdatedMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + OfflineRegion map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return OfflineRegion( + id: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + name: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + minLat: + attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}min_lat'], + )!, + minLon: + attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}min_lon'], + )!, + maxLat: + attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}max_lat'], + )!, + maxLon: + attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}max_lon'], + )!, + tilesSizeBytes: + attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}tiles_size_bytes'], + )!, + routingSizeBytes: + attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}routing_size_bytes'], + )!, + poisSizeBytes: + attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}pois_size_bytes'], + )!, + downloadedAt: + attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}downloaded_at'], + )!, + lastUpdated: + attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}last_updated'], + )!, + ); + } + + @override + $OfflineRegionsTable createAlias(String alias) { + return $OfflineRegionsTable(attachedDatabase, alias); + } +} + +class OfflineRegion extends DataClass implements Insertable { + final String id; + final String name; + final double minLat; + final double minLon; + final double maxLat; + final double maxLon; + final int tilesSizeBytes; + final int routingSizeBytes; + final int poisSizeBytes; + final int downloadedAt; + final int lastUpdated; + const OfflineRegion({ + required this.id, + required this.name, + required this.minLat, + required this.minLon, + required this.maxLat, + required this.maxLon, + required this.tilesSizeBytes, + required this.routingSizeBytes, + required this.poisSizeBytes, + required this.downloadedAt, + required this.lastUpdated, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['min_lat'] = Variable(minLat); + map['min_lon'] = Variable(minLon); + map['max_lat'] = Variable(maxLat); + map['max_lon'] = Variable(maxLon); + map['tiles_size_bytes'] = Variable(tilesSizeBytes); + map['routing_size_bytes'] = Variable(routingSizeBytes); + map['pois_size_bytes'] = Variable(poisSizeBytes); + map['downloaded_at'] = Variable(downloadedAt); + map['last_updated'] = Variable(lastUpdated); + return map; + } + + OfflineRegionsCompanion toCompanion(bool nullToAbsent) { + return OfflineRegionsCompanion( + id: Value(id), + name: Value(name), + minLat: Value(minLat), + minLon: Value(minLon), + maxLat: Value(maxLat), + maxLon: Value(maxLon), + tilesSizeBytes: Value(tilesSizeBytes), + routingSizeBytes: Value(routingSizeBytes), + poisSizeBytes: Value(poisSizeBytes), + downloadedAt: Value(downloadedAt), + lastUpdated: Value(lastUpdated), + ); + } + + factory OfflineRegion.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return OfflineRegion( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + minLat: serializer.fromJson(json['minLat']), + minLon: serializer.fromJson(json['minLon']), + maxLat: serializer.fromJson(json['maxLat']), + maxLon: serializer.fromJson(json['maxLon']), + tilesSizeBytes: serializer.fromJson(json['tilesSizeBytes']), + routingSizeBytes: serializer.fromJson(json['routingSizeBytes']), + poisSizeBytes: serializer.fromJson(json['poisSizeBytes']), + downloadedAt: serializer.fromJson(json['downloadedAt']), + lastUpdated: serializer.fromJson(json['lastUpdated']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'minLat': serializer.toJson(minLat), + 'minLon': serializer.toJson(minLon), + 'maxLat': serializer.toJson(maxLat), + 'maxLon': serializer.toJson(maxLon), + 'tilesSizeBytes': serializer.toJson(tilesSizeBytes), + 'routingSizeBytes': serializer.toJson(routingSizeBytes), + 'poisSizeBytes': serializer.toJson(poisSizeBytes), + 'downloadedAt': serializer.toJson(downloadedAt), + 'lastUpdated': serializer.toJson(lastUpdated), + }; + } + + OfflineRegion copyWith({ + String? id, + String? name, + double? minLat, + double? minLon, + double? maxLat, + double? maxLon, + int? tilesSizeBytes, + int? routingSizeBytes, + int? poisSizeBytes, + int? downloadedAt, + int? lastUpdated, + }) => OfflineRegion( + id: id ?? this.id, + name: name ?? this.name, + minLat: minLat ?? this.minLat, + minLon: minLon ?? this.minLon, + maxLat: maxLat ?? this.maxLat, + maxLon: maxLon ?? this.maxLon, + tilesSizeBytes: tilesSizeBytes ?? this.tilesSizeBytes, + routingSizeBytes: routingSizeBytes ?? this.routingSizeBytes, + poisSizeBytes: poisSizeBytes ?? this.poisSizeBytes, + downloadedAt: downloadedAt ?? this.downloadedAt, + lastUpdated: lastUpdated ?? this.lastUpdated, + ); + OfflineRegion copyWithCompanion(OfflineRegionsCompanion data) { + return OfflineRegion( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + minLat: data.minLat.present ? data.minLat.value : this.minLat, + minLon: data.minLon.present ? data.minLon.value : this.minLon, + maxLat: data.maxLat.present ? data.maxLat.value : this.maxLat, + maxLon: data.maxLon.present ? data.maxLon.value : this.maxLon, + tilesSizeBytes: + data.tilesSizeBytes.present + ? data.tilesSizeBytes.value + : this.tilesSizeBytes, + routingSizeBytes: + data.routingSizeBytes.present + ? data.routingSizeBytes.value + : this.routingSizeBytes, + poisSizeBytes: + data.poisSizeBytes.present + ? data.poisSizeBytes.value + : this.poisSizeBytes, + downloadedAt: + data.downloadedAt.present + ? data.downloadedAt.value + : this.downloadedAt, + lastUpdated: + data.lastUpdated.present ? data.lastUpdated.value : this.lastUpdated, + ); + } + + @override + String toString() { + return (StringBuffer('OfflineRegion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('minLat: $minLat, ') + ..write('minLon: $minLon, ') + ..write('maxLat: $maxLat, ') + ..write('maxLon: $maxLon, ') + ..write('tilesSizeBytes: $tilesSizeBytes, ') + ..write('routingSizeBytes: $routingSizeBytes, ') + ..write('poisSizeBytes: $poisSizeBytes, ') + ..write('downloadedAt: $downloadedAt, ') + ..write('lastUpdated: $lastUpdated') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + name, + minLat, + minLon, + maxLat, + maxLon, + tilesSizeBytes, + routingSizeBytes, + poisSizeBytes, + downloadedAt, + lastUpdated, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is OfflineRegion && + other.id == this.id && + other.name == this.name && + other.minLat == this.minLat && + other.minLon == this.minLon && + other.maxLat == this.maxLat && + other.maxLon == this.maxLon && + other.tilesSizeBytes == this.tilesSizeBytes && + other.routingSizeBytes == this.routingSizeBytes && + other.poisSizeBytes == this.poisSizeBytes && + other.downloadedAt == this.downloadedAt && + other.lastUpdated == this.lastUpdated); +} + +class OfflineRegionsCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value minLat; + final Value minLon; + final Value maxLat; + final Value maxLon; + final Value tilesSizeBytes; + final Value routingSizeBytes; + final Value poisSizeBytes; + final Value downloadedAt; + final Value lastUpdated; + final Value rowid; + const OfflineRegionsCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.minLat = const Value.absent(), + this.minLon = const Value.absent(), + this.maxLat = const Value.absent(), + this.maxLon = const Value.absent(), + this.tilesSizeBytes = const Value.absent(), + this.routingSizeBytes = const Value.absent(), + this.poisSizeBytes = const Value.absent(), + this.downloadedAt = const Value.absent(), + this.lastUpdated = const Value.absent(), + this.rowid = const Value.absent(), + }); + OfflineRegionsCompanion.insert({ + required String id, + required String name, + required double minLat, + required double minLon, + required double maxLat, + required double maxLon, + required int tilesSizeBytes, + required int routingSizeBytes, + required int poisSizeBytes, + required int downloadedAt, + required int lastUpdated, + this.rowid = const Value.absent(), + }) : id = Value(id), + name = Value(name), + minLat = Value(minLat), + minLon = Value(minLon), + maxLat = Value(maxLat), + maxLon = Value(maxLon), + tilesSizeBytes = Value(tilesSizeBytes), + routingSizeBytes = Value(routingSizeBytes), + poisSizeBytes = Value(poisSizeBytes), + downloadedAt = Value(downloadedAt), + lastUpdated = Value(lastUpdated); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? minLat, + Expression? minLon, + Expression? maxLat, + Expression? maxLon, + Expression? tilesSizeBytes, + Expression? routingSizeBytes, + Expression? poisSizeBytes, + Expression? downloadedAt, + Expression? lastUpdated, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (minLat != null) 'min_lat': minLat, + if (minLon != null) 'min_lon': minLon, + if (maxLat != null) 'max_lat': maxLat, + if (maxLon != null) 'max_lon': maxLon, + if (tilesSizeBytes != null) 'tiles_size_bytes': tilesSizeBytes, + if (routingSizeBytes != null) 'routing_size_bytes': routingSizeBytes, + if (poisSizeBytes != null) 'pois_size_bytes': poisSizeBytes, + if (downloadedAt != null) 'downloaded_at': downloadedAt, + if (lastUpdated != null) 'last_updated': lastUpdated, + if (rowid != null) 'rowid': rowid, + }); + } + + OfflineRegionsCompanion copyWith({ + Value? id, + Value? name, + Value? minLat, + Value? minLon, + Value? maxLat, + Value? maxLon, + Value? tilesSizeBytes, + Value? routingSizeBytes, + Value? poisSizeBytes, + Value? downloadedAt, + Value? lastUpdated, + Value? rowid, + }) { + return OfflineRegionsCompanion( + id: id ?? this.id, + name: name ?? this.name, + minLat: minLat ?? this.minLat, + minLon: minLon ?? this.minLon, + maxLat: maxLat ?? this.maxLat, + maxLon: maxLon ?? this.maxLon, + tilesSizeBytes: tilesSizeBytes ?? this.tilesSizeBytes, + routingSizeBytes: routingSizeBytes ?? this.routingSizeBytes, + poisSizeBytes: poisSizeBytes ?? this.poisSizeBytes, + downloadedAt: downloadedAt ?? this.downloadedAt, + lastUpdated: lastUpdated ?? this.lastUpdated, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (minLat.present) { + map['min_lat'] = Variable(minLat.value); + } + if (minLon.present) { + map['min_lon'] = Variable(minLon.value); + } + if (maxLat.present) { + map['max_lat'] = Variable(maxLat.value); + } + if (maxLon.present) { + map['max_lon'] = Variable(maxLon.value); + } + if (tilesSizeBytes.present) { + map['tiles_size_bytes'] = Variable(tilesSizeBytes.value); + } + if (routingSizeBytes.present) { + map['routing_size_bytes'] = Variable(routingSizeBytes.value); + } + if (poisSizeBytes.present) { + map['pois_size_bytes'] = Variable(poisSizeBytes.value); + } + if (downloadedAt.present) { + map['downloaded_at'] = Variable(downloadedAt.value); + } + if (lastUpdated.present) { + map['last_updated'] = Variable(lastUpdated.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('OfflineRegionsCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('minLat: $minLat, ') + ..write('minLon: $minLon, ') + ..write('maxLat: $maxLat, ') + ..write('maxLon: $maxLon, ') + ..write('tilesSizeBytes: $tilesSizeBytes, ') + ..write('routingSizeBytes: $routingSizeBytes, ') + ..write('poisSizeBytes: $poisSizeBytes, ') + ..write('downloadedAt: $downloadedAt, ') + ..write('lastUpdated: $lastUpdated, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $SettingsTable extends Settings with TableInfo<$SettingsTable, Setting> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $SettingsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _keyMeta = const VerificationMeta('key'); + @override + late final GeneratedColumn key = GeneratedColumn( + 'key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _valueMeta = const VerificationMeta('value'); + @override + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + @override + List get $columns => [key, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'settings'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('key')) { + context.handle( + _keyMeta, + key.isAcceptableOrUnknown(data['key']!, _keyMeta), + ); + } else if (isInserting) { + context.missing(_keyMeta); + } + if (data.containsKey('value')) { + context.handle( + _valueMeta, + value.isAcceptableOrUnknown(data['value']!, _valueMeta), + ); + } else if (isInserting) { + context.missing(_valueMeta); + } + return context; + } + + @override + Set get $primaryKey => {key}; + @override + Setting map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return Setting( + key: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}key'], + )!, + value: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}value'], + )!, + ); + } + + @override + $SettingsTable createAlias(String alias) { + return $SettingsTable(attachedDatabase, alias); + } +} + +class Setting extends DataClass implements Insertable { + final String key; + final String value; + const Setting({required this.key, required this.value}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['key'] = Variable(key); + map['value'] = Variable(value); + return map; + } + + SettingsCompanion toCompanion(bool nullToAbsent) { + return SettingsCompanion(key: Value(key), value: Value(value)); + } + + factory Setting.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return Setting( + key: serializer.fromJson(json['key']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'key': serializer.toJson(key), + 'value': serializer.toJson(value), + }; + } + + Setting copyWith({String? key, String? value}) => + Setting(key: key ?? this.key, value: value ?? this.value); + Setting copyWithCompanion(SettingsCompanion data) { + return Setting( + key: data.key.present ? data.key.value : this.key, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('Setting(') + ..write('key: $key, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(key, value); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Setting && other.key == this.key && other.value == this.value); +} + +class SettingsCompanion extends UpdateCompanion { + final Value key; + final Value value; + final Value rowid; + const SettingsCompanion({ + this.key = const Value.absent(), + this.value = const Value.absent(), + this.rowid = const Value.absent(), + }); + SettingsCompanion.insert({ + required String key, + required String value, + this.rowid = const Value.absent(), + }) : key = Value(key), + value = Value(value); + static Insertable custom({ + Expression? key, + Expression? value, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (key != null) 'key': key, + if (value != null) 'value': value, + if (rowid != null) 'rowid': rowid, + }); + } + + SettingsCompanion copyWith({ + Value? key, + Value? value, + Value? rowid, + }) { + return SettingsCompanion( + key: key ?? this.key, + value: value ?? this.value, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (key.present) { + map['key'] = Variable(key.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('SettingsCompanion(') + ..write('key: $key, ') + ..write('value: $value, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +abstract class _$AppDatabase extends GeneratedDatabase { + _$AppDatabase(QueryExecutor e) : super(e); + $AppDatabaseManager get managers => $AppDatabaseManager(this); + late final $SearchHistoryTable searchHistory = $SearchHistoryTable(this); + late final $FavoritesTable favorites = $FavoritesTable(this); + late final $OfflineRegionsTable offlineRegions = $OfflineRegionsTable(this); + late final $SettingsTable settings = $SettingsTable(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + searchHistory, + favorites, + offlineRegions, + settings, + ]; +} + +typedef $$SearchHistoryTableCreateCompanionBuilder = + SearchHistoryCompanion Function({ + Value id, + required String query, + Value latitude, + Value longitude, + required int timestamp, + }); +typedef $$SearchHistoryTableUpdateCompanionBuilder = + SearchHistoryCompanion Function({ + Value id, + Value query, + Value latitude, + Value longitude, + Value timestamp, + }); + +class $$SearchHistoryTableFilterComposer + extends Composer<_$AppDatabase, $SearchHistoryTable> { + $$SearchHistoryTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get query => $composableBuilder( + column: $table.query, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get latitude => $composableBuilder( + column: $table.latitude, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get longitude => $composableBuilder( + column: $table.longitude, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get timestamp => $composableBuilder( + column: $table.timestamp, + builder: (column) => ColumnFilters(column), + ); +} + +class $$SearchHistoryTableOrderingComposer + extends Composer<_$AppDatabase, $SearchHistoryTable> { + $$SearchHistoryTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get query => $composableBuilder( + column: $table.query, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get latitude => $composableBuilder( + column: $table.latitude, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get longitude => $composableBuilder( + column: $table.longitude, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get timestamp => $composableBuilder( + column: $table.timestamp, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$SearchHistoryTableAnnotationComposer + extends Composer<_$AppDatabase, $SearchHistoryTable> { + $$SearchHistoryTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get query => + $composableBuilder(column: $table.query, builder: (column) => column); + + GeneratedColumn get latitude => + $composableBuilder(column: $table.latitude, builder: (column) => column); + + GeneratedColumn get longitude => + $composableBuilder(column: $table.longitude, builder: (column) => column); + + GeneratedColumn get timestamp => + $composableBuilder(column: $table.timestamp, builder: (column) => column); +} + +class $$SearchHistoryTableTableManager + extends + RootTableManager< + _$AppDatabase, + $SearchHistoryTable, + SearchHistoryData, + $$SearchHistoryTableFilterComposer, + $$SearchHistoryTableOrderingComposer, + $$SearchHistoryTableAnnotationComposer, + $$SearchHistoryTableCreateCompanionBuilder, + $$SearchHistoryTableUpdateCompanionBuilder, + ( + SearchHistoryData, + BaseReferences< + _$AppDatabase, + $SearchHistoryTable, + SearchHistoryData + >, + ), + SearchHistoryData, + PrefetchHooks Function() + > { + $$SearchHistoryTableTableManager(_$AppDatabase db, $SearchHistoryTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: + () => $$SearchHistoryTableFilterComposer($db: db, $table: table), + createOrderingComposer: + () => + $$SearchHistoryTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: + () => $$SearchHistoryTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value query = const Value.absent(), + Value latitude = const Value.absent(), + Value longitude = const Value.absent(), + Value timestamp = const Value.absent(), + }) => SearchHistoryCompanion( + id: id, + query: query, + latitude: latitude, + longitude: longitude, + timestamp: timestamp, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required String query, + Value latitude = const Value.absent(), + Value longitude = const Value.absent(), + required int timestamp, + }) => SearchHistoryCompanion.insert( + id: id, + query: query, + latitude: latitude, + longitude: longitude, + timestamp: timestamp, + ), + withReferenceMapper: + (p0) => + p0 + .map( + (e) => ( + e.readTable(table), + BaseReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$SearchHistoryTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $SearchHistoryTable, + SearchHistoryData, + $$SearchHistoryTableFilterComposer, + $$SearchHistoryTableOrderingComposer, + $$SearchHistoryTableAnnotationComposer, + $$SearchHistoryTableCreateCompanionBuilder, + $$SearchHistoryTableUpdateCompanionBuilder, + ( + SearchHistoryData, + BaseReferences<_$AppDatabase, $SearchHistoryTable, SearchHistoryData>, + ), + SearchHistoryData, + PrefetchHooks Function() + >; +typedef $$FavoritesTableCreateCompanionBuilder = + FavoritesCompanion Function({ + Value id, + Value osmId, + Value osmType, + required String name, + Value note, + Value groupName, + required double latitude, + required double longitude, + Value addressJson, + required int createdAt, + required int updatedAt, + }); +typedef $$FavoritesTableUpdateCompanionBuilder = + FavoritesCompanion Function({ + Value id, + Value osmId, + Value osmType, + Value name, + Value note, + Value groupName, + Value latitude, + Value longitude, + Value addressJson, + Value createdAt, + Value updatedAt, + }); + +class $$FavoritesTableFilterComposer + extends Composer<_$AppDatabase, $FavoritesTable> { + $$FavoritesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get osmId => $composableBuilder( + column: $table.osmId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get osmType => $composableBuilder( + column: $table.osmType, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get note => $composableBuilder( + column: $table.note, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get groupName => $composableBuilder( + column: $table.groupName, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get latitude => $composableBuilder( + column: $table.latitude, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get longitude => $composableBuilder( + column: $table.longitude, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get addressJson => $composableBuilder( + column: $table.addressJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$FavoritesTableOrderingComposer + extends Composer<_$AppDatabase, $FavoritesTable> { + $$FavoritesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get osmId => $composableBuilder( + column: $table.osmId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get osmType => $composableBuilder( + column: $table.osmType, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get note => $composableBuilder( + column: $table.note, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get groupName => $composableBuilder( + column: $table.groupName, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get latitude => $composableBuilder( + column: $table.latitude, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get longitude => $composableBuilder( + column: $table.longitude, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get addressJson => $composableBuilder( + column: $table.addressJson, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$FavoritesTableAnnotationComposer + extends Composer<_$AppDatabase, $FavoritesTable> { + $$FavoritesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get osmId => + $composableBuilder(column: $table.osmId, builder: (column) => column); + + GeneratedColumn get osmType => + $composableBuilder(column: $table.osmType, builder: (column) => column); + + GeneratedColumn get name => + $composableBuilder(column: $table.name, builder: (column) => column); + + GeneratedColumn get note => + $composableBuilder(column: $table.note, builder: (column) => column); + + GeneratedColumn get groupName => + $composableBuilder(column: $table.groupName, builder: (column) => column); + + GeneratedColumn get latitude => + $composableBuilder(column: $table.latitude, builder: (column) => column); + + GeneratedColumn get longitude => + $composableBuilder(column: $table.longitude, builder: (column) => column); + + GeneratedColumn get addressJson => $composableBuilder( + column: $table.addressJson, + builder: (column) => column, + ); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); +} + +class $$FavoritesTableTableManager + extends + RootTableManager< + _$AppDatabase, + $FavoritesTable, + Favorite, + $$FavoritesTableFilterComposer, + $$FavoritesTableOrderingComposer, + $$FavoritesTableAnnotationComposer, + $$FavoritesTableCreateCompanionBuilder, + $$FavoritesTableUpdateCompanionBuilder, + (Favorite, BaseReferences<_$AppDatabase, $FavoritesTable, Favorite>), + Favorite, + PrefetchHooks Function() + > { + $$FavoritesTableTableManager(_$AppDatabase db, $FavoritesTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: + () => $$FavoritesTableFilterComposer($db: db, $table: table), + createOrderingComposer: + () => $$FavoritesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: + () => $$FavoritesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value osmId = const Value.absent(), + Value osmType = const Value.absent(), + Value name = const Value.absent(), + Value note = const Value.absent(), + Value groupName = const Value.absent(), + Value latitude = const Value.absent(), + Value longitude = const Value.absent(), + Value addressJson = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + }) => FavoritesCompanion( + id: id, + osmId: osmId, + osmType: osmType, + name: name, + note: note, + groupName: groupName, + latitude: latitude, + longitude: longitude, + addressJson: addressJson, + createdAt: createdAt, + updatedAt: updatedAt, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + Value osmId = const Value.absent(), + Value osmType = const Value.absent(), + required String name, + Value note = const Value.absent(), + Value groupName = const Value.absent(), + required double latitude, + required double longitude, + Value addressJson = const Value.absent(), + required int createdAt, + required int updatedAt, + }) => FavoritesCompanion.insert( + id: id, + osmId: osmId, + osmType: osmType, + name: name, + note: note, + groupName: groupName, + latitude: latitude, + longitude: longitude, + addressJson: addressJson, + createdAt: createdAt, + updatedAt: updatedAt, + ), + withReferenceMapper: + (p0) => + p0 + .map( + (e) => ( + e.readTable(table), + BaseReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$FavoritesTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $FavoritesTable, + Favorite, + $$FavoritesTableFilterComposer, + $$FavoritesTableOrderingComposer, + $$FavoritesTableAnnotationComposer, + $$FavoritesTableCreateCompanionBuilder, + $$FavoritesTableUpdateCompanionBuilder, + (Favorite, BaseReferences<_$AppDatabase, $FavoritesTable, Favorite>), + Favorite, + PrefetchHooks Function() + >; +typedef $$OfflineRegionsTableCreateCompanionBuilder = + OfflineRegionsCompanion Function({ + required String id, + required String name, + required double minLat, + required double minLon, + required double maxLat, + required double maxLon, + required int tilesSizeBytes, + required int routingSizeBytes, + required int poisSizeBytes, + required int downloadedAt, + required int lastUpdated, + Value rowid, + }); +typedef $$OfflineRegionsTableUpdateCompanionBuilder = + OfflineRegionsCompanion Function({ + Value id, + Value name, + Value minLat, + Value minLon, + Value maxLat, + Value maxLon, + Value tilesSizeBytes, + Value routingSizeBytes, + Value poisSizeBytes, + Value downloadedAt, + Value lastUpdated, + Value rowid, + }); + +class $$OfflineRegionsTableFilterComposer + extends Composer<_$AppDatabase, $OfflineRegionsTable> { + $$OfflineRegionsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get minLat => $composableBuilder( + column: $table.minLat, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get minLon => $composableBuilder( + column: $table.minLon, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get maxLat => $composableBuilder( + column: $table.maxLat, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get maxLon => $composableBuilder( + column: $table.maxLon, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get tilesSizeBytes => $composableBuilder( + column: $table.tilesSizeBytes, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get routingSizeBytes => $composableBuilder( + column: $table.routingSizeBytes, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get poisSizeBytes => $composableBuilder( + column: $table.poisSizeBytes, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get downloadedAt => $composableBuilder( + column: $table.downloadedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastUpdated => $composableBuilder( + column: $table.lastUpdated, + builder: (column) => ColumnFilters(column), + ); +} + +class $$OfflineRegionsTableOrderingComposer + extends Composer<_$AppDatabase, $OfflineRegionsTable> { + $$OfflineRegionsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get minLat => $composableBuilder( + column: $table.minLat, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get minLon => $composableBuilder( + column: $table.minLon, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get maxLat => $composableBuilder( + column: $table.maxLat, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get maxLon => $composableBuilder( + column: $table.maxLon, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get tilesSizeBytes => $composableBuilder( + column: $table.tilesSizeBytes, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get routingSizeBytes => $composableBuilder( + column: $table.routingSizeBytes, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get poisSizeBytes => $composableBuilder( + column: $table.poisSizeBytes, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get downloadedAt => $composableBuilder( + column: $table.downloadedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastUpdated => $composableBuilder( + column: $table.lastUpdated, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$OfflineRegionsTableAnnotationComposer + extends Composer<_$AppDatabase, $OfflineRegionsTable> { + $$OfflineRegionsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get name => + $composableBuilder(column: $table.name, builder: (column) => column); + + GeneratedColumn get minLat => + $composableBuilder(column: $table.minLat, builder: (column) => column); + + GeneratedColumn get minLon => + $composableBuilder(column: $table.minLon, builder: (column) => column); + + GeneratedColumn get maxLat => + $composableBuilder(column: $table.maxLat, builder: (column) => column); + + GeneratedColumn get maxLon => + $composableBuilder(column: $table.maxLon, builder: (column) => column); + + GeneratedColumn get tilesSizeBytes => $composableBuilder( + column: $table.tilesSizeBytes, + builder: (column) => column, + ); + + GeneratedColumn get routingSizeBytes => $composableBuilder( + column: $table.routingSizeBytes, + builder: (column) => column, + ); + + GeneratedColumn get poisSizeBytes => $composableBuilder( + column: $table.poisSizeBytes, + builder: (column) => column, + ); + + GeneratedColumn get downloadedAt => $composableBuilder( + column: $table.downloadedAt, + builder: (column) => column, + ); + + GeneratedColumn get lastUpdated => $composableBuilder( + column: $table.lastUpdated, + builder: (column) => column, + ); +} + +class $$OfflineRegionsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $OfflineRegionsTable, + OfflineRegion, + $$OfflineRegionsTableFilterComposer, + $$OfflineRegionsTableOrderingComposer, + $$OfflineRegionsTableAnnotationComposer, + $$OfflineRegionsTableCreateCompanionBuilder, + $$OfflineRegionsTableUpdateCompanionBuilder, + ( + OfflineRegion, + BaseReferences<_$AppDatabase, $OfflineRegionsTable, OfflineRegion>, + ), + OfflineRegion, + PrefetchHooks Function() + > { + $$OfflineRegionsTableTableManager( + _$AppDatabase db, + $OfflineRegionsTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: + () => $$OfflineRegionsTableFilterComposer($db: db, $table: table), + createOrderingComposer: + () => + $$OfflineRegionsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: + () => $$OfflineRegionsTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value name = const Value.absent(), + Value minLat = const Value.absent(), + Value minLon = const Value.absent(), + Value maxLat = const Value.absent(), + Value maxLon = const Value.absent(), + Value tilesSizeBytes = const Value.absent(), + Value routingSizeBytes = const Value.absent(), + Value poisSizeBytes = const Value.absent(), + Value downloadedAt = const Value.absent(), + Value lastUpdated = const Value.absent(), + Value rowid = const Value.absent(), + }) => OfflineRegionsCompanion( + id: id, + name: name, + minLat: minLat, + minLon: minLon, + maxLat: maxLat, + maxLon: maxLon, + tilesSizeBytes: tilesSizeBytes, + routingSizeBytes: routingSizeBytes, + poisSizeBytes: poisSizeBytes, + downloadedAt: downloadedAt, + lastUpdated: lastUpdated, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String name, + required double minLat, + required double minLon, + required double maxLat, + required double maxLon, + required int tilesSizeBytes, + required int routingSizeBytes, + required int poisSizeBytes, + required int downloadedAt, + required int lastUpdated, + Value rowid = const Value.absent(), + }) => OfflineRegionsCompanion.insert( + id: id, + name: name, + minLat: minLat, + minLon: minLon, + maxLat: maxLat, + maxLon: maxLon, + tilesSizeBytes: tilesSizeBytes, + routingSizeBytes: routingSizeBytes, + poisSizeBytes: poisSizeBytes, + downloadedAt: downloadedAt, + lastUpdated: lastUpdated, + rowid: rowid, + ), + withReferenceMapper: + (p0) => + p0 + .map( + (e) => ( + e.readTable(table), + BaseReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$OfflineRegionsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $OfflineRegionsTable, + OfflineRegion, + $$OfflineRegionsTableFilterComposer, + $$OfflineRegionsTableOrderingComposer, + $$OfflineRegionsTableAnnotationComposer, + $$OfflineRegionsTableCreateCompanionBuilder, + $$OfflineRegionsTableUpdateCompanionBuilder, + ( + OfflineRegion, + BaseReferences<_$AppDatabase, $OfflineRegionsTable, OfflineRegion>, + ), + OfflineRegion, + PrefetchHooks Function() + >; +typedef $$SettingsTableCreateCompanionBuilder = + SettingsCompanion Function({ + required String key, + required String value, + Value rowid, + }); +typedef $$SettingsTableUpdateCompanionBuilder = + SettingsCompanion Function({ + Value key, + Value value, + Value rowid, + }); + +class $$SettingsTableFilterComposer + extends Composer<_$AppDatabase, $SettingsTable> { + $$SettingsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get key => $composableBuilder( + column: $table.key, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get value => $composableBuilder( + column: $table.value, + builder: (column) => ColumnFilters(column), + ); +} + +class $$SettingsTableOrderingComposer + extends Composer<_$AppDatabase, $SettingsTable> { + $$SettingsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get key => $composableBuilder( + column: $table.key, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get value => $composableBuilder( + column: $table.value, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$SettingsTableAnnotationComposer + extends Composer<_$AppDatabase, $SettingsTable> { + $$SettingsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get key => + $composableBuilder(column: $table.key, builder: (column) => column); + + GeneratedColumn get value => + $composableBuilder(column: $table.value, builder: (column) => column); +} + +class $$SettingsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $SettingsTable, + Setting, + $$SettingsTableFilterComposer, + $$SettingsTableOrderingComposer, + $$SettingsTableAnnotationComposer, + $$SettingsTableCreateCompanionBuilder, + $$SettingsTableUpdateCompanionBuilder, + (Setting, BaseReferences<_$AppDatabase, $SettingsTable, Setting>), + Setting, + PrefetchHooks Function() + > { + $$SettingsTableTableManager(_$AppDatabase db, $SettingsTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: + () => $$SettingsTableFilterComposer($db: db, $table: table), + createOrderingComposer: + () => $$SettingsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: + () => $$SettingsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value key = const Value.absent(), + Value value = const Value.absent(), + Value rowid = const Value.absent(), + }) => SettingsCompanion(key: key, value: value, rowid: rowid), + createCompanionCallback: + ({ + required String key, + required String value, + Value rowid = const Value.absent(), + }) => SettingsCompanion.insert( + key: key, + value: value, + rowid: rowid, + ), + withReferenceMapper: + (p0) => + p0 + .map( + (e) => ( + e.readTable(table), + BaseReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$SettingsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $SettingsTable, + Setting, + $$SettingsTableFilterComposer, + $$SettingsTableOrderingComposer, + $$SettingsTableAnnotationComposer, + $$SettingsTableCreateCompanionBuilder, + $$SettingsTableUpdateCompanionBuilder, + (Setting, BaseReferences<_$AppDatabase, $SettingsTable, Setting>), + Setting, + PrefetchHooks Function() + >; + +class $AppDatabaseManager { + final _$AppDatabase _db; + $AppDatabaseManager(this._db); + $$SearchHistoryTableTableManager get searchHistory => + $$SearchHistoryTableTableManager(_db, _db.searchHistory); + $$FavoritesTableTableManager get favorites => + $$FavoritesTableTableManager(_db, _db.favorites); + $$OfflineRegionsTableTableManager get offlineRegions => + $$OfflineRegionsTableTableManager(_db, _db.offlineRegions); + $$SettingsTableTableManager get settings => + $$SettingsTableTableManager(_db, _db.settings); +} diff --git a/mobile/lib/core/database/tables.dart b/mobile/lib/core/database/tables.dart new file mode 100644 index 0000000..d57e27c --- /dev/null +++ b/mobile/lib/core/database/tables.dart @@ -0,0 +1,53 @@ +import 'package:drift/drift.dart'; + +/// Search history table: stores the last 50 search queries. +class SearchHistory extends Table { + IntColumn get id => integer().autoIncrement()(); + TextColumn get query => text()(); + RealColumn get latitude => real().nullable()(); + RealColumn get longitude => real().nullable()(); + IntColumn get timestamp => integer()(); // Unix epoch seconds +} + +/// Favorites table: stores user-saved places. +class Favorites extends Table { + IntColumn get id => integer().autoIncrement()(); + IntColumn get osmId => integer().nullable()(); + TextColumn get osmType => text().nullable()(); // 'N', 'W', or 'R' + TextColumn get name => text()(); + TextColumn get note => text().nullable()(); + TextColumn get groupName => + text().withDefault(const Constant('Favorites'))(); + RealColumn get latitude => real()(); + RealColumn get longitude => real()(); + TextColumn get addressJson => text().nullable()(); // JSON-encoded address + IntColumn get createdAt => integer()(); // Unix epoch seconds + IntColumn get updatedAt => integer()(); // Unix epoch seconds +} + +/// Offline regions table: tracks downloaded offline regions on the device. +class OfflineRegions extends Table { + TextColumn get id => text()(); + TextColumn get name => text()(); + RealColumn get minLat => real()(); + RealColumn get minLon => real()(); + RealColumn get maxLat => real()(); + RealColumn get maxLon => real()(); + IntColumn get tilesSizeBytes => integer()(); + IntColumn get routingSizeBytes => integer()(); + IntColumn get poisSizeBytes => integer()(); + IntColumn get downloadedAt => integer()(); // Unix epoch seconds + IntColumn get lastUpdated => integer()(); // Unix epoch seconds (from backend) + + @override + Set get primaryKey => {id}; +} + +/// Settings table: key-value storage for user preferences. +class Settings extends Table { + TextColumn get key => text()(); + TextColumn get value => text()(); + + @override + Set get primaryKey => {key}; +} diff --git a/mobile/lib/features/map/presentation/screens/map_screen.dart b/mobile/lib/features/map/presentation/screens/map_screen.dart new file mode 100644 index 0000000..efbfddd --- /dev/null +++ b/mobile/lib/features/map/presentation/screens/map_screen.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map_cancellable_tile_provider/flutter_map_cancellable_tile_provider.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../../../core/api/api_client.dart'; +import '../../providers/map_provider.dart'; +import '../widgets/map_controls.dart'; +import '../widgets/place_card.dart'; + +class MapScreen extends ConsumerStatefulWidget { + const MapScreen({super.key}); + + @override + ConsumerState createState() => _MapScreenState(); +} + +class _MapScreenState extends ConsumerState { + late final MapController _mapController; + + @override + void initState() { + super.initState(); + _mapController = MapController(); + } + + @override + void dispose() { + _mapController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final mapState = ref.watch(mapProvider); + final apiClient = ref.watch(apiClientProvider); + final tileUrl = '${apiClient.baseUrl}/tiles/openmaptiles/{z}/{x}/{y}.pbf'; + + // Listen for zoom/center changes from the provider and move the map. + ref.listen(mapProvider, (previous, next) { + if (previous?.center != next.center || previous?.zoom != next.zoom) { + _mapController.move(next.center, next.zoom); + } + }); + + return Scaffold( + body: Stack( + children: [ + FlutterMap( + mapController: _mapController, + options: MapOptions( + initialCenter: mapState.center, + initialZoom: mapState.zoom, + minZoom: 0, + maxZoom: 18, + onPositionChanged: (position, hasGesture) { + if (hasGesture) { + ref.read(mapProvider.notifier).updateCamera( + position.center, + position.zoom, + ); + } + }, + onTap: (tapPosition, point) { + ref.read(mapProvider.notifier).clearSelectedPlace(); + }, + ), + children: [ + TileLayer( + urlTemplate: tileUrl, + tileProvider: CancellableNetworkTileProvider(), + userAgentPackageName: 'com.privacymaps.app', + ), + if (mapState.currentLocation != null) + MarkerLayer( + markers: [ + Marker( + point: mapState.currentLocation!, + width: 24, + height: 24, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 3), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + ), + ), + ], + ), + ], + ), + // Search bar at the top + Positioned( + top: MediaQuery.of(context).padding.top + 8, + left: 12, + right: 12, + child: GestureDetector( + onTap: () => context.push('/search'), + child: Card( + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon(Icons.search, + color: Theme.of(context).colorScheme.onSurfaceVariant), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Search places...', + style: + Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + ), + IconButton( + icon: const Icon(Icons.settings), + onPressed: () => context.push('/settings'), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + ), + ), + ), + ), + // Map controls + const MapControls(), + // Place card bottom sheet + const PlaceCard(), + // Offline button + Positioned( + left: 16, + bottom: 120, + child: FloatingActionButton.small( + heroTag: 'offline', + onPressed: () => context.push('/offline'), + child: const Icon(Icons.download), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/features/map/presentation/widgets/map_controls.dart b/mobile/lib/features/map/presentation/widgets/map_controls.dart new file mode 100644 index 0000000..1908a0c --- /dev/null +++ b/mobile/lib/features/map/presentation/widgets/map_controls.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../providers/map_provider.dart'; + +class MapControls extends ConsumerWidget { + const MapControls({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isLocating = ref.watch(mapProvider.select((s) => s.isLocating)); + + return Positioned( + right: 16, + bottom: 120, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FloatingActionButton.small( + heroTag: 'zoom_in', + onPressed: () => ref.read(mapProvider.notifier).zoomIn(), + child: const Icon(Icons.add), + ), + const SizedBox(height: 8), + FloatingActionButton.small( + heroTag: 'zoom_out', + onPressed: () => ref.read(mapProvider.notifier).zoomOut(), + child: const Icon(Icons.remove), + ), + const SizedBox(height: 16), + FloatingActionButton.small( + heroTag: 'locate_me', + onPressed: isLocating + ? null + : () => ref.read(mapProvider.notifier).locateUser(), + child: isLocating + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.my_location), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/features/map/presentation/widgets/place_card.dart b/mobile/lib/features/map/presentation/widgets/place_card.dart new file mode 100644 index 0000000..ca4ecdc --- /dev/null +++ b/mobile/lib/features/map/presentation/widgets/place_card.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../providers/map_provider.dart'; + +class PlaceCard extends ConsumerWidget { + const PlaceCard({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedPlace = + ref.watch(mapProvider.select((s) => s.selectedPlace)); + + if (selectedPlace == null) return const SizedBox.shrink(); + + return Positioned( + left: 0, + right: 0, + bottom: 0, + child: Card( + margin: const EdgeInsets.all(12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + selectedPlace.name, + style: Theme.of(context).textTheme.titleMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => + ref.read(mapProvider.notifier).clearSelectedPlace(), + iconSize: 20, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + if (selectedPlace.address != null) ...[ + const SizedBox(height: 4), + Text( + selectedPlace.address!, + style: Theme.of(context).textTheme.bodySmall, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + if (selectedPlace.category != null) ...[ + const SizedBox(height: 4), + Chip( + label: Text(selectedPlace.category!), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: () { + context.push('/route', extra: { + 'destLat': selectedPlace.latitude, + 'destLon': selectedPlace.longitude, + 'destName': selectedPlace.name, + }); + }, + icon: const Icon(Icons.directions), + label: const Text('Directions'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + onPressed: () { + if (selectedPlace.osmType != null && + selectedPlace.osmId != null) { + context.push( + '/place/${selectedPlace.osmType}/${selectedPlace.osmId}', + ); + } + }, + icon: const Icon(Icons.bookmark_add_outlined), + label: const Text('Save'), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/map/providers/map_provider.dart b/mobile/lib/features/map/providers/map_provider.dart new file mode 100644 index 0000000..2c423de --- /dev/null +++ b/mobile/lib/features/map/providers/map_provider.dart @@ -0,0 +1,158 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:latlong2/latlong.dart'; +import '../../../core/constants.dart'; + +/// State for the map feature. +class MapState { + final LatLng center; + final double zoom; + final LatLng? currentLocation; + final SelectedPlace? selectedPlace; + final bool isLocating; + final String? locationError; + + const MapState({ + required this.center, + required this.zoom, + this.currentLocation, + this.selectedPlace, + this.isLocating = false, + this.locationError, + }); + + MapState copyWith({ + LatLng? center, + double? zoom, + LatLng? currentLocation, + SelectedPlace? selectedPlace, + bool? isLocating, + String? locationError, + bool clearSelectedPlace = false, + bool clearLocationError = false, + }) { + return MapState( + center: center ?? this.center, + zoom: zoom ?? this.zoom, + currentLocation: currentLocation ?? this.currentLocation, + selectedPlace: + clearSelectedPlace ? null : (selectedPlace ?? this.selectedPlace), + isLocating: isLocating ?? this.isLocating, + locationError: + clearLocationError ? null : (locationError ?? this.locationError), + ); + } +} + +/// A place selected on the map. +class SelectedPlace { + final String name; + final String? address; + final String? category; + final double latitude; + final double longitude; + final int? osmId; + final String? osmType; + + const SelectedPlace({ + required this.name, + this.address, + this.category, + required this.latitude, + required this.longitude, + this.osmId, + this.osmType, + }); +} + +class MapNotifier extends StateNotifier { + MapNotifier() + : super(MapState( + center: AppConstants.defaultCenter, + zoom: AppConstants.defaultZoom, + )); + + void updateCamera(LatLng center, double zoom) { + state = state.copyWith(center: center, zoom: zoom); + } + + void selectPlace(SelectedPlace place) { + state = state.copyWith(selectedPlace: place); + } + + void clearSelectedPlace() { + state = state.copyWith(clearSelectedPlace: true); + } + + Future locateUser() async { + state = state.copyWith(isLocating: true, clearLocationError: true); + + try { + bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + state = state.copyWith( + isLocating: false, + locationError: 'Location services are disabled.', + ); + return; + } + + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + state = state.copyWith( + isLocating: false, + locationError: 'Location permission denied.', + ); + return; + } + } + + if (permission == LocationPermission.deniedForever) { + state = state.copyWith( + isLocating: false, + locationError: 'Location permission permanently denied.', + ); + return; + } + + final position = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high, + ); + + final location = LatLng(position.latitude, position.longitude); + state = state.copyWith( + currentLocation: location, + center: location, + zoom: AppConstants.poiZoom, + isLocating: false, + ); + } catch (e) { + state = state.copyWith( + isLocating: false, + locationError: 'Could not determine location.', + ); + } + } + + void zoomIn() { + final newZoom = (state.zoom + 1).clamp( + AppConstants.minZoom, + AppConstants.maxZoom, + ); + state = state.copyWith(zoom: newZoom); + } + + void zoomOut() { + final newZoom = (state.zoom - 1).clamp( + AppConstants.minZoom, + AppConstants.maxZoom, + ); + state = state.copyWith(zoom: newZoom); + } +} + +final mapProvider = StateNotifierProvider((ref) { + return MapNotifier(); +}); diff --git a/mobile/lib/features/offline/data/offline_repository.dart b/mobile/lib/features/offline/data/offline_repository.dart new file mode 100644 index 0000000..cec0a70 --- /dev/null +++ b/mobile/lib/features/offline/data/offline_repository.dart @@ -0,0 +1,168 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as p; +import '../../../core/api/api_client.dart'; +import '../../../core/database/app_database.dart'; + +/// Region metadata from the server. +class OfflineRegionInfo { + final String id; + final String name; + final String description; + final List bbox; // [minLon, minLat, maxLon, maxLat] + final int sizeMb; + final String lastUpdated; + final Map components; + + OfflineRegionInfo({ + required this.id, + required this.name, + required this.description, + required this.bbox, + required this.sizeMb, + required this.lastUpdated, + required this.components, + }); + + factory OfflineRegionInfo.fromJson(Map json) { + final comps = json['components'] as Map; + return OfflineRegionInfo( + id: json['id'] as String, + name: json['name'] as String, + description: json['description'] as String? ?? '', + bbox: (json['bbox'] as List).map((e) => (e as num).toDouble()).toList(), + sizeMb: json['size_mb'] as int, + lastUpdated: json['last_updated'] as String, + components: { + 'tiles_mb': comps['tiles_mb'] as int? ?? 0, + 'routing_driving_mb': comps['routing_driving_mb'] as int? ?? 0, + 'routing_walking_mb': comps['routing_walking_mb'] as int? ?? 0, + 'routing_cycling_mb': comps['routing_cycling_mb'] as int? ?? 0, + 'pois_mb': comps['pois_mb'] as int? ?? 0, + }, + ); + } +} + +class OfflineRepository { + final ApiClient _apiClient; + final AppDatabase _db; + + OfflineRepository(this._apiClient, this._db); + + /// Fetch available regions from GET /api/offline/regions. + Future> getAvailableRegions() async { + final data = await _apiClient.get('/api/offline/regions'); + final regions = (data['regions'] as List) + .map((r) => OfflineRegionInfo.fromJson(r as Map)) + .toList(); + return regions; + } + + /// Download a region component with progress tracking. + Future downloadComponent({ + required String regionId, + required String component, + required void Function(int received, int total) onProgress, + }) async { + final dir = await _getOfflineDir(); + final filePath = p.join(dir.path, '$regionId-$component'); + + await _apiClient.getRaw( + '/api/offline/regions/$regionId/$component', + responseType: ResponseType.bytes, + options: Options( + responseType: ResponseType.bytes, + receiveTimeout: const Duration(minutes: 30), + ), + onReceiveProgress: onProgress, + ).then((response) async { + final file = File(filePath); + await file.writeAsBytes(response.data as List); + }); + } + + /// Download all components for a region. + Future downloadRegion({ + required OfflineRegionInfo region, + required void Function(String component, int received, int total) + onProgress, + }) async { + final components = ['tiles', 'routing-driving', 'routing-walking', + 'routing-cycling', 'pois']; + + for (final component in components) { + await downloadComponent( + regionId: region.id, + component: component, + onProgress: (received, total) => + onProgress(component, received, total), + ); + } + + // Save to DB after successful download. + await _db.upsertOfflineRegion(OfflineRegionsCompanion.insert( + id: region.id, + name: region.name, + minLat: region.bbox[1], + minLon: region.bbox[0], + maxLat: region.bbox[3], + maxLon: region.bbox[2], + tilesSizeBytes: (region.components['tiles_mb'] ?? 0) * 1024 * 1024, + routingSizeBytes: + ((region.components['routing_driving_mb'] ?? 0) + + (region.components['routing_walking_mb'] ?? 0) + + (region.components['routing_cycling_mb'] ?? 0)) * + 1024 * + 1024, + poisSizeBytes: (region.components['pois_mb'] ?? 0) * 1024 * 1024, + downloadedAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + lastUpdated: DateTime.parse(region.lastUpdated).millisecondsSinceEpoch ~/ + 1000, + )); + } + + /// Delete a downloaded region (DB record + files). + Future deleteRegion(String regionId) async { + await _db.deleteOfflineRegion(regionId); + final dir = await _getOfflineDir(); + final components = ['tiles', 'routing-driving', 'routing-walking', + 'routing-cycling', 'pois']; + for (final component in components) { + final file = File(p.join(dir.path, '$regionId-$component')); + if (await file.exists()) { + await file.delete(); + } + } + } + + /// Watch downloaded regions. + Stream> watchDownloadedRegions() { + return _db.watchOfflineRegions(); + } + + /// Check if a region is downloaded. + Future isRegionDownloaded(String regionId) async { + final region = await _db.getOfflineRegionById(regionId); + return region != null; + } + + Future _getOfflineDir() async { + final appDir = await getApplicationDocumentsDirectory(); + final offlineDir = Directory(p.join(appDir.path, 'offline')); + if (!await offlineDir.exists()) { + await offlineDir.create(recursive: true); + } + return offlineDir; + } +} + +final offlineRepositoryProvider = Provider((ref) { + return OfflineRepository( + ref.watch(apiClientProvider), + ref.watch(appDatabaseProvider), + ); +}); diff --git a/mobile/lib/features/offline/presentation/screens/offline_screen.dart b/mobile/lib/features/offline/presentation/screens/offline_screen.dart new file mode 100644 index 0000000..c661646 --- /dev/null +++ b/mobile/lib/features/offline/presentation/screens/offline_screen.dart @@ -0,0 +1,202 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../providers/offline_provider.dart'; +import '../../data/offline_repository.dart'; + +class OfflineScreen extends ConsumerWidget { + const OfflineScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final offlineState = ref.watch(offlineProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('Offline Maps'), + ), + body: offlineState.isLoading + ? const Center(child: CircularProgressIndicator()) + : _buildContent(context, ref, offlineState), + ); + } + + Widget _buildContent( + BuildContext context, + WidgetRef ref, + OfflineState offlineState, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Storage info + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Text( + 'Storage used: ${offlineState.totalStorageUsedMb} MB', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + + // Error + if (offlineState.error != null) + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + color: Theme.of(context).colorScheme.errorContainer, + child: Text( + offlineState.error!, + style: TextStyle( + color: Theme.of(context).colorScheme.onErrorContainer, + ), + ), + ), + + // Region list + Expanded( + child: offlineState.availableRegions.isEmpty + ? const Center(child: Text('No regions available.')) + : ListView.builder( + itemCount: offlineState.availableRegions.length, + itemBuilder: (context, index) { + final region = offlineState.availableRegions[index]; + return _RegionTile( + region: region, + isDownloaded: offlineState.isRegionDownloaded(region.id), + isDownloading: + offlineState.isRegionDownloading(region.id), + progress: offlineState.downloadProgress[region.id], + ); + }, + ), + ), + ], + ); + } +} + +class _RegionTile extends ConsumerWidget { + final OfflineRegionInfo region; + final bool isDownloaded; + final bool isDownloading; + final DownloadProgress? progress; + + const _RegionTile({ + required this.region, + required this.isDownloaded, + required this.isDownloading, + this.progress, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + region.name, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 2), + Text( + region.description, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + Text( + '${region.sizeMb} MB', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + const SizedBox(height: 8), + + // Component size breakdown + Wrap( + spacing: 8, + runSpacing: 4, + children: [ + _SizeChip( + 'Tiles', region.components['tiles_mb'] ?? 0), + _SizeChip( + 'Drive', region.components['routing_driving_mb'] ?? 0), + _SizeChip( + 'Walk', region.components['routing_walking_mb'] ?? 0), + _SizeChip( + 'Cycle', region.components['routing_cycling_mb'] ?? 0), + _SizeChip('POIs', region.components['pois_mb'] ?? 0), + ], + ), + const SizedBox(height: 12), + + // Progress indicator + if (isDownloading && progress != null) ...[ + LinearProgressIndicator(value: progress!.fraction), + const SizedBox(height: 4), + Text( + 'Downloading ${progress!.component}...', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + + // Action buttons + if (!isDownloading) + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (isDownloaded) + FilledButton.tonalIcon( + onPressed: () => ref + .read(offlineProvider.notifier) + .deleteRegion(region.id), + icon: const Icon(Icons.delete_outline), + label: const Text('Delete'), + ) + else + FilledButton.icon( + onPressed: () => ref + .read(offlineProvider.notifier) + .downloadRegion(region), + icon: const Icon(Icons.download), + label: const Text('Download'), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _SizeChip extends StatelessWidget { + final String label; + final int sizeMb; + + const _SizeChip(this.label, this.sizeMb); + + @override + Widget build(BuildContext context) { + return Chip( + label: Text('$label: $sizeMb MB'), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + labelStyle: Theme.of(context).textTheme.labelSmall, + ); + } +} diff --git a/mobile/lib/features/offline/providers/offline_provider.dart b/mobile/lib/features/offline/providers/offline_provider.dart new file mode 100644 index 0000000..20c7e33 --- /dev/null +++ b/mobile/lib/features/offline/providers/offline_provider.dart @@ -0,0 +1,171 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/database/app_database.dart'; +import '../data/offline_repository.dart'; + +class DownloadProgress { + final String regionId; + final String component; + final int received; + final int total; + + DownloadProgress({ + required this.regionId, + required this.component, + required this.received, + required this.total, + }); + + double get fraction => total > 0 ? received / total : 0; +} + +class OfflineState { + final List availableRegions; + final List downloadedRegions; + final Map downloadProgress; // regionId -> progress + final bool isLoading; + final String? error; + + const OfflineState({ + this.availableRegions = const [], + this.downloadedRegions = const [], + this.downloadProgress = const {}, + this.isLoading = false, + this.error, + }); + + OfflineState copyWith({ + List? availableRegions, + List? downloadedRegions, + Map? downloadProgress, + bool? isLoading, + String? error, + bool clearError = false, + }) { + return OfflineState( + availableRegions: availableRegions ?? this.availableRegions, + downloadedRegions: downloadedRegions ?? this.downloadedRegions, + downloadProgress: downloadProgress ?? this.downloadProgress, + isLoading: isLoading ?? this.isLoading, + error: clearError ? null : (error ?? this.error), + ); + } + + bool isRegionDownloaded(String regionId) { + return downloadedRegions.any((r) => r.id == regionId); + } + + bool isRegionDownloading(String regionId) { + return downloadProgress.containsKey(regionId); + } + + int get totalStorageUsedMb { + int total = 0; + for (final r in downloadedRegions) { + total += r.tilesSizeBytes + r.routingSizeBytes + r.poisSizeBytes; + } + return total ~/ (1024 * 1024); + } +} + +class OfflineNotifier extends StateNotifier { + final OfflineRepository _repository; + + OfflineNotifier(this._repository) : super(const OfflineState()) { + _init(); + } + + Future _init() async { + await loadAvailableRegions(); + _repository.watchDownloadedRegions().listen((regions) { + if (mounted) { + state = state.copyWith(downloadedRegions: regions); + } + }); + } + + Future loadAvailableRegions() async { + state = state.copyWith(isLoading: true, clearError: true); + try { + final regions = await _repository.getAvailableRegions(); + if (mounted) { + state = state.copyWith( + availableRegions: regions, + isLoading: false, + ); + } + } catch (e) { + if (mounted) { + state = state.copyWith( + isLoading: false, + error: 'Could not load available regions.', + ); + } + } + } + + Future downloadRegion(OfflineRegionInfo region) async { + state = state.copyWith( + downloadProgress: { + ...state.downloadProgress, + region.id: DownloadProgress( + regionId: region.id, + component: 'starting', + received: 0, + total: region.sizeMb * 1024 * 1024, + ), + }, + ); + + try { + await _repository.downloadRegion( + region: region, + onProgress: (component, received, total) { + if (mounted) { + state = state.copyWith( + downloadProgress: { + ...state.downloadProgress, + region.id: DownloadProgress( + regionId: region.id, + component: component, + received: received, + total: total, + ), + }, + ); + } + }, + ); + if (mounted) { + final newProgress = Map.from( + state.downloadProgress); + newProgress.remove(region.id); + state = state.copyWith(downloadProgress: newProgress); + } + } catch (e) { + if (mounted) { + final newProgress = Map.from( + state.downloadProgress); + newProgress.remove(region.id); + state = state.copyWith( + downloadProgress: newProgress, + error: 'Download failed for ${region.name}.', + ); + } + } + } + + Future deleteRegion(String regionId) async { + try { + await _repository.deleteRegion(regionId); + } catch (e) { + if (mounted) { + state = state.copyWith(error: 'Could not delete region.'); + } + } + } +} + +final offlineProvider = + StateNotifierProvider((ref) { + return OfflineNotifier(ref.watch(offlineRepositoryProvider)); +}); diff --git a/mobile/lib/features/places/data/places_repository.dart b/mobile/lib/features/places/data/places_repository.dart new file mode 100644 index 0000000..b0e8add --- /dev/null +++ b/mobile/lib/features/places/data/places_repository.dart @@ -0,0 +1,164 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/api/api_client.dart'; +import '../../../core/database/app_database.dart'; + +/// A POI parsed from the API response. +class PlaceData { + final int osmId; + final String osmType; + final String name; + final String category; + final double latitude; + final double longitude; + final Map? address; + final String? openingHours; + final Map? openingHoursParsed; + final String? phone; + final String? website; + final String? wheelchair; + final Map? tags; + + PlaceData({ + required this.osmId, + required this.osmType, + required this.name, + required this.category, + required this.latitude, + required this.longitude, + this.address, + this.openingHours, + this.openingHoursParsed, + this.phone, + this.website, + this.wheelchair, + this.tags, + }); + + String get displayAddress { + if (address == null) return ''; + final parts = []; + final street = address!['street'] as String?; + final housenumber = address!['housenumber'] as String?; + if (street != null) { + parts.add(housenumber != null ? '$street $housenumber' : street); + } + final postcode = address!['postcode'] as String?; + final city = address!['city'] as String?; + if (postcode != null && city != null) { + parts.add('$postcode $city'); + } else if (city != null) { + parts.add(city); + } + return parts.join(', '); + } + + factory PlaceData.fromGeoJsonFeature(Map feature) { + final geometry = feature['geometry'] as Map; + final coords = geometry['coordinates'] as List; + final props = feature['properties'] as Map; + + return PlaceData( + osmId: props['osm_id'] as int, + osmType: props['osm_type'] as String, + name: props['name'] as String? ?? '', + category: props['category'] as String? ?? 'unknown', + longitude: (coords[0] as num).toDouble(), + latitude: (coords[1] as num).toDouble(), + address: props['address'] as Map?, + openingHours: props['opening_hours'] as String?, + openingHoursParsed: + props['opening_hours_parsed'] as Map?, + phone: props['phone'] as String?, + website: props['website'] as String?, + wheelchair: props['wheelchair'] as String?, + tags: props['tags'] as Map?, + ); + } + + /// Parse a single Feature response (GET /api/pois/{osm_type}/{osm_id}). + factory PlaceData.fromGeoJsonSingle(Map data) { + return PlaceData.fromGeoJsonFeature(data); + } +} + +class PlacesRepository { + final ApiClient _apiClient; + final AppDatabase _db; + + PlacesRepository(this._apiClient, this._db); + + /// Get POIs in a bounding box via GET /api/pois?bbox=... + Future> getPoisInBbox({ + required double minLon, + required double minLat, + required double maxLon, + required double maxLat, + String? category, + int limit = 100, + }) async { + final params = { + 'bbox': '$minLon,$minLat,$maxLon,$maxLat', + 'limit': limit, + }; + if (category != null) { + params['category'] = category; + } + + final data = await _apiClient.get('/api/pois', queryParameters: params); + final features = (data['features'] as List?) ?? []; + return features + .map((f) => PlaceData.fromGeoJsonFeature(f as Map)) + .toList(); + } + + /// Get a single POI by type and ID via GET /api/pois/{osm_type}/{osm_id}. + Future getPoiDetail(String osmType, int osmId) async { + final data = await _apiClient.get('/api/pois/$osmType/$osmId'); + return PlaceData.fromGeoJsonSingle(data as Map); + } + + // ----------------------------------------------------------------------- + // Favorites (local Drift DB) + // ----------------------------------------------------------------------- + + Stream> watchFavorites() { + return _db.watchAllFavorites(); + } + + Future> getAllFavorites() { + return _db.getAllFavorites(); + } + + Future addFavorite(PlaceData place) { + return _db.addFavorite(FavoritesCompanion.insert( + osmId: Value(place.osmId), + osmType: Value(place.osmType), + name: place.name, + latitude: place.latitude, + longitude: place.longitude, + addressJson: Value( + place.address != null ? jsonEncode(place.address) : null, + ), + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + updatedAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + )); + } + + Future removeFavorite(int id) { + return _db.deleteFavorite(id); + } + + Future isFavorited(String osmType, int osmId) { + return _db.findFavoriteByOsm(osmType, osmId); + } +} + +final placesRepositoryProvider = Provider((ref) { + return PlacesRepository( + ref.watch(apiClientProvider), + ref.watch(appDatabaseProvider), + ); +}); diff --git a/mobile/lib/features/places/presentation/screens/place_detail_screen.dart b/mobile/lib/features/places/presentation/screens/place_detail_screen.dart new file mode 100644 index 0000000..ed657cf --- /dev/null +++ b/mobile/lib/features/places/presentation/screens/place_detail_screen.dart @@ -0,0 +1,313 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../data/places_repository.dart'; +import '../../providers/places_provider.dart'; +import '../widgets/poi_chip.dart'; + +class PlaceDetailScreen extends ConsumerStatefulWidget { + final String osmType; + final int osmId; + + const PlaceDetailScreen({ + super.key, + required this.osmType, + required this.osmId, + }); + + @override + ConsumerState createState() => _PlaceDetailScreenState(); +} + +class _PlaceDetailScreenState extends ConsumerState { + PlaceData? _place; + bool _isLoading = true; + String? _error; + bool _isFavorited = false; + int? _favoriteId; + + @override + void initState() { + super.initState(); + _loadPlace(); + } + + Future _loadPlace() async { + try { + final repo = ref.read(placesRepositoryProvider); + final place = await repo.getPoiDetail(widget.osmType, widget.osmId); + final fav = await repo.isFavorited(widget.osmType, widget.osmId); + if (mounted) { + setState(() { + _place = place; + _isLoading = false; + _isFavorited = fav != null; + _favoriteId = fav?.id; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isLoading = false; + _error = 'Could not load place details.'; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(_place?.name ?? 'Place Details'), + ), + body: _buildBody(context), + ); + } + + Widget _buildBody(BuildContext context) { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (_error != null) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 48), + const SizedBox(height: 8), + Text(_error!), + const SizedBox(height: 16), + FilledButton( + onPressed: () { + setState(() { + _isLoading = true; + _error = null; + }); + _loadPlace(); + }, + child: const Text('Retry'), + ), + ], + ), + ); + } + + final place = _place!; + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Name and category + Text(place.name, style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 8), + PoiChip(category: place.category), + const SizedBox(height: 16), + + // Address + if (place.displayAddress.isNotEmpty) ...[ + _InfoRow( + icon: Icons.location_on, + label: 'Address', + value: place.displayAddress, + ), + const SizedBox(height: 12), + ], + + // Opening hours + if (place.openingHoursParsed != null) ...[ + _InfoRow( + icon: Icons.access_time, + label: 'Hours', + value: _formatOpeningHours(place), + ), + const SizedBox(height: 12), + ] else if (place.openingHours != null) ...[ + _InfoRow( + icon: Icons.access_time, + label: 'Hours', + value: place.openingHours!, + ), + const SizedBox(height: 12), + ], + + // Phone + if (place.phone != null) ...[ + _InfoRow( + icon: Icons.phone, + label: 'Phone', + value: place.phone!, + ), + const SizedBox(height: 12), + ], + + // Website + if (place.website != null) ...[ + _InfoRow( + icon: Icons.language, + label: 'Website', + value: place.website!, + ), + const SizedBox(height: 12), + ], + + // Wheelchair accessibility + if (place.wheelchair != null) ...[ + _InfoRow( + icon: Icons.accessible, + label: 'Wheelchair', + value: _wheelchairLabel(place.wheelchair!), + ), + const SizedBox(height: 12), + ], + + const SizedBox(height: 24), + + // Action buttons + Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: () { + context.push('/route', extra: { + 'destLat': place.latitude, + 'destLon': place.longitude, + 'destName': place.name, + }); + }, + icon: const Icon(Icons.directions), + label: const Text('Directions'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _isFavorited + ? FilledButton.tonalIcon( + onPressed: () async { + if (_favoriteId != null) { + await ref + .read(placesProvider.notifier) + .removeFromFavorites(_favoriteId!); + setState(() { + _isFavorited = false; + _favoriteId = null; + }); + } + }, + icon: const Icon(Icons.bookmark), + label: const Text('Saved'), + ) + : OutlinedButton.icon( + onPressed: () async { + await ref + .read(placesProvider.notifier) + .addToFavorites(place); + final fav = await ref + .read(placesRepositoryProvider) + .isFavorited(place.osmType, place.osmId); + setState(() { + _isFavorited = true; + _favoriteId = fav?.id; + }); + }, + icon: const Icon(Icons.bookmark_add_outlined), + label: const Text('Save'), + ), + ), + ], + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () { + final coords = + '${place.latitude.toStringAsFixed(6)},${place.longitude.toStringAsFixed(6)}'; + Clipboard.setData(ClipboardData(text: coords)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Coordinates copied: $coords')), + ); + }, + icon: const Icon(Icons.share), + label: const Text('Share coordinates'), + ), + ), + ], + ), + ); + } + + String _formatOpeningHours(PlaceData place) { + final parsed = place.openingHoursParsed!; + final parts = []; + final isOpen = parsed['is_open'] as bool?; + if (isOpen == true) { + parts.add('Open now'); + } else if (isOpen == false) { + parts.add('Closed'); + } + final today = parsed['today'] as String?; + if (today != null) { + parts.add('Today: $today'); + } + final nextChange = parsed['next_change'] as String?; + if (nextChange != null) { + parts.add(nextChange); + } + return parts.join(' \u2022 '); + } + + String _wheelchairLabel(String value) { + switch (value) { + case 'yes': + return 'Wheelchair accessible'; + case 'limited': + return 'Limited wheelchair access'; + case 'no': + return 'Not wheelchair accessible'; + default: + return value; + } + } +} + +class _InfoRow extends StatelessWidget { + final IconData icon; + final String label; + final String value; + + const _InfoRow({ + required this.icon, + required this.label, + required this.value, + }); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.outline, + ), + ), + Text(value, style: Theme.of(context).textTheme.bodyMedium), + ], + ), + ), + ], + ); + } +} diff --git a/mobile/lib/features/places/presentation/widgets/poi_chip.dart b/mobile/lib/features/places/presentation/widgets/poi_chip.dart new file mode 100644 index 0000000..7921093 --- /dev/null +++ b/mobile/lib/features/places/presentation/widgets/poi_chip.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; + +class PoiChip extends StatelessWidget { + final String category; + + const PoiChip({super.key, required this.category}); + + IconData _iconForCategory(String category) { + switch (category) { + case 'restaurant': + return Icons.restaurant; + case 'cafe': + return Icons.local_cafe; + case 'shop': + return Icons.shopping_bag; + case 'supermarket': + return Icons.shopping_cart; + case 'pharmacy': + return Icons.local_pharmacy; + case 'hospital': + return Icons.local_hospital; + case 'fuel': + return Icons.local_gas_station; + case 'parking': + return Icons.local_parking; + case 'atm': + return Icons.atm; + case 'public_transport': + return Icons.directions_bus; + case 'hotel': + return Icons.hotel; + case 'tourist_attraction': + return Icons.museum; + case 'park': + return Icons.park; + default: + return Icons.place; + } + } + + @override + Widget build(BuildContext context) { + return Chip( + avatar: Icon( + _iconForCategory(category), + size: 16, + ), + label: Text( + category.replaceAll('_', ' '), + style: Theme.of(context).textTheme.labelSmall, + ), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ); + } +} diff --git a/mobile/lib/features/places/providers/places_provider.dart b/mobile/lib/features/places/providers/places_provider.dart new file mode 100644 index 0000000..c74bae6 --- /dev/null +++ b/mobile/lib/features/places/providers/places_provider.dart @@ -0,0 +1,91 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/database/app_database.dart'; +import '../data/places_repository.dart'; + +class PlacesState { + final List pois; + final List favorites; + final bool isLoading; + final String? error; + + const PlacesState({ + this.pois = const [], + this.favorites = const [], + this.isLoading = false, + this.error, + }); + + PlacesState copyWith({ + List? pois, + List? favorites, + bool? isLoading, + String? error, + bool clearError = false, + }) { + return PlacesState( + pois: pois ?? this.pois, + favorites: favorites ?? this.favorites, + isLoading: isLoading ?? this.isLoading, + error: clearError ? null : (error ?? this.error), + ); + } +} + +class PlacesNotifier extends StateNotifier { + final PlacesRepository _repository; + + PlacesNotifier(this._repository) : super(const PlacesState()) { + _loadFavorites(); + } + + Future _loadFavorites() async { + final favorites = await _repository.getAllFavorites(); + if (mounted) { + state = state.copyWith(favorites: favorites); + } + } + + Future loadPoisInViewport({ + required double minLon, + required double minLat, + required double maxLon, + required double maxLat, + String? category, + }) async { + state = state.copyWith(isLoading: true, clearError: true); + try { + final pois = await _repository.getPoisInBbox( + minLon: minLon, + minLat: minLat, + maxLon: maxLon, + maxLat: maxLat, + category: category, + ); + if (mounted) { + state = state.copyWith(pois: pois, isLoading: false); + } + } catch (e) { + if (mounted) { + state = state.copyWith( + isLoading: false, + error: 'Could not load places.', + ); + } + } + } + + Future addToFavorites(PlaceData place) async { + await _repository.addFavorite(place); + await _loadFavorites(); + } + + Future removeFromFavorites(int id) async { + await _repository.removeFavorite(id); + await _loadFavorites(); + } +} + +final placesProvider = + StateNotifierProvider((ref) { + return PlacesNotifier(ref.watch(placesRepositoryProvider)); +}); diff --git a/mobile/lib/features/routing/data/routing_repository.dart b/mobile/lib/features/routing/data/routing_repository.dart new file mode 100644 index 0000000..70f36a5 --- /dev/null +++ b/mobile/lib/features/routing/data/routing_repository.dart @@ -0,0 +1,173 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/api/api_client.dart'; + +/// A single route from the OSRM response. +class RouteData { + final double distance; // meters + final double duration; // seconds + final List> geometry; // [[lon, lat], ...] + final List legs; + + RouteData({ + required this.distance, + required this.duration, + required this.geometry, + required this.legs, + }); + + String get distanceFormatted { + if (distance < 1000) { + return '${distance.round()} m'; + } + return '${(distance / 1000).toStringAsFixed(1)} km'; + } + + String get durationFormatted { + final minutes = (duration / 60).round(); + if (minutes < 60) { + return '$minutes min'; + } + final hours = minutes ~/ 60; + final remainingMin = minutes % 60; + return '${hours}h ${remainingMin}min'; + } + + factory RouteData.fromJson(Map json) { + final geom = json['geometry'] as Map; + final coords = (geom['coordinates'] as List) + .map((c) => [(c[0] as num).toDouble(), (c[1] as num).toDouble()]) + .toList(); + + final legs = (json['legs'] as List) + .map((l) => RouteLeg.fromJson(l as Map)) + .toList(); + + return RouteData( + distance: (json['distance'] as num).toDouble(), + duration: (json['duration'] as num).toDouble(), + geometry: coords, + legs: legs, + ); + } +} + +class RouteLeg { + final double distance; + final double duration; + final String summary; + final List steps; + + RouteLeg({ + required this.distance, + required this.duration, + required this.summary, + required this.steps, + }); + + factory RouteLeg.fromJson(Map json) { + final steps = (json['steps'] as List?) + ?.map((s) => RouteStep.fromJson(s as Map)) + .toList() ?? + []; + return RouteLeg( + distance: (json['distance'] as num).toDouble(), + duration: (json['duration'] as num).toDouble(), + summary: json['summary'] as String? ?? '', + steps: steps, + ); + } +} + +class RouteStep { + final double distance; + final double duration; + final String name; + final Maneuver maneuver; + + RouteStep({ + required this.distance, + required this.duration, + required this.name, + required this.maneuver, + }); + + factory RouteStep.fromJson(Map json) { + return RouteStep( + distance: (json['distance'] as num).toDouble(), + duration: (json['duration'] as num).toDouble(), + name: json['name'] as String? ?? '', + maneuver: + Maneuver.fromJson(json['maneuver'] as Map), + ); + } +} + +class Maneuver { + final String type; + final String? modifier; + final List location; // [lon, lat] + final String instruction; + + Maneuver({ + required this.type, + this.modifier, + required this.location, + required this.instruction, + }); + + factory Maneuver.fromJson(Map json) { + final loc = (json['location'] as List) + .map((e) => (e as num).toDouble()) + .toList(); + return Maneuver( + type: json['type'] as String, + modifier: json['modifier'] as String?, + location: loc, + instruction: json['instruction'] as String? ?? '', + ); + } +} + +class RoutingRepository { + final ApiClient _apiClient; + + RoutingRepository(this._apiClient); + + /// Calculate a route via GET /api/route/{profile}/{coordinates}. + /// [origin] and [destination] are [lat, lon] pairs. + Future> getRoute({ + required String profile, + required double originLat, + required double originLon, + required double destLat, + required double destLon, + }) async { + // OSRM coordinates format: lon,lat;lon,lat + final coordinates = + '$originLon,$originLat;$destLon,$destLat'; + + final data = await _apiClient.get( + '/api/route/$profile/$coordinates', + queryParameters: { + 'alternatives': 2, + 'steps': true, + 'geometries': 'geojson', + }, + ); + + final code = data['code'] as String?; + if (code != 'Ok') { + throw Exception('Routing failed: $code'); + } + + final routes = (data['routes'] as List) + .map((r) => RouteData.fromJson(r as Map)) + .toList(); + + return routes; + } +} + +final routingRepositoryProvider = Provider((ref) { + return RoutingRepository(ref.watch(apiClientProvider)); +}); diff --git a/mobile/lib/features/routing/presentation/screens/route_screen.dart b/mobile/lib/features/routing/presentation/screens/route_screen.dart new file mode 100644 index 0000000..39cfbd4 --- /dev/null +++ b/mobile/lib/features/routing/presentation/screens/route_screen.dart @@ -0,0 +1,347 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map_cancellable_tile_provider/flutter_map_cancellable_tile_provider.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:latlong2/latlong.dart'; +import '../../../map/providers/map_provider.dart'; +import '../../providers/routing_provider.dart'; +import '../widgets/route_summary.dart'; +import '../widgets/step_list.dart'; + +class RouteScreen extends ConsumerStatefulWidget { + final double? destinationLat; + final double? destinationLon; + final String? destinationName; + + const RouteScreen({ + super.key, + this.destinationLat, + this.destinationLon, + this.destinationName, + }); + + @override + ConsumerState createState() => _RouteScreenState(); +} + +class _RouteScreenState extends ConsumerState { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _initializeRoute(); + }); + } + + void _initializeRoute() { + final notifier = ref.read(routingProvider.notifier); + final mapState = ref.read(mapProvider); + + // Set origin from current location if available. + if (mapState.currentLocation != null) { + notifier.setOrigin( + mapState.currentLocation!.latitude, + mapState.currentLocation!.longitude, + 'My Location', + ); + } + + // Set destination from route params. + if (widget.destinationLat != null && widget.destinationLon != null) { + notifier.setDestination( + widget.destinationLat!, + widget.destinationLon!, + widget.destinationName ?? 'Destination', + ); + notifier.calculateRoute(); + } + } + + @override + Widget build(BuildContext context) { + final routingState = ref.watch(routingProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('Route'), + ), + body: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Origin and destination fields + Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + TextField( + readOnly: true, + decoration: InputDecoration( + labelText: 'From', + prefixIcon: const Icon(Icons.trip_origin), + hintText: routingState.originName, + ), + controller: + TextEditingController(text: routingState.originName), + ), + const SizedBox(height: 8), + TextField( + readOnly: true, + decoration: InputDecoration( + labelText: 'To', + prefixIcon: const Icon(Icons.flag), + hintText: routingState.destName, + ), + controller: + TextEditingController(text: routingState.destName), + ), + ], + ), + ), + + // Profile selector + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + _ProfileChip( + icon: Icons.directions_car, + label: 'Drive', + isSelected: routingState.profile == 'driving', + onTap: () { + ref.read(routingProvider.notifier).setProfile('driving'); + ref.read(routingProvider.notifier).calculateRoute(); + }, + ), + const SizedBox(width: 8), + _ProfileChip( + icon: Icons.directions_walk, + label: 'Walk', + isSelected: routingState.profile == 'walking', + onTap: () { + ref.read(routingProvider.notifier).setProfile('walking'); + ref.read(routingProvider.notifier).calculateRoute(); + }, + ), + const SizedBox(width: 8), + _ProfileChip( + icon: Icons.directions_bike, + label: 'Cycle', + isSelected: routingState.profile == 'cycling', + onTap: () { + ref.read(routingProvider.notifier).setProfile('cycling'); + ref.read(routingProvider.notifier).calculateRoute(); + }, + ), + ], + ), + ), + const SizedBox(height: 16), + + // Loading + if (routingState.isLoading) + const Center( + child: Padding( + padding: EdgeInsets.all(32), + child: CircularProgressIndicator(), + ), + ), + + // Error + if (routingState.error != null) + Padding( + padding: const EdgeInsets.all(16), + child: Card( + color: Theme.of(context).colorScheme.errorContainer, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(Icons.error_outline, + color: Theme.of(context) + .colorScheme + .onErrorContainer), + const SizedBox(width: 8), + Expanded(child: Text(routingState.error!)), + ], + ), + ), + ), + ), + + // Map with route + if (routingState.routes.isNotEmpty) + _buildRouteMap(context, routingState), + + // Route summaries + if (routingState.routes.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + for (var i = 0; i < routingState.routes.length; i++) + RouteSummary( + route: routingState.routes[i], + profile: routingState.profile, + isSelected: i == routingState.selectedRouteIndex, + onTap: () => + ref.read(routingProvider.notifier).selectRoute(i), + ), + ], + ), + ), + + // Step-by-step instructions + if (routingState.selectedRoute != null && + routingState.selectedRoute!.legs.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + 'Turn-by-turn directions', + style: Theme.of(context).textTheme.titleSmall, + ), + ), + StepList( + steps: routingState.selectedRoute!.legs + .expand((leg) => leg.steps) + .toList(), + ), + ], + + const SizedBox(height: 32), + ], + ), + ), + ); + } + + Widget _buildRouteMap(BuildContext context, RoutingState routingState) { + final selectedRoute = routingState.selectedRoute; + if (selectedRoute == null) return const SizedBox.shrink(); + + final routePoints = selectedRoute.geometry + .map((c) => LatLng(c[1], c[0])) + .toList(); + + // Calculate bounds. + double minLat = 90, maxLat = -90, minLon = 180, maxLon = -180; + for (final p in routePoints) { + if (p.latitude < minLat) minLat = p.latitude; + if (p.latitude > maxLat) maxLat = p.latitude; + if (p.longitude < minLon) minLon = p.longitude; + if (p.longitude > maxLon) maxLon = p.longitude; + } + + return Container( + height: 250, + margin: const EdgeInsets.all(16), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + ), + child: FlutterMap( + options: MapOptions( + initialCameraFit: CameraFit.bounds( + bounds: LatLngBounds( + LatLng(minLat, minLon), + LatLng(maxLat, maxLon), + ), + padding: const EdgeInsets.all(32), + ), + interactionOptions: const InteractionOptions( + flags: InteractiveFlag.none, + ), + ), + children: [ + TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + tileProvider: CancellableNetworkTileProvider(), + userAgentPackageName: 'com.privacymaps.app', + ), + // Draw all routes, non-selected as faint, selected as bold. + for (var i = routingState.routes.length - 1; i >= 0; i--) + PolylineLayer( + polylines: [ + Polyline( + points: routingState.routes[i].geometry + .map((c) => LatLng(c[1], c[0])) + .toList(), + strokeWidth: + i == routingState.selectedRouteIndex ? 5.0 : 3.0, + color: i == routingState.selectedRouteIndex + ? Theme.of(context).colorScheme.primary + : Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5), + ), + ], + ), + // Origin and destination markers + MarkerLayer( + markers: [ + if (routingState.originLat != null && + routingState.originLon != null) + Marker( + point: LatLng( + routingState.originLat!, + routingState.originLon!, + ), + width: 20, + height: 20, + child: Container( + decoration: BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + ), + ), + if (routingState.destLat != null && + routingState.destLon != null) + Marker( + point: LatLng( + routingState.destLat!, + routingState.destLon!, + ), + width: 20, + height: 20, + child: Container( + decoration: BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + ), + ), + ], + ), + ], + ), + ); + } +} + +class _ProfileChip extends StatelessWidget { + final IconData icon; + final String label; + final bool isSelected; + final VoidCallback onTap; + + const _ProfileChip({ + required this.icon, + required this.label, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return ChoiceChip( + avatar: Icon(icon, size: 18), + label: Text(label), + selected: isSelected, + onSelected: (_) => onTap(), + ); + } +} diff --git a/mobile/lib/features/routing/presentation/widgets/route_summary.dart b/mobile/lib/features/routing/presentation/widgets/route_summary.dart new file mode 100644 index 0000000..682d039 --- /dev/null +++ b/mobile/lib/features/routing/presentation/widgets/route_summary.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import '../../data/routing_repository.dart'; + +class RouteSummary extends StatelessWidget { + final RouteData route; + final String profile; + final bool isSelected; + final VoidCallback? onTap; + + const RouteSummary({ + super.key, + required this.route, + required this.profile, + this.isSelected = false, + this.onTap, + }); + + IconData _profileIcon(String profile) { + switch (profile) { + case 'walking': + return Icons.directions_walk; + case 'cycling': + return Icons.directions_bike; + case 'driving': + default: + return Icons.directions_car; + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Card( + color: isSelected + ? colorScheme.primaryContainer + : colorScheme.surfaceContainerHighest, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon( + _profileIcon(profile), + size: 32, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + route.durationFormatted, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2), + Text( + route.distanceFormatted, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + if (route.legs.isNotEmpty && route.legs.first.summary.isNotEmpty) + Expanded( + child: Text( + 'via ${route.legs.first.summary}', + style: Theme.of(context).textTheme.bodySmall, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/routing/presentation/widgets/step_list.dart b/mobile/lib/features/routing/presentation/widgets/step_list.dart new file mode 100644 index 0000000..eb180f7 --- /dev/null +++ b/mobile/lib/features/routing/presentation/widgets/step_list.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import '../../data/routing_repository.dart'; + +class StepList extends StatelessWidget { + final List steps; + + const StepList({super.key, required this.steps}); + + IconData _maneuverIcon(String type, String? modifier) { + switch (type) { + case 'depart': + return Icons.trip_origin; + case 'arrive': + return Icons.flag; + case 'turn': + if (modifier == null) return Icons.straight; + if (modifier.contains('left')) return Icons.turn_left; + if (modifier.contains('right')) return Icons.turn_right; + if (modifier == 'uturn') return Icons.u_turn_left; + return Icons.straight; + case 'roundabout': + case 'rotary': + return Icons.roundabout_left; + case 'merge': + return Icons.merge; + case 'fork': + if (modifier != null && modifier.contains('left')) { + return Icons.fork_left; + } + return Icons.fork_right; + case 'on ramp': + case 'off ramp': + return Icons.ramp_right; + case 'continue': + case 'new name': + return Icons.straight; + default: + return Icons.straight; + } + } + + @override + Widget build(BuildContext context) { + return ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: steps.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final step = steps[index]; + final distanceText = step.distance < 1000 + ? '${step.distance.round()} m' + : '${(step.distance / 1000).toStringAsFixed(1)} km'; + + return ListTile( + leading: CircleAvatar( + backgroundColor: + Theme.of(context).colorScheme.secondaryContainer, + child: Icon( + _maneuverIcon(step.maneuver.type, step.maneuver.modifier), + color: Theme.of(context).colorScheme.onSecondaryContainer, + size: 20, + ), + ), + title: Text( + step.maneuver.instruction, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + subtitle: step.name.isNotEmpty + ? Text(step.name, + style: Theme.of(context).textTheme.bodySmall) + : null, + trailing: step.distance > 0 + ? Text( + distanceText, + style: Theme.of(context).textTheme.bodySmall, + ) + : null, + ); + }, + ); + } +} diff --git a/mobile/lib/features/routing/providers/routing_provider.dart b/mobile/lib/features/routing/providers/routing_provider.dart new file mode 100644 index 0000000..1fd8f55 --- /dev/null +++ b/mobile/lib/features/routing/providers/routing_provider.dart @@ -0,0 +1,137 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../data/routing_repository.dart'; + +class RoutingState { + final double? originLat; + final double? originLon; + final String originName; + final double? destLat; + final double? destLon; + final String destName; + final String profile; // driving, walking, cycling + final List routes; + final int selectedRouteIndex; + final bool isLoading; + final String? error; + + const RoutingState({ + this.originLat, + this.originLon, + this.originName = 'My Location', + this.destLat, + this.destLon, + this.destName = '', + this.profile = 'driving', + this.routes = const [], + this.selectedRouteIndex = 0, + this.isLoading = false, + this.error, + }); + + RoutingState copyWith({ + double? originLat, + double? originLon, + String? originName, + double? destLat, + double? destLon, + String? destName, + String? profile, + List? routes, + int? selectedRouteIndex, + bool? isLoading, + String? error, + bool clearError = false, + }) { + return RoutingState( + originLat: originLat ?? this.originLat, + originLon: originLon ?? this.originLon, + originName: originName ?? this.originName, + destLat: destLat ?? this.destLat, + destLon: destLon ?? this.destLon, + destName: destName ?? this.destName, + profile: profile ?? this.profile, + routes: routes ?? this.routes, + selectedRouteIndex: selectedRouteIndex ?? this.selectedRouteIndex, + isLoading: isLoading ?? this.isLoading, + error: clearError ? null : (error ?? this.error), + ); + } + + RouteData? get selectedRoute => + routes.isNotEmpty && selectedRouteIndex < routes.length + ? routes[selectedRouteIndex] + : null; +} + +class RoutingNotifier extends StateNotifier { + final RoutingRepository _repository; + + RoutingNotifier(this._repository) : super(const RoutingState()); + + void setOrigin(double lat, double lon, String name) { + state = state.copyWith( + originLat: lat, + originLon: lon, + originName: name, + routes: [], + ); + } + + void setDestination(double lat, double lon, String name) { + state = state.copyWith( + destLat: lat, + destLon: lon, + destName: name, + routes: [], + ); + } + + void setProfile(String profile) { + state = state.copyWith(profile: profile, routes: []); + } + + void selectRoute(int index) { + state = state.copyWith(selectedRouteIndex: index); + } + + Future calculateRoute() async { + if (state.originLat == null || + state.originLon == null || + state.destLat == null || + state.destLon == null) { + state = state.copyWith(error: 'Please set both origin and destination.'); + return; + } + + state = state.copyWith(isLoading: true, clearError: true, routes: []); + + try { + final routes = await _repository.getRoute( + profile: state.profile, + originLat: state.originLat!, + originLon: state.originLon!, + destLat: state.destLat!, + destLon: state.destLon!, + ); + if (mounted) { + state = state.copyWith( + routes: routes, + selectedRouteIndex: 0, + isLoading: false, + ); + } + } catch (e) { + if (mounted) { + state = state.copyWith( + isLoading: false, + error: 'Could not calculate route. Please try again.', + ); + } + } + } +} + +final routingProvider = + StateNotifierProvider.autoDispose((ref) { + return RoutingNotifier(ref.watch(routingRepositoryProvider)); +}); diff --git a/mobile/lib/features/search/data/search_repository.dart b/mobile/lib/features/search/data/search_repository.dart new file mode 100644 index 0000000..c119f9a --- /dev/null +++ b/mobile/lib/features/search/data/search_repository.dart @@ -0,0 +1,136 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/api/api_client.dart'; +import '../../../core/database/app_database.dart'; + +/// A single search result parsed from GeoJSON. +class SearchResult { + final int osmId; + final String osmType; + final String name; + final String? street; + final String? housenumber; + final String? postcode; + final String? city; + final String? state; + final String? country; + final String? countryCode; + final String type; + final double latitude; + final double longitude; + final List? extent; + + SearchResult({ + required this.osmId, + required this.osmType, + required this.name, + this.street, + this.housenumber, + this.postcode, + this.city, + this.state, + this.country, + this.countryCode, + required this.type, + required this.latitude, + required this.longitude, + this.extent, + }); + + String get displayAddress { + final parts = []; + if (street != null) { + parts.add(housenumber != null ? '$street $housenumber' : street!); + } + if (city != null) parts.add(city!); + if (country != null) parts.add(country!); + return parts.join(', '); + } + + factory SearchResult.fromGeoJsonFeature(Map feature) { + final geometry = feature['geometry'] as Map; + final coords = geometry['coordinates'] as List; + final props = feature['properties'] as Map; + + return SearchResult( + osmId: props['osm_id'] as int, + osmType: props['osm_type'] as String, + name: props['name'] as String? ?? '', + street: props['street'] as String?, + housenumber: props['housenumber'] as String?, + postcode: props['postcode'] as String?, + city: props['city'] as String?, + state: props['state'] as String?, + country: props['country'] as String?, + countryCode: props['country_code'] as String?, + type: props['type'] as String? ?? 'unknown', + longitude: (coords[0] as num).toDouble(), + latitude: (coords[1] as num).toDouble(), + extent: (props['extent'] as List?) + ?.map((e) => (e as num).toDouble()) + .toList(), + ); + } +} + +class SearchRepository { + final ApiClient _apiClient; + final AppDatabase _db; + + SearchRepository(this._apiClient, this._db); + + /// Forward search via GET /api/search. + Future> search( + String query, { + double? lat, + double? lon, + int limit = 10, + }) async { + final params = { + 'q': query, + 'limit': limit, + }; + if (lat != null && lon != null) { + params['lat'] = lat; + params['lon'] = lon; + } + + final data = await _apiClient.get('/api/search', queryParameters: params); + final features = (data['features'] as List?) ?? []; + return features + .map((f) => + SearchResult.fromGeoJsonFeature(f as Map)) + .toList(); + } + + /// Save a query to search history. + Future saveToHistory(String query, {double? lat, double? lon}) { + return _db.addSearch(query, lat: lat, lon: lon); + } + + /// Get recent search history. + Future> getRecentSearches() { + return _db.getRecentSearches(); + } + + /// Watch recent search history. + Stream> watchRecentSearches() { + return _db.watchRecentSearches(); + } + + /// Delete a single search history entry. + Future deleteHistoryEntry(int id) { + return _db.deleteSearchEntry(id); + } + + /// Clear all search history. + Future clearHistory() { + return _db.clearSearchHistory(); + } +} + +final searchRepositoryProvider = Provider((ref) { + return SearchRepository( + ref.watch(apiClientProvider), + ref.watch(appDatabaseProvider), + ); +}); diff --git a/mobile/lib/features/search/presentation/screens/search_screen.dart b/mobile/lib/features/search/presentation/screens/search_screen.dart new file mode 100644 index 0000000..d7d2b96 --- /dev/null +++ b/mobile/lib/features/search/presentation/screens/search_screen.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:latlong2/latlong.dart'; +import '../../../map/providers/map_provider.dart'; +import '../../data/search_repository.dart'; +import '../../providers/search_provider.dart'; +import '../widgets/search_result_tile.dart'; + +class SearchScreen extends ConsumerStatefulWidget { + const SearchScreen({super.key}); + + @override + ConsumerState createState() => _SearchScreenState(); +} + +class _SearchScreenState extends ConsumerState { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final searchState = ref.watch(searchProvider); + + return Scaffold( + appBar: AppBar( + title: TextField( + controller: _controller, + autofocus: true, + decoration: const InputDecoration( + hintText: 'Search places...', + border: InputBorder.none, + filled: false, + ), + onChanged: (value) { + ref.read(searchProvider.notifier).updateQuery(value); + }, + ), + actions: [ + if (_controller.text.isNotEmpty) + IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _controller.clear(); + ref.read(searchProvider.notifier).updateQuery(''); + }, + ), + ], + ), + body: _buildBody(context, searchState), + ); + } + + Widget _buildBody(BuildContext context, SearchState searchState) { + if (searchState.error != null) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 48), + const SizedBox(height: 8), + Text(searchState.error!), + ], + ), + ); + } + + if (searchState.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (searchState.query.trim().isNotEmpty) { + if (searchState.results.isEmpty) { + return const Center(child: Text('No results found.')); + } + return ListView.builder( + itemCount: searchState.results.length, + itemBuilder: (context, index) { + final result = searchState.results[index]; + return SearchResultTile( + result: result, + onTap: () => _onResultTap(result), + ); + }, + ); + } + + if (searchState.recentSearches.isEmpty) { + return const Center( + child: Text('Start typing to search for places.'), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Recent Searches', + style: Theme.of(context).textTheme.titleSmall, + ), + TextButton( + onPressed: () => + ref.read(searchProvider.notifier).clearHistory(), + child: const Text('Clear all'), + ), + ], + ), + ), + Expanded( + child: ListView.builder( + itemCount: searchState.recentSearches.length, + itemBuilder: (context, index) { + final item = searchState.recentSearches[index]; + return ListTile( + leading: const Icon(Icons.history), + title: Text(item.query), + trailing: IconButton( + icon: const Icon(Icons.close, size: 18), + onPressed: () => ref + .read(searchProvider.notifier) + .deleteHistoryEntry(item.id), + ), + onTap: () { + _controller.text = item.query; + ref.read(searchProvider.notifier).updateQuery(item.query); + }, + ); + }, + ), + ), + ], + ); + } + + void _onResultTap(SearchResult result) { + ref.read(searchProvider.notifier).selectResult(result); + ref.read(mapProvider.notifier).selectPlace( + SelectedPlace( + name: result.name, + address: result.displayAddress, + category: result.type, + latitude: result.latitude, + longitude: result.longitude, + osmId: result.osmId, + osmType: result.osmType, + ), + ); + ref.read(mapProvider.notifier).updateCamera( + LatLng(result.latitude, result.longitude), + 15, + ); + context.go('/'); + } +} diff --git a/mobile/lib/features/search/presentation/widgets/search_result_tile.dart b/mobile/lib/features/search/presentation/widgets/search_result_tile.dart new file mode 100644 index 0000000..f67ca08 --- /dev/null +++ b/mobile/lib/features/search/presentation/widgets/search_result_tile.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import '../../data/search_repository.dart'; + +class SearchResultTile extends StatelessWidget { + final SearchResult result; + final VoidCallback onTap; + + const SearchResultTile({ + super.key, + required this.result, + required this.onTap, + }); + + IconData _iconForType(String type) { + switch (type) { + case 'house': + case 'building': + return Icons.home; + case 'street': + return Icons.add_road; + case 'city': + case 'town': + case 'village': + return Icons.location_city; + case 'park': + return Icons.park; + case 'restaurant': + return Icons.restaurant; + case 'cafe': + return Icons.local_cafe; + case 'shop': + case 'supermarket': + return Icons.shopping_cart; + case 'hotel': + return Icons.hotel; + case 'hospital': + case 'pharmacy': + return Icons.local_hospital; + default: + return Icons.place; + } + } + + @override + Widget build(BuildContext context) { + return ListTile( + leading: CircleAvatar( + backgroundColor: + Theme.of(context).colorScheme.primaryContainer, + child: Icon( + _iconForType(result.type), + color: Theme.of(context).colorScheme.onPrimaryContainer, + size: 20, + ), + ), + title: Text( + result.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + result.displayAddress, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + trailing: Text( + result.type, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.outline, + ), + ), + onTap: onTap, + ); + } +} diff --git a/mobile/lib/features/search/providers/search_provider.dart b/mobile/lib/features/search/providers/search_provider.dart new file mode 100644 index 0000000..2a2d15d --- /dev/null +++ b/mobile/lib/features/search/providers/search_provider.dart @@ -0,0 +1,117 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/constants.dart'; +import '../../../core/database/app_database.dart'; +import '../data/search_repository.dart'; + +class SearchState { + final String query; + final List results; + final List recentSearches; + final bool isLoading; + final String? error; + + const SearchState({ + this.query = '', + this.results = const [], + this.recentSearches = const [], + this.isLoading = false, + this.error, + }); + + SearchState copyWith({ + String? query, + List? results, + List? recentSearches, + bool? isLoading, + String? error, + bool clearError = false, + }) { + return SearchState( + query: query ?? this.query, + results: results ?? this.results, + recentSearches: recentSearches ?? this.recentSearches, + isLoading: isLoading ?? this.isLoading, + error: clearError ? null : (error ?? this.error), + ); + } +} + +class SearchNotifier extends StateNotifier { + final SearchRepository _repository; + Timer? _debounce; + + SearchNotifier(this._repository) : super(const SearchState()) { + _loadHistory(); + } + + Future _loadHistory() async { + final history = await _repository.getRecentSearches(); + if (mounted) { + state = state.copyWith(recentSearches: history); + } + } + + void updateQuery(String query) { + state = state.copyWith(query: query, clearError: true); + _debounce?.cancel(); + + if (query.trim().isEmpty) { + state = state.copyWith(results: [], isLoading: false); + return; + } + + state = state.copyWith(isLoading: true); + _debounce = Timer( + const Duration(milliseconds: AppConstants.searchDebounceMs), + () => _performSearch(query), + ); + } + + Future _performSearch(String query) async { + try { + final results = await _repository.search(query); + if (mounted && state.query == query) { + state = state.copyWith(results: results, isLoading: false); + } + } catch (e) { + if (mounted) { + state = state.copyWith( + isLoading: false, + error: 'Search failed. Please try again.', + ); + } + } + } + + Future selectResult(SearchResult result) async { + await _repository.saveToHistory( + result.name, + lat: result.latitude, + lon: result.longitude, + ); + await _loadHistory(); + } + + Future deleteHistoryEntry(int id) async { + await _repository.deleteHistoryEntry(id); + await _loadHistory(); + } + + Future clearHistory() async { + await _repository.clearHistory(); + await _loadHistory(); + } + + @override + void dispose() { + _debounce?.cancel(); + super.dispose(); + } +} + +final searchProvider = + StateNotifierProvider.autoDispose((ref) { + return SearchNotifier(ref.watch(searchRepositoryProvider)); +}); diff --git a/mobile/lib/features/settings/presentation/screens/settings_screen.dart b/mobile/lib/features/settings/presentation/screens/settings_screen.dart new file mode 100644 index 0000000..bb2bbb7 --- /dev/null +++ b/mobile/lib/features/settings/presentation/screens/settings_screen.dart @@ -0,0 +1,251 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/api/api_client.dart'; +import '../../../../core/constants.dart'; +import '../../../../core/database/app_database.dart'; + +/// ThemeMode provider, watched by PrivacyMapsApp. +final themeModeProvider = + StateNotifierProvider((ref) { + return ThemeModeNotifier(ref.watch(appDatabaseProvider)); +}); + +class ThemeModeNotifier extends StateNotifier { + final AppDatabase _db; + + ThemeModeNotifier(this._db) : super(ThemeMode.system) { + _load(); + } + + Future _load() async { + final value = await _db.getSetting(AppConstants.settingThemeMode); + if (value != null && mounted) { + state = _parse(value); + } + } + + Future setThemeMode(ThemeMode mode) async { + state = mode; + await _db.setSetting(AppConstants.settingThemeMode, mode.name); + } + + ThemeMode _parse(String value) { + switch (value) { + case 'light': + return ThemeMode.light; + case 'dark': + return ThemeMode.dark; + default: + return ThemeMode.system; + } + } +} + +class SettingsScreen extends ConsumerStatefulWidget { + const SettingsScreen({super.key}); + + @override + ConsumerState createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends ConsumerState { + late final TextEditingController _backendUrlController; + bool _urlSaved = false; + + @override + void initState() { + super.initState(); + _backendUrlController = TextEditingController(); + _loadBackendUrl(); + } + + Future _loadBackendUrl() async { + final db = ref.read(appDatabaseProvider); + final url = await db.getSetting(AppConstants.settingBackendUrl); + _backendUrlController.text = url ?? AppConstants.defaultBackendUrl; + } + + @override + void dispose() { + _backendUrlController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final themeMode = ref.watch(themeModeProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('Settings'), + ), + body: ListView( + children: [ + // Backend URL + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Backend Server', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + TextField( + controller: _backendUrlController, + decoration: InputDecoration( + labelText: 'Server URL', + hintText: AppConstants.defaultBackendUrl, + suffixIcon: _urlSaved + ? const Icon(Icons.check, color: Colors.green) + : null, + ), + keyboardType: TextInputType.url, + onChanged: (_) { + if (_urlSaved) setState(() => _urlSaved = false); + }, + ), + const SizedBox(height: 8), + FilledButton( + onPressed: _saveBackendUrl, + child: const Text('Save'), + ), + ], + ), + ), + const Divider(), + + // Theme + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + 'Appearance', + style: Theme.of(context).textTheme.titleSmall, + ), + ), + RadioListTile( + title: const Text('System default'), + value: ThemeMode.system, + groupValue: themeMode, + onChanged: (v) => + ref.read(themeModeProvider.notifier).setThemeMode(v!), + ), + RadioListTile( + title: const Text('Day (light)'), + value: ThemeMode.light, + groupValue: themeMode, + onChanged: (v) => + ref.read(themeModeProvider.notifier).setThemeMode(v!), + ), + RadioListTile( + title: const Text('Night (dark)'), + value: ThemeMode.dark, + groupValue: themeMode, + onChanged: (v) => + ref.read(themeModeProvider.notifier).setThemeMode(v!), + ), + const Divider(), + + // About + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + 'About', + style: Theme.of(context).textTheme.titleSmall, + ), + ), + const ListTile( + leading: Icon(Icons.info_outline), + title: Text('Privacy Maps'), + subtitle: Text('Version 1.0.0'), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'A privacy-first maps application. No tracking, no analytics, ' + 'no third-party SDKs. All data stays on your device or your ' + 'own server.', + ), + ), + const SizedBox(height: 16), + + // Attributions + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + 'Open Source Attributions', + style: Theme.of(context).textTheme.titleSmall, + ), + ), + const _AttributionTile( + name: 'OpenStreetMap', + description: 'Map data', + license: 'ODbL 1.0', + ), + const _AttributionTile( + name: 'OSRM', + description: 'Open Source Routing Machine', + license: 'BSD 2-Clause', + ), + const _AttributionTile( + name: 'Photon', + description: 'Geocoding / search powered by Komoot', + license: 'Apache License 2.0', + ), + const _AttributionTile( + name: 'Martin', + description: 'Vector tile server', + license: 'Apache License 2.0 / MIT', + ), + const SizedBox(height: 32), + ], + ), + ); + } + + Future _saveBackendUrl() async { + final url = _backendUrlController.text.trim(); + if (url.isEmpty) return; + + final db = ref.read(appDatabaseProvider); + await db.setSetting(AppConstants.settingBackendUrl, url); + + ref.read(apiClientProvider).updateBaseUrl(url); + + setState(() => _urlSaved = true); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Backend URL saved.')), + ); + } + } +} + +class _AttributionTile extends StatelessWidget { + final String name; + final String description; + final String license; + + const _AttributionTile({ + required this.name, + required this.description, + required this.license, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + dense: true, + title: Text(name), + subtitle: Text(description), + trailing: Text( + license, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.outline, + ), + ), + ); + } +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart new file mode 100644 index 0000000..628a81d --- /dev/null +++ b/mobile/lib/main.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'app/app.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + runApp( + const ProviderScope( + child: PrivacyMapsApp(), + ), + ); +} diff --git a/mobile/linux/.gitignore b/mobile/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/mobile/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/mobile/linux/CMakeLists.txt b/mobile/linux/CMakeLists.txt new file mode 100644 index 0000000..8cdf96e --- /dev/null +++ b/mobile/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "privacy_maps") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.privacymaps.privacy_maps") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/mobile/linux/flutter/CMakeLists.txt b/mobile/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/mobile/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/mobile/linux/flutter/generated_plugin_registrant.cc b/mobile/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..2c1ec4f --- /dev/null +++ b/mobile/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); + sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar); +} diff --git a/mobile/linux/flutter/generated_plugin_registrant.h b/mobile/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/mobile/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/mobile/linux/flutter/generated_plugins.cmake b/mobile/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..7ea2a80 --- /dev/null +++ b/mobile/linux/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + sqlite3_flutter_libs +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/mobile/linux/runner/CMakeLists.txt b/mobile/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/mobile/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/mobile/linux/runner/main.cc b/mobile/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/mobile/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/mobile/linux/runner/my_application.cc b/mobile/linux/runner/my_application.cc new file mode 100644 index 0000000..659be30 --- /dev/null +++ b/mobile/linux/runner/my_application.cc @@ -0,0 +1,130 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "privacy_maps"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "privacy_maps"); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/mobile/linux/runner/my_application.h b/mobile/linux/runner/my_application.h new file mode 100644 index 0000000..72271d5 --- /dev/null +++ b/mobile/linux/runner/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/mobile/macos/.gitignore b/mobile/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/mobile/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/mobile/macos/Flutter/Flutter-Debug.xcconfig b/mobile/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..4b81f9b --- /dev/null +++ b/mobile/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/mobile/macos/Flutter/Flutter-Release.xcconfig b/mobile/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..5caa9d1 --- /dev/null +++ b/mobile/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/mobile/macos/Flutter/GeneratedPluginRegistrant.swift b/mobile/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..034f62e --- /dev/null +++ b/mobile/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,16 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import geolocator_apple +import path_provider_foundation +import sqlite3_flutter_libs + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) +} diff --git a/mobile/macos/Podfile b/mobile/macos/Podfile new file mode 100644 index 0000000..29c8eb3 --- /dev/null +++ b/mobile/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.14' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/mobile/macos/Runner.xcodeproj/project.pbxproj b/mobile/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..e332fea --- /dev/null +++ b/mobile/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,705 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* privacy_maps.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "privacy_maps.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* privacy_maps.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* privacy_maps.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.privacymaps.privacyMaps.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/privacy_maps.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/privacy_maps"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.privacymaps.privacyMaps.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/privacy_maps.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/privacy_maps"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.privacymaps.privacyMaps.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/privacy_maps.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/privacy_maps"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/mobile/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/mobile/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/mobile/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/mobile/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/mobile/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..8ecc94b --- /dev/null +++ b/mobile/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/macos/Runner.xcworkspace/contents.xcworkspacedata b/mobile/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/mobile/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/mobile/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/mobile/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/mobile/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/mobile/macos/Runner/AppDelegate.swift b/mobile/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/mobile/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000000000000000000000000000000000000..82b6f9d9a33e198f5747104729e1fcef999772a5 GIT binary patch literal 102994 zcmeEugo5nb1G~3xi~y`}h6XHx5j$(L*3|5S2UfkG$|UCNI>}4f?MfqZ+HW-sRW5RKHEm z^unW*Xx{AH_X3Xdvb%C(Bh6POqg==@d9j=5*}oEny_IS;M3==J`P0R!eD6s~N<36C z*%-OGYqd0AdWClO!Z!}Y1@@RkfeiQ$Ib_ z&fk%T;K9h`{`cX3Hu#?({4WgtmkR!u3ICS~|NqH^fdNz>51-9)OF{|bRLy*RBv#&1 z3Oi_gk=Y5;>`KbHf~w!`u}!&O%ou*Jzf|Sf?J&*f*K8cftMOKswn6|nb1*|!;qSrlw= zr-@X;zGRKs&T$y8ENnFU@_Z~puu(4~Ir)>rbYp{zxcF*!EPS6{(&J}qYpWeqrPWW< zfaApz%<-=KqxrqLLFeV3w0-a0rEaz9&vv^0ZfU%gt9xJ8?=byvNSb%3hF^X_n7`(fMA;C&~( zM$cQvQ|g9X)1AqFvbp^B{JEX$o;4iPi?+v(!wYrN{L}l%e#5y{j+1NMiT-8=2VrCP zmFX9=IZyAYA5c2!QO96Ea-6;v6*$#ZKM-`%JCJtrA3d~6h{u+5oaTaGE)q2b+HvdZ zvHlY&9H&QJ5|uG@wDt1h99>DdHy5hsx)bN`&G@BpxAHh$17yWDyw_jQhhjSqZ=e_k z_|r3=_|`q~uA47y;hv=6-o6z~)gO}ZM9AqDJsR$KCHKH;QIULT)(d;oKTSPDJ}Jx~G#w-(^r<{GcBC*~4bNjfwHBumoPbU}M)O za6Hc2ik)2w37Yyg!YiMq<>Aov?F2l}wTe+>h^YXcK=aesey^i)QC_p~S zp%-lS5%)I29WfywP(r4@UZ@XmTkqo51zV$|U|~Lcap##PBJ}w2b4*kt7x6`agP34^ z5fzu_8rrH+)2u*CPcr6I`gL^cI`R2WUkLDE5*PX)eJU@H3HL$~o_y8oMRoQ0WF9w| z6^HZDKKRDG2g;r8Z4bn+iJNFV(CG;K-j2>aj229gl_C6n12Jh$$h!}KVhn>*f>KcH z;^8s3t(ccVZ5<{>ZJK@Z`hn_jL{bP8Yn(XkwfRm?GlEHy=T($8Z1Mq**IM`zxN9>-yXTjfB18m_$E^JEaYn>pj`V?n#Xu;Z}#$- zw0Vw;T*&9TK$tKI7nBk9NkHzL++dZ^;<|F6KBYh2+XP-b;u`Wy{~79b%IBZa3h*3^ zF&BKfQ@Ej{7ku_#W#mNJEYYp=)bRMUXhLy2+SPMfGn;oBsiG_6KNL8{p1DjuB$UZB zA)a~BkL)7?LJXlCc}bB~j9>4s7tlnRHC5|wnycQPF_jLl!Avs2C3^lWOlHH&v`nGd zf&U!fn!JcZWha`Pl-B3XEe;(ks^`=Z5R zWyQR0u|do2`K3ec=YmWGt5Bwbu|uBW;6D8}J3{Uep7_>L6b4%(d=V4m#(I=gkn4HT zYni3cnn>@F@Wr<hFAY3Y~dW+3bte;70;G?kTn4Aw5nZ^s5|47 z4$rCHCW%9qa4)4vE%^QPMGf!ET!^LutY$G zqdT(ub5T5b+wi+OrV}z3msoy<4)`IPdHsHJggmog0K*pFYMhH!oZcgc5a)WmL?;TPSrerTVPp<#s+imF3v#!FuBNNa`#6 z!GdTCF|IIpz#(eV^mrYKThA4Bnv&vQet@%v9kuRu3EHx1-2-it@E`%9#u`)HRN#M? z7aJ{wzKczn#w^`OZ>Jb898^Xxq)0zd{3Tu7+{-sge-rQ z&0PME&wIo6W&@F|%Z8@@N3)@a_ntJ#+g{pUP7i?~3FirqU`rdf8joMG^ld?(9b7Iv z>TJgBg#)(FcW)h!_if#cWBh}f+V08GKyg|$P#KTS&%=!+0a%}O${0$i)kn9@G!}En zv)_>s?glPiLbbx)xk(lD-QbY(OP3;MSXM5E*P&_`Zks2@46n|-h$Y2L7B)iH{GAAq19h5-y0q>d^oy^y+soJu9lXxAe%jcm?=pDLFEG2kla40e!5a}mpe zdL=WlZ=@U6{>g%5a+y-lx)01V-x;wh%F{=qy#XFEAqcd+m}_!lQ)-9iiOL%&G??t| z?&NSdaLqdPdbQs%y0?uIIHY7rw1EDxtQ=DU!i{)Dkn~c$LG5{rAUYM1j5*G@oVn9~ zizz{XH(nbw%f|wI=4rw^6mNIahQpB)OQy10^}ACdLPFc2@ldVi|v@1nWLND?)53O5|fg`RZW&XpF&s3@c-R?aad!$WoH6u0B|}zt)L($E^@U- zO#^fxu9}Zw7Xl~nG1FVM6DZSR0*t!4IyUeTrnp@?)Z)*!fhd3)&s(O+3D^#m#bAem zpf#*aiG_0S^ofpm@9O7j`VfLU0+{$x!u^}3!zp=XST0N@DZTp!7LEVJgqB1g{psNr za0uVmh3_9qah14@M_pi~vAZ#jc*&aSm$hCNDsuQ-zPe&*Ii#2=2gP+DP4=DY z_Y0lUsyE6yaV9)K)!oI6+*4|spx2at*30CAx~6-5kfJzQ`fN8$!lz%hz^J6GY?mVH zbYR^JZ(Pmj6@vy-&!`$5soyy-NqB^8cCT40&R@|6s@m+ZxPs=Bu77-+Os7+bsz4nA3DrJ8#{f98ZMaj-+BD;M+Jk?pgFcZIb}m9N z{ct9T)Kye&2>l^39O4Q2@b%sY?u#&O9PO4@t0c$NUXG}(DZJ<;_oe2~e==3Z1+`Zo zFrS3ns-c}ZognVBHbg#e+1JhC(Yq7==rSJQ8J~}%94(O#_-zJKwnBXihl#hUd9B_>+T& z7eHHPRC?5ONaUiCF7w|{J`bCWS7Q&xw-Sa={j-f)n5+I=9s;E#fBQB$`DDh<^mGiF zu-m_k+)dkBvBO(VMe2O4r^sf3;sk9K!xgXJU>|t9Vm8Ty;fl5pZzw z9j|}ZD}6}t;20^qrS?YVPuPRS<39d^y0#O1o_1P{tN0?OX!lc-ICcHI@2#$cY}_CY zev|xdFcRTQ_H)1fJ7S0*SpPs8e{d+9lR~IZ^~dKx!oxz?=Dp!fD`H=LH{EeC8C&z-zK$e=!5z8NL=4zx2{hl<5z*hEmO=b-7(k5H`bA~5gT30Sjy`@-_C zKM}^so9Ti1B;DovHByJkTK87cfbF16sk-G>`Q4-txyMkyQS$d}??|Aytz^;0GxvOs zPgH>h>K+`!HABVT{sYgzy3CF5ftv6hI-NRfgu613d|d1cg^jh+SK7WHWaDX~hlIJ3 z>%WxKT0|Db1N-a4r1oPKtF--^YbP=8Nw5CNt_ZnR{N(PXI>Cm$eqi@_IRmJ9#)~ZHK_UQ8mi}w^`+4$OihUGVz!kW^qxnCFo)-RIDbA&k-Y=+*xYv5y4^VQ9S)4W5Pe?_RjAX6lS6Nz#!Hry=+PKx2|o_H_3M`}Dq{Bl_PbP(qel~P@=m}VGW*pK96 zI@fVag{DZHi}>3}<(Hv<7cVfWiaVLWr@WWxk5}GDEbB<+Aj;(c>;p1qmyAIj+R!`@#jf$ zy4`q23L-72Zs4j?W+9lQD;CYIULt%;O3jPWg2a%Zs!5OW>5h1y{Qof!p&QxNt5=T( zd5fy&7=hyq;J8%86YBOdc$BbIFxJx>dUyTh`L z-oKa=OhRK9UPVRWS`o2x53bAv+py)o)kNL6 z9W1Dlk-g6Ht@-Z^#6%`9S9`909^EMj?9R^4IxssCY-hYzei^TLq7Cj>z$AJyaU5=z zl!xiWvz0U8kY$etrcp8mL;sYqGZD!Hs-U2N{A|^oEKA482v1T%cs%G@X9M?%lX)p$ zZoC7iYTPe8yxY0Jne|s)fCRe1mU=Vb1J_&WcIyP|x4$;VSVNC`M+e#oOA`#h>pyU6 z?7FeVpk`Hsu`~T3i<_4<5fu?RkhM;@LjKo6nX>pa%8dSdgPO9~Jze;5r>Tb1Xqh5q z&SEdTXevV@PT~!O6z|oypTk7Qq+BNF5IQ(8s18c=^0@sc8Gi|3e>VKCsaZ?6=rrck zl@oF5Bd0zH?@15PxSJIRroK4Wa?1o;An;p0#%ZJ^tI=(>AJ2OY0GP$E_3(+Zz4$AQ zW)QWl<4toIJ5TeF&gNXs>_rl}glkeG#GYbHHOv-G!%dJNoIKxn)FK$5&2Zv*AFic! z@2?sY&I*PSfZ8bU#c9fdIJQa_cQijnj39-+hS@+~e*5W3bj%A}%p9N@>*tCGOk+cF zlcSzI6j%Q|2e>QG3A<86w?cx6sBtLNWF6_YR?~C)IC6_10SNoZUHrCpp6f^*+*b8` zlx4ToZZuI0XW1W)24)92S)y0QZa);^NRTX6@gh8@P?^=#2dV9s4)Q@K+gnc{6|C}& zDLHr7nDOLrsH)L@Zy{C_2UrYdZ4V{|{c8&dRG;wY`u>w%$*p>PO_}3`Y21pk?8Wtq zGwIXTulf7AO2FkPyyh2TZXM1DJv>hI`}x`OzQI*MBc#=}jaua&czSkI2!s^rOci|V zFkp*Vbiz5vWa9HPFXMi=BV&n3?1?%8#1jq?p^3wAL`jgcF)7F4l<(H^!i=l-(OTDE zxf2p71^WRIExLf?ig0FRO$h~aA23s#L zuZPLkm>mDwBeIu*C7@n@_$oSDmdWY7*wI%aL73t~`Yu7YwE-hxAATmOi0dmB9|D5a zLsR7OQcA0`vN9m0L|5?qZ|jU+cx3_-K2!K$zDbJ$UinQy<9nd5ImWW5n^&=Gg>Gsh zY0u?m1e^c~Ug39M{{5q2L~ROq#c{eG8Oy#5h_q=#AJj2Yops|1C^nv0D1=fBOdfAG z%>=vl*+_w`&M7{qE#$xJJp_t>bSh7Mpc(RAvli9kk3{KgG5K@a-Ue{IbU{`umXrR3ra5Y7xiX42+Q%N&-0#`ae_ z#$Y6Wa++OPEDw@96Zz##PFo9sADepQe|hUy!Zzc2C(L`k9&=a8XFr+!hIS>D2{pdGP1SzwyaGLiH3j--P>U#TWw90t8{8Bt%m7Upspl#=*hS zhy|(XL6HOqBW}Og^tLX7 z+`b^L{O&oqjwbxDDTg2B;Yh2(fW>%S5Pg8^u1p*EFb z`(fbUM0`afawYt%VBfD&b3MNJ39~Ldc@SAuzsMiN%E}5{uUUBc7hc1IUE~t-Y9h@e7PC|sv$xGx=hZiMXNJxz5V(np%6u{n24iWX#!8t#>Ob$in<>dw96H)oGdTHnU zSM+BPss*5)Wz@+FkooMxxXZP1{2Nz7a6BB~-A_(c&OiM)UUNoa@J8FGxtr$)`9;|O z(Q?lq1Q+!E`}d?KemgC!{nB1JJ!B>6J@XGQp9NeQvtbM2n7F%v|IS=XWPVZY(>oq$ zf=}8O_x`KOxZoGnp=y24x}k6?gl_0dTF!M!T`={`Ii{GnT1jrG9gPh)R=RZG8lIR| z{ZJ6`x8n|y+lZuy${fuEDTAf`OP!tGySLXD}ATJO5UoZv|Xo3%7O~L63+kw}v)Ci=&tWx3bQJfL@5O18CbPlkR^IcKA zy1=^Vl-K-QBP?9^R`@;czcUw;Enbbyk@vJQB>BZ4?;DM%BUf^eZE+sOy>a){qCY6Y znYy;KGpch-zf=5|p#SoAV+ie8M5(Xg-{FoLx-wZC9IutT!(9rJ8}=!$!h%!J+vE2e z(sURwqCC35v?1>C1L)swfA^sr16{yj7-zbT6Rf26-JoEt%U?+|rQ zeBuGohE?@*!zR9)1P|3>KmJSgK*fOt>N>j}LJB`>o(G#Dduvx7@DY7};W7K;Yj|8O zGF<+gTuoIKe7Rf+LQG3-V1L^|E;F*}bQ-{kuHq}| ze_NwA7~US19sAZ)@a`g*zkl*ykv2v3tPrb4Og2#?k6Lc7@1I~+ew48N&03hW^1Cx+ zfk5Lr4-n=#HYg<7ka5i>2A@ZeJ60gl)IDX!!p zzfXZQ?GrT>JEKl7$SH!otzK6=0dIlqN)c23YLB&Krf9v-{@V8p+-e2`ujFR!^M%*; ze_7(Jh$QgoqwB!HbX=S+^wqO15O_TQ0-qX8f-|&SOuo3ZE{{9Jw5{}>MhY}|GBhO& zv48s_B=9aYQfa;d>~1Z$y^oUUaDer>7ve5+Gf?rIG4GZ!hRKERlRNgg_C{W_!3tsI2TWbX8f~MY)1Q`6Wj&JJ~*;ay_0@e zzx+mE-pu8{cEcVfBqsnm=jFU?H}xj@%CAx#NO>3 z_re3Rq%d1Y7VkKy{=S73&p;4^Praw6Y59VCP6M?!Kt7{v#DG#tz?E)`K95gH_mEvb z%$<~_mQ$ad?~&T=O0i0?`YSp?E3Dj?V>n+uTRHAXn`l!pH9Mr}^D1d@mkf+;(tV45 zH_yfs^kOGLXlN*0GU;O&{=awxd?&`{JPRr$z<1HcAO2K`K}92$wC}ky&>;L?#!(`w z68avZGvb728!vgw>;8Z8I@mLtI`?^u6R>sK4E7%=y)jpmE$fH!Dj*~(dy~-2A5Cm{ zl{1AZw`jaDmfvaB?jvKwz!GC}@-Dz|bFm1OaPw(ia#?>vF7Y5oh{NVbyD~cHB1KFn z9C@f~X*Wk3>sQH9#D~rLPslAd26@AzMh=_NkH_yTNXx6-AdbAb z{Ul89YPHslD?xAGzOlQ*aMYUl6#efCT~WI zOvyiewT=~l1W(_2cEd(8rDywOwjM-7P9!8GCL-1<9KXXO=6%!9=W++*l1L~gRSxLVd8K=A7&t52ql=J&BMQu{fa6y zXO_e>d?4X)xp2V8e3xIQGbq@+vo#&n>-_WreTTW0Yr?|YRPP43cDYACMQ(3t6(?_k zfgDOAU^-pew_f5U#WxRXB30wcfDS3;k~t@b@w^GG&<5n$Ku?tT(%bQH(@UHQGN)N|nfC~7?(etU`}XB)$>KY;s=bYGY#kD%i9fz= z2nN9l?UPMKYwn9bX*^xX8Y@%LNPFU>s#Ea1DaP%bSioqRWi9JS28suTdJycYQ+tW7 zrQ@@=13`HS*dVKaVgcem-45+buD{B;mUbY$YYULhxK)T{S?EB<8^YTP$}DA{(&)@S zS#<8S96y9K2!lG^VW-+CkfXJIH;Vo6wh)N}!08bM$I7KEW{F6tqEQ?H@(U zAqfi%KCe}2NUXALo;UN&k$rU0BLNC$24T_mcNY(a@lxR`kqNQ0z%8m>`&1ro40HX} z{{3YQ;2F9JnVTvDY<4)x+88i@MtXE6TBd7POk&QfKU-F&*C`isS(T_Q@}K)=zW#K@ zbXpcAkTT-T5k}Wj$dMZl7=GvlcCMt}U`#Oon1QdPq%>9J$rKTY8#OmlnNWBYwafhx zqFnym@okL#Xw>4SeRFejBnZzY$jbO)e^&&sHBgMP%Ygfi!9_3hp17=AwLBNFTimf0 zw6BHNXw19Jg_Ud6`5n#gMpqe%9!QB^_7wAYv8nrW94A{*t8XZu0UT&`ZHfkd(F{Px zD&NbRJP#RX<=+sEeGs2`9_*J2OlECpR;4uJie-d__m*(aaGE}HIo+3P{my@;a~9Y$ zHBXVJ83#&@o6{M+pE9^lI<4meLLFN_3rwgR4IRyp)~OF0n+#ORrcJ2_On9-78bWbG zuCO0esc*n1X3@p1?lN{qWS?l7J$^jbpeel{w~51*0CM+q9@9X=>%MF(ce~om(}?td zjkUmdUR@LOn-~6LX#=@a%rvj&>DFEoQscOvvC@&ZB5jVZ-;XzAshwx$;Qf@U41W=q zOSSjQGQV8Qi3*4DngNMIM&Cxm7z*-K`~Bl(TcEUxjQ1c=?)?wF8W1g;bAR%sM#LK( z_Op?=P%)Z+J!>vpN`By0$?B~Out%P}kCriDq@}In&fa_ZyKV+nLM0E?hfxuu%ciUz z>yAk}OydbWNl7{)#112j&qmw;*Uj&B;>|;Qwfc?5wIYIHH}s6Mve@5c5r+y)jK9i( z_}@uC(98g)==AGkVN?4>o@w=7x9qhW^ zB(b5%%4cHSV?3M?k&^py)j*LK16T^Ef4tb05-h-tyrjt$5!oo4spEfXFK7r_Gfv7#x$bsR7T zs;dqxzUg9v&GjsQGKTP*=B(;)be2aN+6>IUz+Hhw-n>^|`^xu*xvjGPaDoFh2W4-n z@Wji{5Y$m>@Vt7TE_QVQN4*vcfWv5VY-dT0SV=l=8LAEq1go*f zkjukaDV=3kMAX6GAf0QOQHwP^{Z^=#Lc)sh`QB)Ftl&31jABvq?8!3bt7#8vxB z53M{4{GR4Hl~;W3r}PgXSNOt477cO62Yj(HcK&30zsmWpvAplCtpp&mC{`2Ue*Bwu zF&UX1;w%`Bs1u%RtGPFl=&sHu@Q1nT`z={;5^c^^S~^?2-?<|F9RT*KQmfgF!7=wD@hytxbD;=9L6PZrK*1<4HMObNWehA62DtTy)q5H|57 z9dePuC!1;0MMRRl!S@VJ8qG=v^~aEU+}2Qx``h1LII!y{crP2ky*R;Cb;g|r<#ryo zju#s4dE?5CTIZKc*O4^3qWflsQ(voX>(*_JP7>Q&$%zCAIBTtKC^JUi@&l6u&t0hXMXjz_y!;r@?k|OU9aD%938^TZ>V? zqJmom_6dz4DBb4Cgs_Ef@}F%+cRCR%UMa9pi<-KHN;t#O@cA%(LO1Rb=h?5jiTs93 zPLR78p+3t>z4|j=<>2i4b`ketv}9Ax#B0)hn7@bFl;rDfP8p7u9XcEb!5*PLKB(s7wQC2kzI^@ae)|DhNDmSy1bOLid%iIap@24A(q2XI!z_hkl-$1T10 z+KKugG4-}@u8(P^S3PW4x>an;XWEF-R^gB{`t8EiP{ZtAzoZ!JRuMRS__-Gg#Qa3{<;l__CgsF+nfmFNi}p z>rV!Y6B@cC>1up)KvaEQiAvQF!D>GCb+WZsGHjDeWFz?WVAHP65aIA8u6j6H35XNYlyy8>;cWe3ekr};b;$9)0G`zsc9LNsQ&D?hvuHRpBxH)r-1t9|Stc*u<}Ol&2N+wPMom}d15_TA=Aprp zjN-X3*Af$7cDWMWp##kOH|t;c2Pa9Ml4-)o~+7P;&q8teF-l}(Jt zTGKOQqJTeT!L4d}Qw~O0aanA$Vn9Rocp-MO4l*HK)t%hcp@3k0%&_*wwpKD6ThM)R z8k}&7?)YS1ZYKMiy?mn>VXiuzX7$Ixf7EW8+C4K^)m&eLYl%#T=MC;YPvD&w#$MMf zQ=>`@rh&&r!@X&v%ZlLF42L_c=5dSU^uymKVB>5O?AouR3vGv@ei%Z|GX5v1GK2R* zi!!}?+-8>J$JH^fPu@)E6(}9$d&9-j51T^n-e0Ze%Q^)lxuex$IL^XJ&K2oi`wG}QVGk2a7vC4X?+o^z zsCK*7`EUfSuQA*K@Plsi;)2GrayQOG9OYF82Hc@6aNN5ulqs1Of-(iZQdBI^U5of^ zZg2g=Xtad7$hfYu6l~KDQ}EU;oIj(3nO#u9PDz=eO3(iax7OCmgT2p_7&^3q zg7aQ;Vpng*)kb6=sd5?%j5Dm|HczSChMo8HHq_L8R;BR5<~DVyU$8*Tk5}g0eW5x7 z%d)JFZ{(Y<#OTKLBA1fwLM*fH7Q~7Sc2Ne;mVWqt-*o<;| z^1@vo_KTYaMnO$7fbLL+qh#R$9bvnpJ$RAqG+z8h|} z3F5iwG*(sCn9Qbyg@t0&G}3fE0jGq3J!JmG2K&$urx^$z95) z7h?;4vE4W=v)uZ*Eg3M^6f~|0&T)2D;f+L_?M*21-I1pnK(pT$5l#QNlT`SidYw~o z{`)G)Asv#cue)Ax1RNWiRUQ(tQ(bzd-f2U4xlJK+)ZWBxdq#fp=A>+Qc%-tl(c)`t z$e2Ng;Rjvnbu7((;v4LF9Y1?0el9hi!g>G{^37{ z`^s-03Z5jlnD%#Mix19zkU_OS|86^_x4<0(*YbPN}mi-$L?Z4K(M|2&VV*n*ZYN_UqI?eKZi3!b)i z%n3dzUPMc-dc|q}TzvPy!VqsEWCZL(-eURDRG4+;Eu!LugSSI4Fq$Ji$Dp08`pfP_C5Yx~`YKcywlMG;$F z)R5!kVml_Wv6MSpeXjG#g?kJ0t_MEgbXlUN3k|JJ%N>|2xn8yN>>4qxh!?dGI}s|Y zDTKd^JCrRSN+%w%D_uf=Tj6wIV$c*g8D96jb^Kc#>5Fe-XxKC@!pIJw0^zu;`_yeb zhUEm-G*C=F+jW%cP(**b61fTmPn2WllBr4SWNdKe*P8VabZsh0-R|?DO=0x`4_QY) zR7sthW^*BofW7{Sak&S1JdiG?e=SfL24Y#w_)xrBVhGB-13q$>mFU|wd9Xqe-o3{6 zSn@@1@&^)M$rxb>UmFuC+pkio#T;mSnroMVZJ%nZ!uImi?%KsIX#@JU2VY(`kGb1A z7+1MEG)wd@)m^R|a2rXeviv$!emwcY(O|M*xV!9%tBzarBOG<4%gI9SW;Um_gth4=gznYzOFd)y8e+3APCkL)i-OI`;@7-mCJgE`js(M} z;~ZcW{{FMVVO)W>VZ}ILouF#lWGb%Couu}TI4kubUUclW@jEn6B_^v!Ym*(T*4HF9 zWhNKi8%sS~viSdBtnrq!-Dc5(G^XmR>DFx8jhWvR%*8!m*b*R8e1+`7{%FACAK`7 zzdy8TmBh?FVZ0vtw6npnWwM~XjF2fNvV#ZlGG z?FxHkXHN>JqrBYoPo$)zNC7|XrQfcqmEXWud~{j?La6@kbHG@W{xsa~l1=%eLly8B z4gCIH05&Y;6O2uFSopNqP|<$ml$N40^ikxw0`o<~ywS1(qKqQN!@?Ykl|bE4M?P+e zo$^Vs_+x)iuw?^>>`$&lOQOUkZ5>+OLnRA)FqgpDjW&q*WAe(_mAT6IKS9;iZBl8M z<@=Y%zcQUaSBdrs27bVK`c$)h6A1GYPS$y(FLRD5Yl8E3j0KyH08#8qLrsc_qlws; znMV%Zq8k+&T2kf%6ZO^2=AE9>?a587g%-={X}IS~P*I(NeCF9_9&`)|ok0iiIun zo+^odT0&Z4k;rn7I1v87=z!zKU(%gfB$(1mrRYeO$sbqM22Kq68z9wgdg8HBxp>_< zn9o%`f?sVO=IN#5jSX&CGODWlZfQ9A)njK2O{JutYwRZ?n0G_p&*uwpE`Md$iQxrd zoQfF^b8Ou)+3BO_3_K5y*~?<(BF@1l+@?Z6;^;U>qlB)cdro;rxOS1M{Az$s^9o5sXDCg8yD<=(pKI*0e zLk>@lo#&s0)^*Q+G)g}C0IErqfa9VbL*Qe=OT@&+N8m|GJF7jd83vY#SsuEv2s{Q> z>IpoubNs>D_5?|kXGAPgF@mb_9<%hjU;S0C8idI)a=F#lPLuQJ^7OnjJlH_Sks9JD zMl1td%YsWq3YWhc;E$H1<0P$YbSTqs`JKY%(}svsifz|h8BHguL82dBl+z0^YvWk8 zGy;7Z0v5_FJ2A$P0wIr)lD?cPR%cz>kde!=W%Ta^ih+Dh4UKdf7ip?rBz@%y2&>`6 zM#q{JXvW9ZlaSk1oD!n}kSmcDa2v6T^Y-dy+#fW^y>eS8_%<7tWXUp8U@s$^{JFfKMjDAvR z$YmVB;n3ofl!ro9RNT!TpQpcycXCR}$9k5>IPWDXEenQ58os?_weccrT+Bh5sLoiH zZ_7~%t(vT)ZTEO= zb0}@KaD{&IyK_sd8b$`Qz3%UA`nSo zn``!BdCeN!#^G;lK@G2ron*0jQhbdw)%m$2;}le@z~PSLnU-z@tL)^(p%P>OO^*Ff zNRR9oQ`W+x^+EU+3BpluwK77|B3=8QyT|$V;02bn_LF&3LhLA<#}{{)jE)}CiW%VEU~9)SW+=F%7U-iYlQ&q!#N zwI2{(h|Pi&<8_fqvT*}FLN^0CxN}#|3I9G_xmVg$gbn2ZdhbmGk7Q5Q2Tm*ox8NMo zv`iaZW|ZEOMyQga5fts?&T-eCCC9pS0mj7v0SDkD=*^MxurP@89v&Z#3q{FM!a_nr zb?KzMv`BBFOew>4!ft@A&(v-kWXny-j#egKef|#!+3>26Qq0 zv!~8ev4G`7Qk>V1TaMT-&ziqoY3IJp8_S*%^1j73D|=9&;tDZH^!LYFMmME4*Wj(S zRt~Q{aLb_O;wi4u&=}OYuj}Lw*j$@z*3>4&W{)O-oi@9NqdoU!=U%d|se&h?^$Ip# z)BY+(1+cwJz!yy4%l(aLC;T!~Ci>yAtXJb~b*yr&v7f{YCU8P|N1v~H`xmGsG)g)y z4%mv=cPd`s7a*#OR7f0lpD$ueP>w8qXj0J&*7xX+U!uat5QNk>zwU$0acn5p=$88L=jn_QCSYkTV;1~(yUem#0gB`FeqY98sf=>^@ z_MCdvylv~WL%y_%y_FE1)j;{Szj1+K7Lr_y=V+U zk6Tr;>XEqlEom~QGL!a+wOf(@ZWoxE<$^qHYl*H1a~kk^BLPn785%nQb$o;Cuz0h& za9LMx^bKEbPS%e8NM33Jr|1T|ELC(iE!FUci38xW_Y7kdHid#2ie+XZhP;2!Z;ZAM zB_cXKm)VrPK!SK|PY00Phwrpd+x0_Aa;}cDQvWKrwnQrqz##_gvHX2ja?#_{f#;bz`i>C^^ zTLDy;6@HZ~XQi7rph!mz9k!m;KchA)uMd`RK4WLK7)5Rl48m#l>b(#`WPsl<0j z-sFkSF6>Nk|LKnHtZ`W_NnxZP62&w)S(aBmmjMDKzF%G;3Y?FUbo?>b5;0j8Lhtc4 zr*8d5Y9>g@FFZaViw7c16VsHcy0u7M%6>cG1=s=Dtx?xMJSKIu9b6GU8$uSzf43Y3 zYq|U+IWfH;SM~*N1v`KJo!|yfLxTFS?oHsr3qvzeVndVV^%BWmW6re_S!2;g<|Oao z+N`m#*i!)R%i1~NO-xo{qpwL0ZrL7hli;S z3L0lQ_z}z`fdK39Mg~Zd*%mBdD;&5EXa~@H(!###L`ycr7gW`f)KRuqyHL3|uyy3h zSS^td#E&Knc$?dXs*{EnPYOp^-vjAc-h4z#XkbG&REC7;0>z^^Z}i8MxGKerEY z>l?(wReOlXEsNE5!DO&ZWyxY)gG#FSZs%fXuzA~XIAPVp-%yb2XLSV{1nH6{)5opg z(dZKckn}Q4Li-e=eUDs1Psg~5zdn1>ql(*(nn6)iD*OcVkwmKL(A{fix(JhcVB&}V zVt*Xb!{gzvV}dc446>(D=SzfCu7KB`oMjv6kPzSv&B>>HLSJP|wN`H;>oRw*tl#N) z*zZ-xwM7D*AIsBfgqOjY1Mp9aq$kRa^dZU_xw~KxP;|q(m+@e+YSn~`wEJzM|Ippb zzb@%;hB7iH4op9SqmX?j!KP2chsb79(mFossBO-Zj8~L}9L%R%Bw<`^X>hjkCY5SG z7lY!8I2mB#z)1o;*3U$G)3o0A&{0}#B;(zPd2`OF`Gt~8;0Re8nIseU z_yzlf$l+*-wT~_-cYk$^wTJ@~7i@u(CZs9FVkJCru<*yK8&>g+t*!JqCN6RH%8S-P zxH8+Cy#W?!;r?cLMC(^BtAt#xPNnwboI*xWw#T|IW^@3|q&QYY6Ehxoh@^URylR|T zne-Y6ugE^7p5bkRDWIh)?JH5V^ub82l-LuVjDr7UT^g`q4dB&mBFRWGL_C?hoeL(% zo}ocH5t7|1Mda}T!^{Qt9vmA2ep4)dQSZO>?Eq8}qRp&ZJ?-`Tnw+MG(eDswP(L*X3ahC2Ad0_wD^ff9hfzb%Jd`IXx5 zae@NMzBXJDwJS?7_%!TB^E$N8pvhOHDK$7YiOelTY`6KX8hK6YyT$tk*adwN>s^Kp zwM3wGVPhwKU*Yq-*BCs}l`l#Tej(NQ>jg*S0TN%D+GcF<14Ms6J`*yMY;W<-mMN&-K>((+P}+t+#0KPGrzjP zJ~)=Bcz%-K!L5ozIWqO(LM)l_9lVOc4*S65&DKM#TqsiWNG{(EZQw!bc>qLW`=>p-gVJ;T~aN2D_- z{>SZC=_F+%hNmH6ub%Ykih0&YWB!%sd%W5 zHC2%QMP~xJgt4>%bU>%6&uaDtSD?;Usm}ari0^fcMhi_)JZgb1g5j zFl4`FQ*%ROfYI}e7RIq^&^a>jZF23{WB`T>+VIxj%~A-|m=J7Va9FxXV^%UwccSZd zuWINc-g|d6G5;95*%{e;9S(=%yngpfy+7ao|M7S|Jb0-4+^_q-uIqVS&ufU880UDH*>(c)#lt2j zzvIEN>>$Y(PeALC-D?5JfH_j+O-KWGR)TKunsRYKLgk7eu4C{iF^hqSz-bx5^{z0h ze2+u>Iq0J4?)jIo)}V!!m)%)B;a;UfoJ>VRQ*22+ncpe9f4L``?v9PH&;5j{WF?S_C>Lq>nkChZB zjF8(*v0c(lU^ZI-)_uGZnnVRosrO4`YinzI-RSS-YwjYh3M`ch#(QMNw*)~Et7Qpy z{d<3$4FUAKILq9cCZpjvKG#yD%-juhMj>7xIO&;c>_7qJ%Ae8Z^m)g!taK#YOW3B0 zKKSMOd?~G4h}lrZbtPk)n*iOC1~mDhASGZ@N{G|dF|Q^@1ljhe=>;wusA&NvY*w%~ zl+R6B^1yZiF)YN>0ms%}qz-^U-HVyiN3R9k1q4)XgDj#qY4CE0)52%evvrrOc898^ z*^)XFR?W%g0@?|6Mxo1ZBp%(XNv_RD-<#b^?-Fs+NL^EUW=iV|+Vy*F%;rBz~pN7%-698U-VMfGEVnmEz7fL1p)-5sLT zL;Iz>FCLM$p$c}g^tbkGK1G$IALq1Gd|We@&TtW!?4C7x4l*=4oF&&sr0Hu`x<5!m zhX&&Iyjr?AkNXU_5P_b^Q3U9sy#f6ZF@2C96$>1k*E-E%DjwvA{VL0PdU~suN~DZo zm{T!>sRdp`Ldpp9olrH@(J$QyGq!?#o1bUo=XP2OEuT3`XzI>s^0P{manUaE4pI%! zclQq;lbT;nx7v3tR9U)G39h?ryrxzd0xq4KX7nO?piJZbzT_CU&O=T(Vt;>jm?MgC z2vUL#*`UcMsx%w#vvjdamHhmN!(y-hr~byCA-*iCD};#l+bq;gkwQ0oN=AyOf@8ow>Pj<*A~2*dyjK}eYdN);%!t1 z6Y=|cuEv-|5BhA?n2Db@4s%y~(%Wse4&JXw=HiO48%c6LB~Z0SL1(k^9y?ax%oj~l zf7(`iAYLdPRq*ztFC z7VtAb@s{as%&Y;&WnyYl+6Wm$ru*u!MKIg_@01od-iQft0rMjIj8e7P9eKvFnx_X5 zd%pDg-|8<>T2Jdqw>AII+fe?CgP+fL(m0&U??QL8YzSjV{SFi^vW~;wN@or_(q<0Y zRt~L}#JRcHOvm$CB)T1;;7U>m%)QYBLTR)KTARw%zoDxgssu5#v{UEVIa<>{8dtkm zXgbCGp$tfue+}#SD-PgiNT{Zu^YA9;4BnM(wZ9-biRo_7pN}=aaimjYgC=;9@g%6< zxol5sT_$<8{LiJ6{l1+sV)Z_QdbsfEAEMw!5*zz6)Yop?T0DMtR_~wfta)E6_G@k# zZRP11D}$ir<`IQ`<(kGfAS?O-DzCyuzBq6dxGTNNTK?r^?zT30mLY!kQ=o~Hv*k^w zvq!LBjW=zzIi%UF@?!g9vt1CqdwV(-2LYy2=E@Z?B}JDyVkluHtzGsWuI1W5svX~K z&?UJ45$R7g>&}SFnLnmw09R2tUgmr_w6mM9C}8GvQX>nL&5R#xBqnp~Se(I>R42`T zqZe9p6G(VzNB3QD><8+y%{e%6)sZDRXTR|MI zM#eZmao-~_`N|>Yf;a;7yvd_auTG#B?Vz5D1AHx=zpVUFe7*hME z+>KH5h1In8hsVhrstc>y0Q!FHR)hzgl+*Q&5hU9BVJlNGRkXiS&06eOBV^dz3;4d5 zeYX%$62dNOprZV$px~#h1RH?_E%oD6y;J;pF%~y8M)8pQ0olYKj6 zE+hd|7oY3ot=j9ZZ))^CCPADL6Jw%)F@A{*coMApcA$7fZ{T@3;WOQ352F~q6`Mgi z$RI6$8)a`Aaxy<8Bc;{wlDA%*%(msBh*xy$L-cBJvQ8hj#FCyT^%+Phw1~PaqyDou^JR0rxDkSrmAdjeYDFDZ`E z)G3>XtpaSPDlydd$RGHg;#4|4{aP5c_Om z2u5xgnhnA)K%8iU==}AxPxZCYC)lyOlj9as#`5hZ=<6<&DB%i_XCnt5=pjh?iusH$ z>)E`@HNZcAG&RW3Ys@`Ci{;8PNzE-ZsPw$~Wa!cP$ye+X6;9ceE}ah+3VY7Mx}#0x zbqYa}eO*FceiY2jNS&2cH9Y}(;U<^^cWC5Ob&)dZedvZA9HewU3R;gRQ)}hUdf+~Q zS_^4ds*W1T#bxS?%RH&<739q*n<6o|mV;*|1s>ly-Biu<2*{!!0#{_234&9byvn0* z5=>{95Zfb{(?h_Jk#ocR$FZ78O*UTOxld~0UF!kyGM|nH%B*qf)Jy}N!uT9NGeM19 z-@=&Y0yGGo_dw!FD>juk%P$6$qJkj}TwLBoefi;N-$9LAeV|)|-ET&culW9Sb_pc_ zp{cXI0>I0Jm_i$nSvGnYeLSSj{ccVS2wyL&0x~&5v;3Itc82 z5lIAkfn~wcY-bQB$G!ufWt%qO;P%&2B_R5UKwYxMemIaFm)qF1rA zc>gEihb=jBtsXCi0T%J37s&kt*3$s7|6)L(%UiY)6axuk{6RWIS8^+u;)6!R?Sgap z9|6<0bx~AgVi|*;zL@2x>Pbt2Bz*uv4x-`{F)XatTs`S>unZ#P^ZiyjpfL_q2z^fqgR-fbOcG=Y$q>ozkw1T6dH8-)&ww+z?E0 zR|rV(9bi6zpX3Ub>PrPK!{X>e$C66qCXAeFm)Y+lX8n2Olt7PNs*1^si)j!QmFV#t z0P2fyf$N^!dyTot&`Ew5{i5u<8D`8U`qs(KqaWq5iOF3x2!-z65-|HsyYz(MAKZ?< zCpQR;E)wn%s|&q(LVm0Ab>gdmCFJeKwVTnv@Js%!At;I=A>h=l=p^&<4;Boc{$@h< z38v`3&2wJtka@M}GS%9!+SpJ}sdtoYzMevVbnH+d_eMxN@~~ zZq@k)7V5f8u!yAX2qF3qjS7g%n$JuGrMhQF!&S^7(%Y{rP*w2FWj(v_J{+Hg*}wdWOd~pHQ19&n3RWeljK9W%sz&Y3Tm3 zR`>6YR54%qBHGa)2xbs`9cs_EsNHxsfraEgZ)?vrtooeA0sPKJK7an){ngtV@{SBa zkO6ORr1_Xqp+`a0e}sC*_y(|RKS13ikmHp3C^XkE@&wjbGWrt^INg^9lDz#B;bHiW zkK4{|cg08b!yHFSgPca5)vF&gqCgeu+c82%&FeM^Bb}GUxLy-zo)}N;#U?sJ2?G2BNe*9u_7kE5JeY!it=f`A_4gV3} z`M!HXZy#gN-wS!HvHRqpCHUmjiM;rVvpkC!voImG%OFVN3k(QG@X%e``VJSJ@Z7tb z*Onlf>z^D+&$0!4`IE$;2-NSO9HQWd+UFW(r;4hh;(j^p4H-~6OE!HQp^96v?{9Zt z;@!ZcccV%C2s6FMP#qvo4kG6C04A>XILt>JW}%0oE&HM5f6 zYLD!;My>CW+j<~=Wzev{aYtx2ZNw|ptTFV(4;9`6Tmbz6K1)fv4qPXa2mtoPt&c?P zhmO+*o8uP3ykL6E$il00@TDf6tOW7fmo?Oz_6GU^+5J=c22bWyuH#aNj!tT-^IHrJ zu{aqTYw@q;&$xDE*_kl50Jb*dp`(-^p={z}`rqECTi~3 z>0~A7L6X)=L5p#~$V}gxazgGT7$3`?a)zen>?TvAuQ+KAIAJ-s_v}O6@`h9n-sZk> z`3{IJeb2qu9w=P*@q>iC`5wea`KxCxrx{>(4{5P+!cPg|pn~;n@DiZ0Y>;k5mnKeS z!LIfT4{Lgd=MeysR5YiQKCeNhUQ;Os1kAymg6R!u?j%LF z4orCszIq_n52ulpes{(QN|zirdtBsc{9^Z72Ycb2ht?G^opkT_#|4$wa9`)8k3ilU z%ntAi`nakS1r10;#k^{-ZGOD&Z2|k=p40hRh5D7(&JG#Cty|ECOvwsSHkkSa)36$4 z?;v#%@D(=Raw(HP5s>#4Bm?f~n1@ebH}2tv#7-0l-i^H#H{PC|F@xeNS+Yw{F-&wH z07)bj8MaE6`|6NoqKM~`4%X> zKFl&7g1$Z3HB>lxn$J`P`6GSb6CE6_^NA1V%=*`5O!zP$a7Vq)IwJAki~XBLf=4TF zPYSL}>4nOGZ`fyHChq)jy-f{PKFp6$plHB2=;|>%Z^%)ecVue(*mf>EH_uO^+_zm? zJATFa9SF~tFwR#&0xO{LLf~@}s_xvCPU8TwIJgBs%FFzjm`u?1699RTui;O$rrR{# z1^MqMl5&6)G%@_k*$U5Kxq84!AdtbZ!@8FslBML}<`(Jr zenXrC6bFJP=R^FMBg7P?Pww-!a%G@kJH_zezKvuWU0>m1uyy}#Vf<$>u?Vzo3}@O% z1JR`B?~Tx2)Oa|{DQ_)y9=oY%haj!80GNHw3~qazgU-{|q+Bl~H94J!a%8UR?XsZ@ z0*ZyQugyru`V9b(0OrJOKISfi89bSVR zQy<+i_1XY}4>|D%X_`IKZUPz6=TDb)t1mC9eg(Z=tv zq@|r37AQM6A%H%GaH3szv1L^ku~H%5_V*fv$UvHl*yN4iaqWa69T2G8J2f3kxc7UE zOia@p0YNu_q-IbT%RwOi*|V|&)e5B-u>4=&n@`|WzH}BK4?33IPpXJg%`b=dr_`hU z8JibW_3&#uIN_#D&hX<)x(__jUT&lIH$!txEC@cXv$7yB&Rgu){M`9a`*PH} zRcU)pMWI2O?x;?hzR{WdzKt^;_pVGJAKKd)F$h;q=Vw$MP1XSd<;Mu;EU5ffyKIg+ z&n-Nb?h-ERN7(fix`htopPIba?0Gd^y(4EHvfF_KU<4RpN0PgVxt%7Yo99X*Pe|zR z?ytK&5qaZ$0KSS$3ZNS$$k}y(2(rCl=cuYZg{9L?KVgs~{?5adxS))Upm?LDo||`H zV)$`FF3icFmxcQshXX*1k*w3O+NjBR-AuE70=UYM*7>t|I-oix=bzDwp2*RoIwBp@r&vZukG; zyi-2zdyWJ3+E?{%?>e2Ivk`fAn&Ho(KhGSVE4C-zxM-!j01b~mTr>J|5={PrZHOgO zw@ND3=z(J7D>&C7aw{zT>GHhL2BmUX0GLt^=31RRPSnjoUO9LYzh_yegyPoAKhAQE z>#~O27dR4&LdQiak6={9_{LN}Z>;kyVYKH^d^*!`JVSXJlx#&r4>VnP$zb{XoTb=> zZsLvh>keP3fkLTIDdpf-@(ADfq4=@X=&n>dyU0%dwD{zsjCWc;r`-e~X$Q3NTz_TJ zOXG|LMQQIjGXY3o5tBm9>k6y<6XNO<=9H@IXF;63rzsC=-VuS*$E{|L_i;lZmHOD< zY92;>4spdeRn4L6pY4oUKZG<~+8U-q7ZvNOtW0i*6Q?H`9#U3M*k#4J;ek(MwF02x zUo1wgq9o6XG#W^mxl>pAD)Ll-V5BNsdVQ&+QS0+K+?H-gIBJ-ccB1=M_hxB6qcf`C zJ?!q!J4`kLhAMry4&a_0}up{CFevcjBl|N(uDM^N5#@&-nQt2>z*U}eJGi}m5f}l|IRVj-Q;a>wcLpK5RRWJ> zysdd$)Nv0tS?b~bw1=gvz3L_ZAIdDDPj)y|bp1;LE`!av!rODs-tlc}J#?erTgXRX z$@ph%*~_wr^bQYHM7<7=Q=45v|Hk7T=mDpW@OwRy3A_v`ou@JX5h!VI*e((v*5Aq3 zVYfB4<&^Dq5%^?~)NcojqK`(VXP$`#w+&VhQOn%;4pCkz;NEH6-FPHTQ+7I&JE1+Ozq-g43AEZV>ceQ^9PCx zZG@OlEF~!Lq@5dttlr%+gNjRyMwJdJU(6W_KpuVnd{3Yle(-p#6erIRc${l&qx$HA z89&sp=rT7MJ=DuTL1<5{)wtUfpPA|Gr6Q2T*=%2RFm@jyo@`@^*{5{lFPgv>84|pv z%y{|cVNz&`9C*cUely>-PRL)lHVErAKPO!NQ3<&l5(>Vp(MuJnrOf^4qpIa!o3D7( z1bjn#Vv$#or|s7Hct5D@%;@48mM%ISY7>7@ft8f?q~{s)@BqGiupoK1BAg?PyaDQ1 z`YT8{0Vz{zBwJ={I4)#ny{RP{K1dqzAaQN_aaFC%Z>OZ|^VhhautjDavGtsQwx@WH zr|1UKk^+X~S*RjCY_HN!=Jx>b6J8`Q(l4y|mc<6jnkHVng^Wk(A13-;AhawATsmmE#H%|8h}f1frs2x@Fwa_|ea+$tdG2Pz{7 z!ox^w^>^Cv4e{Xo7EQ7bxCe8U+LZG<_e$RnR?p3t?s^1Mb!ieB z#@45r*PTc_yjh#P=O8Zogo+>1#|a2nJvhOjIqKK1U&6P)O%5s~M;99O<|Y9zomWTL z666lK^QW`)cXV_^Y05yQZH3IRCW%25BHAM$c0>w`x!jh^15Zp6xYb!LoQ zr+RukTw0X2mxN%K0%=8|JHiaA3pg5+GMfze%9o5^#upx0M?G9$+P^DTx7~qq9$Qoi zV$o)yy zuUq>3c{_q+HA5OhdN*@*RkxRuD>Bi{Ttv_hyaaB;XhB%mJ2Cb{yL;{Zu@l{N?!GKE7es6_9J{9 zO(tmc0ra2;@oC%SS-8|D=omQ$-Dj>S)Utkthh{ovD3I%k}HoranSepC_yco2Q8 zY{tAuPIhD{X`KbhQIr%!t+GeH%L%q&p z3P%<-S0YY2Emjc~Gb?!su85}h_qdu5XN2XJUM}X1k^!GbwuUPT(b$Ez#LkG6KEWQB z7R&IF4srHe$g2R-SB;inW9T{@+W+~wi7VQd?}7||zi!&V^~o0kM^aby7YE_-B63^d zf_uo8#&C77HBautt_YH%v6!Q>H?}(0@4pv>cM6_7dHJ)5JdyV0Phi!)vz}dv{*n;t zf(+#Hdr=f8DbJqbMez)(n>@QT+amJ7g&w6vZ-vG^H1v~aZqG~u!1D(O+jVAG0EQ*aIsr*bsBdbD`)i^FNJ z&B@yxqPFCRGT#}@dmu-{0vp47xk(`xNM6E=7QZ5{tg6}#zFrd8Pb_bFg7XP{FsYP8 zbvWqG6#jfg*4gvY9!gJxJ3l2UjP}+#QMB(*(?Y&Q4PO`EknE&Cb~Yb@lCbk;-KY)n zzbjS~W5KZ3FV%y>S#$9Sqi$FIBCw`GfPDP|G=|y32VV-g@a1D&@%_oAbB@cAUx#aZ zlAPTJ{iz#Qda8(aNZE&0q+8r3&z_Ln)b=5a%U|OEcc3h1f&8?{b8ErEbilrun}mh3 z$1o^$-XzIiH|iGoJA`w`o|?w3m*NX|sd$`Mt+f*!hyJvQ2fS*&!SYn^On-M|pHGlu z4SC5bM7f6BAkUhGuN*w`97LLkbCx=p@K5RL2p>YpDtf{WTD|d3ucb6iVZ-*DRtoEA zCC5(x)&e=giR_id>5bE^l%Mxx>0@FskpCD4oq@%-Fg$8IcdRwkfn;DsjoX(v;mt3d z_4Mnf#Ft4x!bY!7Hz?RRMq9;5FzugD(sbt4up~6j?-or+ch~y_PqrM2hhTToJjR_~ z)E1idgt7EW>G*9%Q^K;o_#uFjX!V2pwfpgi>}J&p_^QlZki!@#dkvR`p?bckC`J*g z=%3PkFT3HAX2Q+dShHUbb1?ZcK8U7oaufLTCB#1W{=~k0Jabgv>q|H+GU=f-y|{p4 zwN|AE+YbCgx=7vlXE?@gkXW9PaqbO#GB=4$o0FkNT#EI?aLVd2(qnPK$Yh%YD%v(mdwn}bgsxyIBI^)tY?&G zi^2JfClZ@4b{xFjyTY?D61w@*ez2@5rWLpG#34id?>>oPg{`4F-l`7Lg@D@Hc}On} zx%BO4MsLYosLGACJ-d?ifZ35r^t*}wde>AAWO*J-X%jvD+gL9`u`r=kP zyeJ%FqqKfz8e_3K(M1RmB?gIYi{W7Z<THP2ihue0mbpu5n(x_l|e1tw(q!#m5lmef6ktqIb${ zV+ee#XRU}_dDDUiV@opHZ@EbQ<9qIZJMDsZDkW0^t3#j`S)G#>N^ZBs8k+FJhAfu< z%u!$%dyP3*_+jUvCf-%{x#MyDAK?#iPfE<(@Q0H7;a125eD%I(+!x1f;Sy`e<9>nm zQH4czZDQmW7^n>jL)@P@aAuAF$;I7JZE5a8~AJI5CNDqyf$gjloKR7C?OPt9yeH}n5 zNF8Vhmd%1O>T4EZD&0%Dt7YWNImmEV{7QF(dy!>q5k>Kh&Xy8hcBMUvVV~Xn8O&%{ z&q=JCYw#KlwM8%cu-rNadu(P~i3bM<_a{3!J*;vZhR6dln6#eW0^0kN)Vv3!bqM`w z{@j*eyzz=743dgFPY`Cx3|>ata;;_hQ3RJd+kU}~p~aphRx`03B>g4*~f%hUV+#D9rYRbsGD?jkB^$3XcgB|3N1L& zrmk9&Dg450mAd=Q_p?gIy5Zx7vRL?*rpNq76_rysFo)z)tp0B;7lSb9G5wX1vC9Lc z5Q8tb-alolVNWFsxO_=12o}X(>@Mwz1mkYh1##(qQwN=7VKz?61kay8A9(94Ky(4V zq6qd2+4a20Z0QRrmp6C?4;%U?@MatfXnkj&U6bP_&2Ny}BF%4{QhNx*Tabik9Y-~Z z@0WV6XD}aI(%pN}oW$X~Qo_R#+1$@J8(31?zM`#e`#(0f<-AZ^={^NgH#lc?oi(Mu zMk|#KR^Q;V@?&(sh5)D;-fu)rx%gXZ1&5)MR+Mhssy+W>V%S|PRNyTAd}74<(#J>H zR(1BfM%eIv0+ngHH6(i`?-%_4!6PpK*0X)79SX0X$`lv_q>9(E2kkkP;?c@rW2E^Q zs<;`9dg|lDMNECFrD3jTM^Mn-C$44}9d9Kc z#>*k&e#25;D^%82^1d@Yt{Y91MbEu0C}-;HR4+IaCeZ`l?)Q8M2~&E^FvJ?EBJJ(% zz1>tCW-E~FB}DI}z#+fUo+=kQME^=eH>^%V8w)dh*ugPFdhMUi3R2Cg}Zak4!k_8YW(JcR-)hY8C zXja}R7@%Q0&IzQTk@M|)2ViZDNCDRLNI)*lH%SDa^2TG4;%jE4n`8`aQAA$0SPH2@ z)2eWZuP26+uGq+m8F0fZn)X^|bNe z#f{qYZS!(CdBdM$N2(JH_a^b#R2=>yVf%JI_ieRFB{w&|o9txwMrVxv+n78*aXFGb z>Rkj2yq-ED<)A46T9CL^$iPynv`FoEhUM10@J+UZ@+*@_gyboQ>HY9CiwTUo7OM=w zd~$N)1@6U8H#Zu(wGLa_(Esx%h@*pmm5Y9OX@CY`3kPYPQx@z8yAgtm(+agDU%4?c zy8pR4SYbu8vY?JX6HgVq7|f=?w(%`m-C+a@E{euXo>XrGmkmFGzktI*rj*8D z)O|CHKXEzH{~iS+6)%ybRD|JRQ6j<+u_+=SgnJP%K+4$st+~XCVcAjI9e5`RYq$n{ zzy!X9Nv7>T4}}BZpSj9G9|(4ei-}Du<_IZw+CB`?fd$w^;=j8?vlp(#JOWiHaXJjB0Q00RHJ@sG6N#y^H7t^&V} z;VrDI4?75G$q5W9mV=J2iP24NHJy&d|HWHva>FaS#3AO?+ohh1__FMx;?`f{HG3v0 ztiO^Wanb>U4m9eLhoc_2B(ca@YdnHMB*~aYO+AE(&qh@?WukLbf_y z>*3?Xt-lxr?#}y%kTv+l8;!q?Hq8XSU+1E8x~o@9$)zO2z9K#(t`vPDri`mKhv|sh z{KREcy`#pnV>cTT7dm7M9B@9qJRt3lfo(C`CNkIq@>|2<(yn!AmVN?ST zbX_`JjtWa3&N*U{K7FYX8})*D#2@KBae` zhKS~s!r%SrXdhCsv~sF}7?ocyS?afya6%rDBu6g^b2j#TOGp^1zrMR}|70Z>CeYq- z1o|-=FBKlu{@;pm@QQJ_^!&hzi;0Z_Ho){x3O1KQ#TYk=rAt9`YKC0Y^}8GWIN{QW znYJyVTrmNvl!L=YS1G8BAxGmMUPi+Q7yb0XfG`l+L1NQVSbe^BICYrD;^(rke{jWCEZOtVv3xFze!=Z&(7}!)EcN;v0Dbit?RJ6bOr;N$ z=nk8}H<kCEE+IK3z<+3mkn4q!O7TMWpKShWWWM)X*)m6k%3luF6c>zOsFccvfLWf zH+mNkh!H@vR#~oe=ek}W3!71z$Dlj0c(%S|sJr>rvw!x;oCek+8f8s!U{DmfHcNpO z9>(IKOMfJwv?ey`V2ysSx2Npeh_x#bMh)Ngdj$al;5~R7Ac5R2?*f{hI|?{*$0qU- zY$6}ME%OGh^zA^z9zJUs-?a4ni8cw_{cYED*8x{bWg!Fn9)n;E9@B+t;#k}-2_j@# zg#b%R(5_SJAOtfgFCBZc`n<&z6)%nOIu@*yo!a% zpLg#36KBN$01W{b;qWN`Tp(T#jh%;Zp_zpS64lvBVY2B#UK)p`B4Oo)IO3Z&D6<3S zfF?ZdeNEnzE{}#gyuv)>;z6V{!#bx)` zY;hL*f(WVD*D9A4$WbRKF2vf;MoZVdhfWbWhr{+Db5@M^A4wrFReuWWimA4qp`GgoL2`W4WPUL5A=y3Y3P z%G?8lLUhqo@wJW8VDT`j&%YY7xh51NpVYlsrk_i4J|pLO(}(b8_>%U2M`$iVRDc-n zQiOdJbroQ%*vhN{!{pL~N|cfGooK_jTJCA3g_qs4c#6a&_{&$OoSQr_+-O^mKP=Fu zGObEx`7Qyu{nHTGNj(XSX*NPtAILL(0%8Jh)dQh+rtra({;{W2=f4W?Qr3qHi*G6B zOEj7%nw^sPy^@05$lOCjAI)?%B%&#cZ~nC|=g1r!9W@C8T0iUc%T*ne z)&u$n>Ue3FN|hv+VtA+WW)odO-sdtDcHfJ7s&|YCPfWaVHpTGN46V7Lx@feE#Od%0XwiZy40plD%{xl+K04*se zw@X4&*si2Z_0+FU&1AstR)7!Th(fdaOlsWh`d!y=+3m!QC$Zlkg8gnz!}_B7`+wSz z&kD?6{zPnE3uo~Tv8mLP%RaNt2hcCJBq=0T>%MW~Q@Tpt2pPP1?KcywH>in5@ zx+5;xu-ltFfo5vLU;2>r$-KCHjwGR&1XZ0YNyrXXAUK!FLM_7mV&^;;X^*YH(FLRr z`0Jjg7wiq2bisa`CG%o9i)o1`uG?oFjU_Zrv1S^ipz$G-lc^X@~6*)#%nn+RbgksJfl{w=k31(q>7a!PCMp5YY{+Neh~mo zG-3dd!0cy`F!nWR?=9f_KP$X?Lz&cLGm_ohy-|u!VhS1HG~e7~xKpYOh=GmiiU;nu zrZ5tWfan3kp-q_vO)}vY6a$19Q6UL0r znJ+iSHN-&w@vDEZ0V%~?(XBr|jz&vrBNLOngULxtH(Rp&U*rMY42n;05F11xh?k;n_DX2$4|vWIkXnbwfC z=ReH=(O~a;VEgVO?>qsP*#eOC9Y<_9Yt<6X}X{PyF7UXIA$f)>NR5P&4G_Ygq(9TwwQH*P>Rq>3T4I+t2X(b5ogXBAfNf!xiF#Gilm zp2h{&D4k!SkKz-SBa%F-ZoVN$7GX2o=(>vkE^j)BDSGXw?^%RS9F)d_4}PN+6MlI8*Uk7a28CZ)Gp*EK)`n5i z){aq=0SFSO-;sw$nAvJU-$S-cW?RSc7kjEBvWDr1zxb1J7i;!i+3PQwb=)www?7TZ zE~~u)vO>#55eLZW;)F(f0KFf8@$p)~llV{nO7K_Nq-+S^h%QV_CnXLi)p*Pq&`s!d zK2msiR;Hk_rO8`kqe_jfTmmv|$MMo0ll}mI)PO4!ikVd(ZThhi&4ZwK?tD-}noj}v zBJ?jH-%VS|=t)HuTk?J1XaDUjd_5p1kPZi6y#F6$lLeRQbj4hsr=hX z4tXkX2d5DeLMcAYTeYm|u(XvG5JpW}hcOs4#s8g#ihK%@hVz|kL=nfiBqJ{*E*WhC zht3mi$P3a(O5JiDq$Syu9p^HY&9~<#H89D8 zJm84@%TaL_BZ+qy8+T3_pG7Q%z80hnjN;j>S=&WZWF48PDD%55lVuC0%#r5(+S;WH zS7!HEzmn~)Ih`gE`faPRjPe^t%g=F ztpGVW=Cj5ZkpghCf~`ar0+j@A=?3(j@7*pq?|9)n*B4EQTA1xj<+|(Y72?m7F%&&& zdO44owDBPT(8~RO=dT-K4#Ja@^4_0v$O3kn73p6$s?mCmVDUZ+Xl@QcpR6R3B$=am z%>`r9r2Z79Q#RNK?>~lwk^nQlR=Hr-ji$Ss3ltbmB)x@0{VzHL-rxVO(++@Yr@Iu2 zTEX)_9sVM>cX$|xuqz~Y8F-(n;KLAfi*63M7mh&gsPR>N0pd9h!0bm%nA?Lr zS#iEmG|wQd^BSDMk0k?G>S-uE$vtKEF8Dq}%vLD07zK4RLoS?%F1^oZZI$0W->7Z# z?v&|a`u#UD=_>i~`kzBGaPj!mYX5g?3RC4$5EV*j0sV)>H#+$G6!ci=6`)85LWR=FCp-NUff`;2zG9nU6F~ z;3ZyE*>*LvUgae+uMf}aV}V*?DCM>{o31+Sx~6+sz;TI(VmIpDrN3z+BUj`oGGgLP z>h9~MP}Pw#YwzfGP8wSkz`V#}--6}7S9yZvb{;SX?6PM_KuYpbi~*=teZr-ga2QqIz{QrEyZ@>eN*qmy;N@FCBbRNEeeoTmQyrX;+ zCkaJ&vOIbc^2BD6_H+Mrcl?Nt7O{xz9R_L0ZPV_u!sz+TKbXmhK)0QWoe-_HwtKJ@@7=L+ z+K8hhf=4vbdg3GqGN<;v-SMIzvX=Z`WUa_91Yf89^#`G(f-Eq>odB^p-Eqx}ENk#&MxJ+%~Ad2-*`1LNT>2INPw?*V3&kE;tt?rQyBw? zI+xJD04GTz1$7~KMnfpkPRW>f%n|0YCML@ODe`10;^DXX-|Hb*IE%_Vi#Pn9@#ufA z_8NY*1U%VseqYrSm?%>F@`laz+f?+2cIE4Jg6 z_VTcx|DSEA`g!R%RS$2dSRM|9VQClsW-G<~=j5T`pTbu-x6O`R z98b;}`rPM(2={YiytrqX+uh65f?%XiPp`;4CcMT*E*dQJ+if9^D>c_Dk8A(cE<#r=&!& z_`Z01=&MEE+2@yr!|#El=yM}v>i=?w^2E_FLPy(*4A9XmCNy>cBWdx3U>1RylsItO z4V8T$z3W-qqq*H`@}lYpfh=>C!tieKhoMGUi)EpWDr;yIL&fy};Y&l|)f^QE*k~4C zH>y`Iu%#S)z)YUqWO%el*Z)ME#p{1_8-^~6UF;kBTW zMQ!eXQuzkR#}j{qb(y9^Y!X7&T}}-4$%4w@w=;w+>Z%uifR9OoQ>P?0d9xpcwa>7kTv2U zT-F?3`Q`7xOR!gS@j>7In>_h){j#@@(ynYh;nB~}+N6qO(JO1xA z@59Pxc#&I~I64slNR?#hB-4XE>EFU@lUB*D)tu%uEa))B#eJ@ZOX0hIulfnDQz-y8 z`CX@(O%_VC{Ogh&ot``jlDL%R!f>-8yq~oLGxBO?+tQb5%k@a9zTs!+=NOwSVH-cR zqFo^jHeXDA_!rx$NzdP;>{-j5w3QUrR<;}=u2|FBJ;D#v{SK@Z6mjeV7_kFmWt95$ zeGaF{IU?U>?W`jzrG_9=9}yN*LKyzz))PLE+)_jc#4Rd$yFGol;NIk(qO1$5VXR)+ zxF7%f4=Q!NzR>DVXUB&nUT&>Nyf+5QRF+Z`X-bB*7=`|Go5D1&h~ zflKLw??kpiRm0h3|1GvySC2^#kcFz^5{79KKlq@`(leBa=_4CgV9sSHr{RIJ^KwR_ zY??M}-x^=MD+9`v@I3jue=OCn0kxno#6i>b(XKk_XTp_LpI}X*UA<#* zsgvq@yKTe_dTh>q1aeae@8yur08S(Q^8kXkP_ty48V$pX#y9)FQa~E7P7}GP_CbCm zc2dQxTeW(-~Y6}im24*XOC8ySfH*HMEnW3 z4CXp8iK(Nk<^D$g0kUW`8PXn2kdcDk-H@P0?G8?|YVlIFb?a>QunCx%B9TzsqQQ~HD!UO7zq^V!v9jho_FUob&Hxi ztU1nNOK)a!gkb-K4V^QVX05*>-^i|{b`hhvQLyj`E1vAnj0fbqqO%r z6Q;X1x0dL~GqMv%8QindZ4CZ%7pYQW~ z9)I*#Gjref-q(4Z*E#1c&rE0-_(4;_M(V7rgH_7H;ps1s%GBmU z{4a|X##j#XUF2n({v?ZUUAP5k>+)^F)7n-npbV3jAlY8V3*W=fwroDS$c&r$>8aH` zH+irV{RG3^F3oW2&E%5hXgMH9>$WlqX76Cm+iFmFC-DToTa`AcuN9S!SB+BT-IA#3P)JW1m~Cuwjs`Ep(wDXE4oYmt*aU z!Naz^lM}B)JFp7ejro7MU9#cI>wUoi{lylR2~s)3M!6a=_W~ITXCPd@U9W)qA5(mdOf zd3PntGPJyRX<9cgX?(9~TZB5FdEHW~gkJXY51}?s4ZT_VEdwOwD{T2E-B>oC8|_ZwsPNj=-q(-kwy%xX2K0~H z{*+W`-)V`7@c#Iuaef=?RR2O&x>W0A^xSwh5MsjTz(DVG-EoD@asu<>72A_h<39_# zawWVU<9t{r*e^u-5Q#SUI6dV#p$NYEGyiowT>>d*or=Ps!H$-3={bB|An$GPkP5F1 zTnu=ktmF|6E*>ZQvk^~DX(k!N`tiLut*?3FZhs$NUEa4ccDw66-~P;x+0b|<!ZN7Z%A`>2tN#CdoG>((QR~IV_Gj^Yh%!HdA~4C3jOXaqb6Ou z21T~Wmi9F6(_K0@KR@JDTh3-4mv2=T7&ML<+$4;b9SAtv*Uu`0>;VVZHB{4?aIl3J zL(rMfk?1V@l)fy{J5DhVlj&cWKJCcrpOAad(7mC6#%|Sn$VwMjtx6RDx1zbQ|Ngg8N&B56DGhu;dYg$Z{=YmCNn+?ceDclp65c_RnKs4*vefnhudSlrCy6-96vSB4_sFAj# zftzECwmNEOtED^NUt{ZDjT7^g>k1w<=af>+0)%NA;IPq6qx&ya7+QAu=pk8t>KTm` zEBj9J*2t|-(h)xc>Us*jHs)w9qmA>8@u21UqzKk*Ei#0kCeW6o z-2Q+Tvt25IUkb}-_LgD1_FUJ!U8@8OC^9(~Kd*0#zr*8IQkD)6Keb(XFai5*DYf~` z@U?-{)9X&BTf!^&@^rjmvea#9OE~m(D>qfM?CFT9Q4RxqhO0sA7S)=--^*Q=kNh7Y zq%2mu_d_#23d`+v`Ol263CZ<;D%D8Njj6L4T`S*^{!lPL@pXSm>2;~Da- zBX97TS{}exvSva@J5FJVCM$j4WDQuME`vTw>PWS0!;J7R+Kq zVUy6%#n5f7EV(}J#FhDpts;>=d6ow!yhJj8j>MJ@Wr_?x30buuutIG97L1A*QFT$c ziC5rBS;#qj=~yP-yWm-p(?llTwDuhS^f&<(9vA9@UhMH2-Fe_YAG$NvK6X{!mvPK~ zuEA&PA}meylmaIbbJXDOzuIn8cJNCV{tUA<$Vb?57JyAM`*GpEfMmFq>)6$E(9e1@W`l|R%-&}38#bl~levA#fx2wiBk^)mPj?<=S&|gv zQO)4*91$n08@W%2b|QxEiO0KxABAZC{^4BX^6r>Jm?{!`ZId9jjz<%pl(G5l));*`UU3KfnuXSDj2aP>{ zRIB$9pm7lj3*Xg)c1eG!cb+XGt&#?7yJ@C)(Ik)^OZ5><4u$VLCqZ#q2NMCt5 z6$|VN(RWM;5!JV?-h<JkEZ(SZF zC(6J+>A6Am9H7OlOFq6S62-2&z^Np=#xXsOq0WUKr zY_+Ob|CQd1*!Hirj5rn*=_bM5_zKmq6lG zn*&_=x%?ATxZ8ZTzd%biKY_qyNC#ZQ1vX+vc48N>aJXEjs{Y*3Op`Q7-oz8jyAh>d zNt_qvn`>q9aO~7xm{z`ree%lJ3YHCyC`q`-jUVCn*&NIml!uuMNm|~u3#AV?6kC+B z?qrT?xu2^mobSlzb&m(8jttB^je0mx;TT8}`_w(F11IKz83NLj@OmYDpCU^u?fD{) z&=$ptwVw#uohPb2_PrFX;X^I=MVXPDpqTuYhRa>f-=wy$y3)40-;#EUDYB1~V9t%$ z^^<7Zbs0{eB93Pcy)96%XsAi2^k`Gmnypd-&x4v9rAq<>a(pG|J#+Q>E$FvMLmy7T z5_06W=*ASUyPRfgCeiPIe{b47Hjqpb`9Xyl@$6*ntH@SV^bgH&Fk3L9L=6VQb)Uqa z33u#>ecDo&bK(h1WqSH)b_Th#Tvk&%$NXC@_pg5f-Ma#7q;&0QgtsFO~`V&{1b zbSP*X)jgLtd@9XdZ#2_BX4{X~pS8okF7c1xUhEV9>PZco>W-qz7YMD`+kCGULdK|^ zE7VwQ-at{%&fv`a+b&h`TjzxsyQX05UB~a0cuU-}{*%jR48J+yGWyl3Kdz5}U>;lE zgkba*yI5>xqIPz*Y!-P$#_mhHB!0Fpnv{$k-$xxjLAc`XdmHd1k$V@2QlblfJPrly z*~-4HVCq+?9vha>&I6aRGyq2VUon^L1a)g`-Xm*@bl2|hi2b|UmVYW|b+Gy?!aS-p z86a}Jep6Mf>>}n^*Oca@Xz}kxh)Y&pX$^CFAmi#$YVf57X^}uQD!IQSN&int=D> zJ>_|au3Be?hmPKK)1^JQ(O29eTf`>-x^jF2xYK6j_9d_qFkWHIan5=7EmDvZoQWz5 zZGb<{szHc9Nf@om)K_<=FuLR<&?5RKo3LONFQZ@?dyjemAe4$yDrnD zglU#XYo6|~L+YpF#?deK6S{8A*Ou;9G`cdC4S0U74EW18bc5~4>)<*}?Z!1Y)j;Ot zosEP!pc$O^wud(={WG%hY07IE^SwS-fGbvpP?;l8>H$;}urY2JF$u#$q}E*ZG%fR# z`p{xslcvG)kBS~B*^z6zVT@e}imYcz_8PRzM4GS52#ms5Jg9z~ME+uke`(Tq1w3_6 zxUa{HerS7!Wq&y(<9yyN@P^PrQT+6ij_qW3^Q)I53iIFCJE?MVyGLID!f?QHUi1tq z0)RNIMGO$2>S%3MlBc09l!6_(ECxXTU>$KjWdZX^3R~@3!SB zah5Za2$63;#y!Y}(wg1#shMePQTzfQfXyJ-Tf`R05KYcyvo8UW9-IWGWnzxR6Vj8_la;*-z5vWuwUe7@sKr#Tr51d z2PWn5h@|?QU3>k=s{pZ9+(}oye zc*95N_iLmtmu}H-t$smi49Y&ovX}@mKYt2*?C-i3Lh4*#q5YDg1Mh`j9ovRDf9&& zp_UMQh`|pC!|=}1uWoMK5RAjdTg3pXPCsYmRkWW}^m&)u-*c_st~gcss(`haA)xVw zAf=;s>$`Gq_`A}^MjY_BnCjktBNHY1*gzh(i0BFZ{Vg^F?Pbf`8_clvdZ)5(J4EWzAP}Ba5zX=S(2{gDugTQ3`%!q`h7kYSnwC`zEWeuFlODKiityMaM9u{Z%E@@y1jmZA#ⅅ8MglG&ER{i5lN315cO?EdHNLrg? zgxkP+ytd)OMWe7QvTf8yj4;V=?m172!BEt@6*TPUT4m3)yir}esnIodFGatGnsSfJ z**;;yw=1VCb2J|A7cBz-F5QFOQh2JDQFLarE>;4ZMzQ$s^)fOscIVv2-o{?ct3~Zv zy{0zU>3`+-PluS|ADraI9n~=3#Tvfx{pDr^5i$^-h5tL*CV@AeQFLxv4Y<$xI{9y< zZ}li*WIQ+XS!IK;?IVD0)C?pNBA(DMxqozMy1L#j+ba1Cd+2w&{^d-OEWSSHmNH>9 z%1Ldo(}5*>a8rjQF&@%Ka`-M|HM+m<^E#bJtVg&YM}uMb7UVJ|OVQI-zt-*BqQ zG&mq`Bn7EY;;+b%Obs9i{gC^%>kUz`{Qnc=ps7ra_UxEP$!?f&|5fHnU(rr?7?)D z$3m9e{&;Zu6yfa1ixTr;80IP7KLgkKCbgv1%f_weZK6b7tY+AS%fyjf6dR(wQa9TD zYG9`#!N4DqpMim|{uViKVf0B+Vmsr7p)Y+;*T~-2HFr!IOedrpiXXz+BDppd5BTf3 ztsg4U?0wR?9@~`iV*nwGmtYFGnq`X< zf?G%=o!t50?gk^qN#J(~!sxi=_yeg?Vio04*w<2iBT+NYX>V#CFuQGLsX^u8dPIkP zPraQK?ro`rqA4t7yUbGYk;pw6Z})Bv=!l-a5^R5Ra^TjoXI?=Qdup)rtyhwo<(c9_ zF>6P%-6Aqxb8gf?wY1z!4*hagIch)&A4treifFk=E9v@kRXyMm?V*~^LEu%Y%0u(| z52VvVF?P^D<|fG)_au(!iqo~1<5eF$Sc5?)*$4P3MAlSircZ|F+9T66-$)0VUD6>e zl2zlSl_QQ?>ULUA~H?QbWazYeh61%B!!u;c(cs`;J|l z=7?q+vo^T#kzddr>C;VZ5h*;De8^F2y{iA#9|(|5@zYh4^FZ-3r)xej=GghMN3K2Y z=(xE`TM%V8UHc4`6Cdhz4%i0OY^%DSguLUXQ?Y3LP+5x3jyN)-UDVhEC}AI5wImt; zHY|*=UW}^bS3va-@L$-fJz2P2LbCl)XybkY)p%2MjPJd-FzkdyWW~NBC@NlPJkz{v z+6k6#nif`E>>KCGaP34oY*c#nBFm#G8a0^px1S6mm6Cs+d}E8{J;DX=NEHb|{fZm0 z@Ors@ebTgbf^Jg&DzVS|h&Or)56$+;%&sh0)`&6VkS@QxQ=#6WxF5g+FWSr7Lp9uF zV#rc`yLe?f*u6oZoi3WpOkKFf^>lHb2GC6t!)dyGaQbK7&BNZ7oyP)hUX1Y(LdW-I z6LI2$i%+g!zsjT(5l}5ROLb)8`9kkldbklcq6tfLSrAyh#s(C1U2Sz9`h3#T9eX#Hryi1AU^!uv*&6I~qdM_B7-@`~8#O^jN&t7+S zTKI6;T$1@`Kky-;;$rU1*TdY;cUyg$JXalGc&3-Rh zJ&7kx=}~4lEx*%NUJA??g8eIeavDIDC7hTvojgRIT$=MlpU}ff0BTTTvjsZ0=wR)8 z?{xmc((XLburb0!&SA&fc%%46KU0e&QkA%_?9ZrZU%9Wt{*5DCUbqIBR%T#Ksp?)3 z%qL(XlnM!>F!=q@jE>x_P?EU=J!{G!BQq3k#mvFR%lJO2EU2M8egD?0r!2s*lL2Y} zdrmy`XvEarM&qTUz4c@>Zn}39Xi2h?n#)r3C4wosel_RUiL8$t;FSuga{9}-%FuOU z!R9L$Q!njtyY!^070-)|#E8My)w*~4k#hi%Y77)c5zfs6o(0zaj~nla0Vt&7bUqfD zrZmH~A50GOvk73qiyfXX6R9x3Qh)K=>#g^^D65<$5wbZjtrtWxfG4w1f<2CzsKj@e zvdsQ$$f6N=-%GJk~N7G(+-29R)Cbz8SIn_u|(VYVSAnlWZhPp8z6qm5=hvS$Y zULkbE?8HQ}vkwD!V*wW7BDBOGc|75qLVkyIWo~3<#nAT6?H_YSsvS+%l_X$}aUj7o z>A9&3f2i-`__#MiM#|ORNbK!HZ|N&jKNL<-pFkqAwuMJi=(jlv5zAN6EW`ex#;d^Z z<;gldpFcVD&mpfJ1d7><79BnCn~z8U*4qo0-{i@1$CCaw+<$T{29l1S2A|8n9ccx0!1Pyf;)aGWQ15lwEEyU35_Y zQS8y~9j9ZiByE-#BV7eknm>ba75<_d1^*% zB_xp#q`bpV1f9o6C(vbhN((A-K+f#~3EJtjWVhRm+g$1$f2scX!eZkfa%EIZd2ZVG z6sbBo@~`iwZQC4rH9w84rlHjd!|fHc9~12Il&?-FldyN50A`jzt~?_4`OWmc$qkgI zD_@7^L@cwg4WdL(sWrBYmkH;OjZGE^0*^iWZM3HBfYNw(hxh5>k@MH>AerLNqUg*Og9LiYmTgPw zX9IiqU)s?_obULF(#f~YeK#6P>;21x+cJ$KTL}|$xeG?i`zO;dAk0{Uj6GhT-p-=f zP2NJUcRJ{fZy=bbsN1Jk3q}(!&|Fkt_~GYdcBd7^JIt)Q!!7L8`3@so@|GM9b(D$+ zlD&69JhPnT>;xlr(W#x`JJvf*DPX(4^OQ%1{t@)Lkw5nc5zLVmRt|s+v zn(25v*1Z(c8RP@=3l_c6j{{=M$=*aO^ zPMUbbEKO7m2Q$4Xn>GIdwm#P_P4`or_w0+J+joK&qIP#uEiCo&RdOaP_7Z;PvfMh@ zsXUTn>ppdoEINmmq5T1BO&57*?QNLolW-8iz-jv7VAIgoV&o<<-vbD)--SD%FFOLd z>T$u+V>)4Dl6?A24xd1vgm}MovrQjf-@YH7cIk6tP^eq-xYFymnoSxcw}{lsbCP1g zE_sX|c_nq(+INR3iq+Oj^TwkjhbdOo}FmpPS2*#NGxNgl98|H0M*lu)Cu0TrA|*t=i`KIqoUl(Q7jN zb6!H-rO*!&_>-t)vG5jG>WR6z#O9O&IvA-4ho9g;as~hSnt!oF5 z6w(4pxz|WpO?HO<>sC_OB4MW)l`-E9DZJ$!=ytzO}fWXwnP>`8yWm5tYw`b1KDdg zp@oD;g===H+sj+^v6DCpEu7R?fh7>@pz>f74V5&#PvBN+95?28`mIdGR@f*L@j2%% z%;Rz5R>l#1U zYCS_5_)zUjgq#0SdO#)xEfYJ)JrHLXfe8^GK3F*CA(Y)jsSPJ{j&Ae!SeWN%Ev727 zxdd3Y0n^OBOtBSKdglEBL)i5=NdKfqK=1n~6LX`ja;#Tr!II$AAH{Z#sp%`rwNGT5 zvHT%(LJB+kD{5N}7c_Rk6}@tikIeq%@MqxX%$P!(238YD(H<_d;xxo*oMiv^1io>g zt5z&6`}cjci90q2r0hutQXr!UA~|4e*u=k81D(Cp7n{4LVCa+u0%-8Uha+sqI#Om~ z!&)KN(#Zone^~&@Ja{|l?X64Dxk)q>tLRv{=0|t$`Kdaj z#{AJr>{_BtpS|XEgTVJ4WMvBRk-(mk@ZYGdY1VwI z81;z(MBGV|2j*Cj%dvl8?b2{{B#e0B7&7wfv+>g`R2^Ai5C_WUx|CnTrHm+RFGXrt zs<~zBtk@?Niu%|o6IEL+y60Q>zJlv``ePCa07C%*O~lj?74|}&A0!uA)3V7ST8b_- z6CBP1;x+S@xTzgOY2#s%@=bhZ@i@BwmS)neQG&=9KUtRf^K=MvjC5JnqLqykCE_P0 zjf#V4SdH2#%2EuDb!>FLHK7j;nd6VLW|$3gJuegpEl3DZ`BpJU$<}}A(rW?<6OB@9 zKP9G3An?T5BztrLdlximA;{>Tr7GAeSU=^<*y;%RHj+7;v+tonyh(8d;Izn}2{oz& zW)fsZ9gHYpI?B|uekS3zHUue3mI zb7?0+&Zm>Kq(F>~%VYEn)0b32I3~O^?Wx-HI|Zu?1-OA2yfyJ;gWygLOeU;)vRm3u z5J4vDIQYztnEm=QauX2(WJO{yzI0HUFl+oO&isMf!Yh2pu@p}65)|0EdWRbg(@J6qo5_Els>#|_2a1p0&y&UP z8x#Z69q=d663NPPi>DHx3|QhJl5Ka$Cfqbvl*oRLYYXiH>g8*vriy!0XgmT~&jh3l z+!|~l=oCj<*PD>1EY*#+^a{rVk3T(66rJ^DxGt|~XTNnJf$vix1v1qdYu+d@Jn~bh z!7`a`y+IEcS#O*fSzA;I`e_T~XYzpW7alC%&?1nr);tSkNwO&J`JnX+7X1Q8fRh_d zx%)Xh_YjI3hwTCmGUeq_Z@H#ovkk_b(`osa$`aNmt`9A#t&<^jvuf z1E1DrW(%7PpAOQGwURz@luEW9-)L!`Jy*aC*4mcD?Si~mb=3Kn#M#1il9%`C0wkZ` zbpJ-qEPaOE5Y5iv_z%Wr{y4jh#U+o^KtP{pPCq-Qf&!=Uu)cEE(Iu9`uT#oHwHj+w z_R=kr7vmr~{^5sxXkj|WzNhAlXkW^oB4V)BZ{({~4ylOcM#O>DR)ZhD;RWwmf|(}y zDn)>%iwCE=*82>zP0db>I4jN#uxcYWod+<;#RtdMGPDpQW;riE;3cu``1toL|FaWa zK)MVA%ogXt3q55(Q&q+sjOG`?h=UJE9P;8i#gI*#f}@JbV(DuGEkee;La*9{p&Z?;~lE!&-kUFCtoDHY*MS zzj+S$L9+aTs(F^4ufZe6>SBg;m@>0&+kEZMFmD*~p~sx?rx=!>Ge;KYw<33y#*&77 zFZI`YE(Iz?+tH;Fq;y=MaSqT{Ayh*HFv0(z{_?Q+7@nE%p?S8%X6c!+y;!0NLXwJV8Co_}R3*7>n+oMsQpv8}8ZS-P@(Rg|gmxZHzf=nMOUAAY}AZGfWVzZjE@4$=7xkIrs8BE%606aVU%kxz_04ipig51k& z(>c9rJL2q%xvU%Zj#GR9C9)HLCR;#zQBB@x;e_9$ayn(JmSg_*0G?+wOF?&iu@}S{ zt$;TPf*Lj$3=d<}Q3o!Hq@3~lFxoiCyeEt}o3fihIn{x2s1)e2@3##&GYDq~YO|!q zUs0P-zy)+ohl-VQ`bhvUpC{-d$lkpML_M%Kl6@#_@A}w{jWCDsPa#cSbWA#C4Sf|*C*&Z{ zz?hOU7Cc`?>H$WGqITA2P~fYudnQHxB8^;0ZFKC;19F#~n_2P@{cE{Czq-#K5L_8| zc3aOEwq4%zL5>YU_mc9fc-p~{fBTWUkxTiZvxt9FOqC{s#TBp(#dWc+{Ee{dZ#B!g zHnaOJ8;KO1G;QU2ciodE+#Z$Wuz*Hc6NRO!AUMi|gov=>=cwcZeL&`>Jfn!35hV1J z;B2@0!bIR853w%T*m6)gQ?DPnQ)o6EtKaN3L;o?*q<83d&lG&U=A|6hcT?f0)4h6{ zGIZ0|!}-?*n{zr}-}cC}qWxEN%g60+{my)o^57{QEn(tSrmD7o)|r0+HVpQPopFu; z0<S}pW8W2vXzSxEqGD+qePj^x?R$e2LO&*ewsLo{+_Z)Wl|Z1K47j zsKoNRlX)h2z^ls_>IZ0!2X5t&irUs%RAO$Dr>0o$-D+$!Kb9puSgpoWza1jnX6(eG zTg-U z6|kf1atI!_>#@|=d01Ro@Rg)BD?mY3XBsG7U9%lmq>4;Gf&2k3_oyEOdEN&X6Hl5K zCz^hyt67G;IE&@w1n~%ji_{sob_ssP#Ke|qd!Xx?J&+|2K=^`WfwZ-zt|sklFouxC zXZeDgluD2a?Zd3e{MtE$gQfAY9eO@KLX;@8N`(?1-m`?AWp!a8bA%UN>QTntIcJX zvbY+C-GD&F?>E?jo$xhyKa@ps9$Dnwq>&)GB=W~2V3m)k;GNR$JoPRk%#f3#hgVdZ zhW3?cSQ*((Fog26jiEeNvum-6ID-fbfJ?q1ZU#)dgnJ^FCm`+sdP?g;d4VD$3XKx{ zs|Y4ePJp|93fpu)RL+#lIN9Ormd;<_5|oN!k5CENnpO>{60X;DN>vgHCX$QZYtgrj z*1{bEA1LKi8#U%oa!4W-4G+458~`5O4S1&tuyv>%H9DjLip7cC~RRS@HvdJ<|c z$TxEL=)r)XTfTgVxaG!gtZhLL`$#=gz1X=j|I@n~eHDUCW39r=o_ml@B z0cDx$5;3OA2l)&41kiKY^z7sO_U%1=)Ka4gV(P#(<^ z_zhThw=}tRG|2|1m4EP|p{Swfq#eNzDdi&QcVWwP+7920UQB*DpO0(tZHvLVMIGJl zdZ5;2J%a!N1lzxFwAkq05DPUg2*6SxcLRsSNI6dLiK0&JRuYAqwL}Z!YVJ$?mdnDF z82)J_t=jbY&le6Hq$Qs}@AOZGpB1}$Ah#i;&SzD1QQNwi6&1ddUf7UG0*@kX?E zDCbHypPZ9+H~KnDwBeOXZ-W-Y80wpoGB*A) z_;26Z`#s0tKrf~QBi2rl2=>;CS1w)rcD3-sB!8NI*1iQo59PJ>OLnqeV4iK7`RBi^ zFW{*6;nlD&cSunmU3v4JKj|K4xeN(q>H%;SsY8yDdw5BJ75q8>Ov)&D5OPZ`XiRHl z;)mAA0Woy6f!xCK(9H2rq?qzp83liZAIpBPl-dQ&$2=&H?Im~%g;vnIw1I+8q|kr! z36&^9}CMmR(U2rf|j12oG=vb%Ypsq8u9Kq}U*ANX*)9uK}fAi8;V_7Z;0_4*iydDxN-? zv?qJ=T*{MzL~-xUv{_Kh_q9#F{8gPV!yPUUS8pEq*=}2-#1d=sC_|U-rX~F0 zBLawgCWy#?#ax{~DAnDvh^`}wyUO`ioMK~jgh%L7^}#h?beSyvQ_g>+`2`}`-1h7# zg*?qJdm=53hwN8~B=^|LPmYtOVrQ(W{sNm4uofq=4P@dUA%$onWbw_m-KWia&n9iv zi)!9#OJ#^}eg8tE{wSb9(c0D^PS1 z9EBS5*ypSiVRS_G0v?$hyoZOS7hFWlp4qbYkf9Y&{%OzhsIdHskLptn96@k6@^K@U zszd8POehITDK+AyW#JKpnWY;ju#MC$JjB1Y*~(E6N%{p#kO+bVxG3X<34n3fW=k{A zCZt|KP%x^GQ9%mU)KE0{LA=vaZvRQbxSlK~eAkwWo2Z<{j5eS5NVTMe`m%re8%~7K zZLtU&b~YDN%~uA9wPf>x2=PI=MA6_oVe>Ek$s5&&Z=8vvF5EODP4Av(b|dlNgF1O8 zy83W0WRdzjz2iNA~t1piEqlyU&`$yZtqR`6X_PmuP>W+D|8iH;FQ zN{JuU#Tz9mV=4R_IewROL1|mK^`lLat#LcIBfggzM(iO$pQT*-c_ z94^LUWw#5B9~sp2W1p`c)Y(xfR<{O^9n4E6vDDw{#-R4UMBKo{>Hqlqn*a9rl_>+0 zS5MwJC~nCC`1X%VCyWFsiDX;bfAJQAUkU#105f_s5U-8rqO}n8fA1{b>Fr6Q|Ea(V z5B11Lo^ooWF?`^{-U#?iatokWI-e$632frzY?Yzzx(xJc@LFM4A~-eg!u|tl{)8Nx ztZLXsSC*68g%9TFu(f&J9nmc^9hgyy#uUOMJFCaifSaDcyQ&6=8e9=t zIFEAQ{EK{|73{($!a4=!wj4ABcQrUQp#+gGM?wEUp(w@+Fzi{!lt}|3`PM%&d-seeR zB$}BrFGD3R10CE>Hsb>;PrP}pd` zaY4}6+Wu(`#uAV+E5SV7VIT7ES#b(U0%%DgN1}USJH>)mm;CHPv>}B18&0F~Kj@1= z&^Jyo+z-E)GRT4U*7$8wJO1OibWg0Jw>C$%Ge|=YwV@Y1(4fR>cV#6aGtRoF@I`*w_V4;)V231NzNqb6g@jdpjmjv*<2j02yU$F8ZS$fTvCC`%|Yn#x< zXUnP&b!GLpOY-TY3d?<-Hhxom_LM9`JC9LEX2{t1P-Nj%nG+0Vq)vQwvO^}coPH-> zAo8w#s>Je^Yy*#PlK=XDxpVS~pFe-j#jN-(As&LRewOf(kN-aKF(H+s*{*!0xrlZw zchJu@XAvQWX7DI1E8?F}Wc8m46eT+C<0eXVB+Z^(g=Kl@FG-cn@u$suj)1V2(KNg_ zh29ws6&6(q~+sOAoHY^o86A<#n*?Pg2)cK$+y;cY$hJLq4)4V84=j+3ShSr##Tk5kgmxB zkW+8A1GtceEx~^Ebhwm36U?oA)h)!mt=eg0QE$D1QsLNZ_T3NH?=B&0j~#298!6iv zhc0|-{46*3`Rx&nKSXnf1&w-Rs>#PGAGuY@cBTU-j|Fxbn3z49S#6KBaP^Lx*AOXxIibr z!1ysMi(&kr!1wwQB5w`BDH2~>T4bI`T1}A2RM0zd7ikC&kuBRsB`Z2@J!Udm{AmSN zrr0k6_qCZL**=)xRW`MFu(OY=OT;3G8eF~ z2mmkXZ9X(sjuKmq+_<=LSjphB$~R1o^Yb=rO!j!(4ErIox^x55o{pXSE9X$!76^*$ zoKhlAX6y%n^U=C~@!vIlEgXQGD@>oOU=_(aXF-Sjas*$AKESfRzxQ8#3yOj|y0OCU z>6Z-0%LCcjla&7I+CXm&caKp@@jQ!5M`(_{CL=@4#JJ}cHeZw>^b6fpv269LSV?gV5Q{kk?4;;y9RIsy5vk%DIRiL(9xe1aA@4!VX zDh2}xgUd5X?6nji%&7-%QuyKSYA-Z{PwJijUQ}In+EJl|x@dF1P<5bPa5W3&&?^h$ zZCo8LepKo0a(Fsln*cHL;D(gu9MMkoiM0*n31u)jHqX5x^F95tnI&^}^yKx3YwEm@ zo8?EZ710ykx@19{=yz5IXb8w4yjdveWb{IVL6Z(Cs>!a_0X^1E27o!4e&b43+J*u2Gb(59k2uK0goLwhO{ujLS ziI9LA9`&x~Y$6JNX!aEXR``}LUI}Gr#=<^wBHmg%v<)zRWDVtq)kT$-P7iU1R)2XZ zi~bYhV@EZ`@prgK(cs{>2jn$pxg$<|KjJ7%26Km>%KcXh^bU@y@V_Lf@=j1x%R4{v zOcQn{I}!2W<~08FOVnoV>zOTH=+>v9!jFo|q)ucqIe!N4{U5_G`>>*sVD{8I~4FqyU8imZ**-Gy`~Xd z4w35GMf%7^i65HdX{Iz|f2Kg193#KhPIeR)-=eYx3Z!%RM=JjwLrdk^B#6rg!ym2w zPbFqYyO4>W_Z6PonAwiu7?!h=x%sR-T+_*xZOGh2wWhWr%}%2^$$ zQvACIB~pi=m|`hXIMvoq`TOCx=J_D2>pi6$NPy3&8#vy|oX)=kM0Z}$BR$r0G}MzOk-OqG+VmZtOZoj6x4(tLh|5h) zBv64Y{DPHsy&_H(5_l(&Y}FhVvr9m_*_Q~Zy-}V9+VmGnvndEjYW4qt4K~N&Y&6g| zfpz*V=A#^mVmuOAz)(KVI<%v5NY0%Goy!{9&o41upsPWk(yFuRP|A4q6NMnX%V~MT zi_Rb-Bno2kI+j0Cw`@ydy{e%ARS#Z%b6I%_yfo_ZKXr4BLVoHzBKJ^ZG z-2>2IzU)55@9C|?_P$ew^-7zEiAKG1XAi{!3h%1m#9s%^pGy6S9wKFYY4<$djeoJP z{GI}Vd%idY$4_fh(7NXm7#;cC!DS&-{tGr!Qze{^%bUx2jgG@-kMta^q-EwrKB}d8 z{%FT>rFk_bzW<{lc%eYlrsiYTZXGgzD1&lmRyp+c1O=0=zAX=KV62bx-a~JP{cPF4 zU$-XT#(9&T>l@bMu3nSr{)%-5lV+0t&bxip4DVJ~vlL$J2P6X~ zd{FS8vm{Lhrieul*7&(AgPuXhjpGila%6_?-+k#b)cdk#M1jB*nE>G6NGOr+Ek{`= z9b%S1`$`=g0CC$>0$Db;l_szReLYVmce*(()9%Zz1`*fNXhI*oRlerWHarD(v^W^c zuc1Vuw6Gbp7ZsoRH>QGt#&lv;5G~Ovt$%7VFd*-rN2>UjbOWBFGNGO`bru7CFB4tn zL`^?69Lj_g_TA&`9`dSI8s|)K|QM0 zybvV7!>xDY|6c6y;Q}qs`){1+WQu_5Dgd8Qe|q}}bxjH+joQQtqs1IVZn6{e7T{ia zF|=^xa%eWO%(x<7j*QZbcU_;aVaVP!arexOLOtoSNt*hvsRL%}%)jPetSich(`b-^ zMZ$PM9%s@%*jPVz0Z^W*cK_>G4f}+eEVX`HOaHg#!B`<4v;x}zDLMR*M27`kNfp!! zOfdt(>k-g>7jf^{Se@3$8<+;R*cYtw+wD_Z8Pl~!JDCUEPq{Ea*!J9`%ihyNJZ30i zmfve}S5<$Uso}_?SuI$ks|{-ddGLu9WR9`^9)Kdi@Vs;x#SY-xp}wHPU0|vEA7234 z@BN1z7OF=OOQtPF$4twn3!HTVlUVD_)ubMM7PEPoiC6lQgL2q9PK4~e8v-OuH%lie z?NgBLkIdPMG$QBq(>r^AOHB`|*1#*!2Z? zuU8H|FD`OBRu^(R?Z-Vhr0j;FLpS~a34KREnd}B=EYHS*>Hm+f%tgJt!4J8Q`qn^4 z9F=tO#JRJ}tzA`vx$nZ)O%wC?Uiv0+_nz}5Lj4ki*&=K&*#U`=rv z`Q@Q{+IhAj@6lrNK2B=8Yln!O2%zomfRehFT~;!O@(@Xy|1Jlw*uOB-M$#6K^)QBm z_7%#QVUDPwnW{iOV-grMQQU|3{=BQMh}c5(yMGdoQf*)k9-B zMQ(^GdJh+y)>qJprknS!%WxqM>HlHOP#7UVdy>%PW$!l72J`n-p7j(DBKoGxXWh(Y z>BFDZl|7knU_jg_SSbvFk8)39%2)Hu5W0}HKlh>EaqvFoXI&56Yy)3) zQkE4X^P0QnPn?iUUVHJZXzPp`s5uv?pG{K9IgGoHvcmlBxubi|iF7n{)mhenIcxGs zgr0OpQy#Y#u=5lOyiECfE_Sn?Fj1LyoRKcbTgX{p<T*v!CGkPc)pcA2D=4Ekp0Gb*wpy7S88C%Ywsbr?MI(3UdsCM?XJ1X%*hNjB)XqZ*W(qDdtSb z<3XN74ARXL3=c^bfW~F%NM^5*Zx92>Wq`&M625p~j$8mYwLbk%Kf)jbn#<2z$%vP5 zy#b>-tF-S2_AB4;R^K&^-1LJrUmi@9rB^FLF)-k&YHK8P+k@RCJ1qSTZ@=kHxA3l$ zmK_ZG)l6(nmCR1a8|;QF-B5e_ELnjJ1$m-;4UXX?WytF_wz7#&AjwZYTMVieLbq@R z3t-q|G4^BB#EpNu4uyfDebB+-uu_$9>y-dzB30Y9F=R zrW-Heqnj*InPTWHgR9v^R7~hokldh&h8=HDhMW(EFfim1*{)5Lc1-+eBVkK-2!u=N zuZKABgJs3I--NbjE;>Undg6uK`^U>AQ6V zhc!RhYgvrmeGNsftr+(C<_MtuV$`5RZTf#5r=DR?gWG->#})#=(td%C3`oO+2B7im zUqY}&a_QNTn?s+?=mNXiREN%x_=(H)L|DtYPY>SR3pQfBOel7G_jR_{!9`dSj8Up-`JgcB;=Oor)U=_EVjF3C5{Sqh8cq=~bRjoBpoc$kJCgtTyZGSpQ4= zYi$6b$-dGmuTDF&@amhV?cU05g(AZV&v2$4m&j_~GZk;&keSO(@LRESRZ&p`dV*6w z2$em~p*8yM6j;SYorw`M5K2mluJq7P5Yn$VtZj8DEs2Zk=O@4T&Q}>~f31Z{uk}`E z{Dp{KObh1kk~~MfLUod72{Pk6G@T$_0_N??lOrdR=Z;VV#m0l)&@hz{Z?)@sgImi-&i1@95g53rON83v!yVPDHRU*Mzc4yZ(-Fr z{8{WXmIJf7jeswk$;6s~Qac6QyM3W&`}m#gRt=rr95A+Ad&wSAgvXZ|F))rBJVJ5W1CsjN`QaOzct2ocq#0!v zmj#075)C!3oS>&N;aHS@<+c>RHL)8j^p)k(8#7$LEx!1g_1^02!4_qA=;uhKW=+ix zGX%+vBMiRiF^^jm{mdO(?GdWJ#unO#_F^7mhT8)s(z_WlwFyJ#Xh)k5+RG2f;LC*K**1dr`#}~6A=0B=I&V;%zDA1)d@G!X#Rng)7G*2k8Kg447r0ox> z5NK`d(H-afBwo9feDOUi>;BbPsu!2|=@g=3j*PY}@YrOb+SX6?#Yb2xaaK!?>SX1J z_!VsB`2n1=wwSftkydm!39|-1?c%Epx?TO<(#GO~I&{f4+)XwRk<7RQ1~5>QcKH|D z?!}j1ueO0Lk;FZ{k4FA_(S`Ot0w~tl&m0duID*f6RY#bkw||o;kZ# zISYNTb|{~|X$m$Q-Jv#uxyw)eM0gIv`V#wOAp&Vv@>X4_tSZ&L#juM@$S9 zx_X_tLh<_^-F;LAQ09s@sPb%PMTrcw*HUV0P=RYSlM&AXEOI&&R&YCm_S<7DRBx^L zA^R^iwW+LMk(r*$Pq-fKU5X@=mQ=`ErO30H@@&qqnI7zJcrbSh+H<V ze&7Uli0xj@WrW#&-9%*FP~kPYF_YYM_hs5~|ExMynQ%qvq`leRB6W0yhC@pCb8>_P zlf=F~WMv_u*-DV=UaVu#2rlzK{q8D95VwZrfV?gj@rSNWXFvktUq)V5+YrlxwX302ae(;aG4e>L-M@3J+-f3IT{b9l!kg*2M zC1+ND9}6m^()LE87Mt+^Q|)!y#suc&v26C=0W88%a{?)E8Yvo@kM&KNMaOst#|-_CbUTm}WS@-c>nRb;&z^ zYr)+IE$1=jov(CZ%3uR+`~NI>1&Gs6W(jaamjcN$a`2!*nO}l|b%?)Q%%UWzw>A`C zR@px(P*7j$TK?jbv*%x)e^|jcLsv}aF(Z0=7(%Oa7+1wY>{B>d+i&ZA$}k(qgZPZY z;VkW~8eWnU&HPIAbco?&tc2O1$6=7n{u|^Y*nXoac{o1W-6aXfy~KlNbJfLoq~6;+ zDYmnv--Fhqrl+UV#k@_(1=gWNtqhyVKN=9CZ-{Ohi>e=~bm4IKbhM%%W zW8oXE!rGpV7Wt(_^4nndH1_imheaWzDi|I})9ZVZ9>pN+P%dVc5wG`Ze*4`@rjn1^ z`ln(;vPBHQUb}y8S>=8q__r7g+=z$>!pReVB0@XKchAvyGjLQs-u>+w%`frV4FeIG zj=7n~hGrwx*&5aHy(7X$bDZ7YhcP%(*>G^lAYMK;qG~V8Jz@b7oNg;IA1z$9@TbzW z;@I51@Ekef#qbxnG$Y8Z%bm~ibZ=4#%yKr%#b)CDrfKN`ujIY?tA4h9)i~dZ4E;ZM znvb$n2)zn$Wx&zlW%mJZDh28ox$@%`w3i7YFepXUChw}$UXKI=-TM51`M#FH=tdr*mQ!c=aB1296Lu>iTTKZWss0f z5~ihdImPN$aTle_AdbYC^31}_^EK|9R&l#%3hbx;8vJ+Gp^tm{9JDILu*1PW!rh^Dn9p<)h#Sl4kKM%nm<+!ESSk* zC;lLNT$fgr-!+{aBsSx$41b}yy6o>r3F#1&iv3cfY2N<+`0qJ+>=&Qxs}JOEkD?^l-F5i`t5+zNuvJf z3Fh4$mNqiFXL-aq4U4K@Ae$fq-TDT`rvrx;gqx96w^*@s=mcthCaIyPe(w)6kI{EqV10tcShHU9eeAPs)s?6#vrq}>y3FeTJu$Udha+z zs7}rmA@yR(L&>35sNjQqrw}o^)UitMU!5g6nnG)(tgst!^`FKJEzI1(d@j_w@;^hr zgYxlIRYjho4U$bhczfq&YySCqCE(5_d>l(4tk1v9!V7PB%Vx{QO=G2NC@c1%3rEzw zN<6i?h;CJX>h)kn49Sr)g#Em6km6ESP`1qc5C3ZHizN>r>V-fSS=X1nT{+Thh@kC! z(H=PlqDt7V6gOYezXUK-dretz!1?IUD6&eL2b!4=9h+HUO&DYZKMM>|YhlEEg?q?S z^XT4$2Fd|zT=x3U#L1|F;-#`to-Y6hiYkWdO=rRC)meY72pIfl`3zEGDU8($iWR^K zI$nq80aSJII<;#W5Pj>^_T&013BJ*O89Uoq z5>;Paa^E}xar^r=!pexg&OTM8wluk4R~Ru=)Hgk`Y#i_$jk{jc8hx}?(dW*X!l4vs z6_%$s#duJJFmaFc-5#>v6Yea=I~)s_pXGS>Tkz?s+WS}>Qp<9MappMLXpkXpSM~SmH6u)`Z5>o02kJs;w@KhdiZ3}29y*xr|6tMo zBHzGic+b+dTd!xOJ;p{Rguh^corJ;K?R6daayQKm+0rf7|AXg0qs!R9eS7t4{G=fs z1$=?kK1Ih=gEkI>@jgXDWHZt*C7FUEWs|u^pE3Z``^K|1KEC^sbN*4nQUfRc_AyE0 zn)?RrGjgPkzfE~_s!rDB!fDsV+*|kEX4+DyS#8%!cshn;s8svwBXSsDGX2ZRa0={* z=`p1F{zD17*Rk>Uk_cw3t5j=9-d6$}MoM~z{v{t^M!g75-+o8_XkP@CZWUQ2z!^26 zCNOu~hgrrK)y>bgqb{`Q_1^zrG4;cGarP!nb4E~(ZKWc`LVeEq;IewVneLp^ZU2+% z95PgN*M5v7Q;ZlGvM#`&u2NdHm%&gZ{bZM5wBCp&?HeZhwU87wyT_z!n4z+1?=RvXZ^72d*%+R1s1$KbAFtR|= zw;MEq=O7pMIKpFwKH6$OOszJAf<_Z<1)36cB>D>|Z6$gJL~jH`n3MMou$#Si%rDAu z4pSkJspG|^CJ86vg6kkfXsA_`8@8iOryOe!Qhn8SV6}mPlof3=WJRVqAr_b;e->`Z zMR(p|K|$L0^6;u~USxg#B6-ZNc%E1dv*^P=|2k*^NOBni#G%9Y?##{=)8KZwh85OL zSBG9|gb|hdmY^gn(ziY&O5#@I?W)W;361Yb^VQNpz0A7&^(7HRAsUvw#)fvhocvja zLxV65J0_$>&cVRctJFsn^qLos^tG`+B0_gQ{NeOwKt-!C^gGFufdtPT*Vi>l#X1|V z2XxsAcixN)Ekq=a##_^=k_^BFH5_zpvPDRP>u6+3$}i&b zy0@FdzAHw?i9OqnlTts_w5D@Nd#eM)KKEuN#m{|AJyscxa}(eA?z4&4yvXo{OBS65 z-?gW;<+;+ntM}U_yTmHm6*2zj0Imj<&ZgE9Wj|gfsXhrVH-c0p$7HXnR8bxDYOi z=_r3FA~u`L&2;Vir8}P3)k|@c?sK1U@&iWo{HEXcoy>6wQSuJ+b4l%aTBuigs&k@Y<2c=S3Ef?p zH>ki4yDuXdo_eu>X1{E$g(Q-u#zVXN^&%70guoizo7x(kQ0OZ}H$O9UB}(FaX8Ct1 zFpx~}EbHf2r6V;x=@8GH$C2|6*?K~?LrtMYd^bw*WYXhA z_))@RMH;nZedW3+qfWbv<|_#BYOxX^rhbN+!za)|!|8K*LRs(R$O*2SDM{g9k7e{u zN4VIdi}e#0&h?sBxu$>Yy%)j(k1V2fuhp8r!}gfF@b;F?U`6}YnnMh1&sSU&lR^?# zu!61+lGsuFEfDraX3+$QZibCbKzc{75G^T7@WZSQ)j5898G1AOXB*H*TSd`f<`IK# zm1%&t?i|2Z-a&r!pJehzg@!awNp)R)aa?q_SqGrxE5u+T#f?K2;GAHV?O&>!W@Q*k)7=g2vDW+7K zbyY9i{|nOF*SbMYoRQSAbSH2y$bE5(@d6xKxcF#@TE~X#3o=;`0sc!RupdRmQsML? z&>SCwS{FOpSr+@6Uuz3m`hj}(^g`Jz|6?({!%WVJn$H|ugxW+x-GEA?J&U^ugj3Nb z;65~)W<}iH2PJ@st8LtLfSOLXYgj=9<;?ih7rq$bXW9J#!B8!Wu6#U`A$wlcoC*&` z_9Js~7%m79#+edeT&P`@_Ng@e&5J+pqpx%31tAF71)pcz~-yJ>P5yX(nuM4;bUHDa8E(~~l{j~JeCGkX>nHJDpgSf&bTHEf)qw8{Q~CBPEVen|MW2P3vmf`8X9-g|>>ddp zcgfjbl~(?3Wa*NzQH>4nsM$3}Ul>pX1xC0oF3TZXe7=V!9!n?WgvH|R zpbruczmB%z=zkZ>=1R|gXwGThLELqD5KCUhtiRGT*JwKIvzbzV%ZU!e!VcNHSSX3> zObH|oohc8nvQZ2}q??C}@>!fe3gH+HF@4(qWqi>;ag~md#D;cl8&gQb^?2a@5cikT z=7r78@&5gV3Ggc9f=<<8v~yz`NcEGvbX1V_`IL(&+Z>LB zM~$ok2qXzod@1$TEl*U~H$V5g$er{Uj^($sWb7Nr{gsIbE(`$LRGECTOraXiU%=uq z0zvpi1S%)RxTjzoVcR4#10)fs()4Mtsa@e?9j)Bk!LsYyXIZga2q7d%`vQE!V@<1Y zmkpH3LeXJNO9f7l>F84g;huc=4nk(UnU}RLZmYk2TtB#lv34K(?8~gyx-mN%g=U44 zOPdr_!j-;IEbe|l9-buuKEy^Q9MLjSKG$S6dz)!U_32{1)N}L)3+COmlg=nY1@od$ zJ<0z-B%sisAR1yh>z-RfQQb6M4i-d#vxvb~f69M{JLPZv1JSCh1$gQ*LxOF-tH9!k zbQ0ZW)S7)qCSF|=2`q_A3}OHBNBueZwTTz^ar~gz#2KA74&&D)KHt~m4F_nK<^*7_ z!!pN@xiGkq%>1N(rNxw$zu-=1t*IpAy$ z4~dD0w%9;E?(greVWZ3(o9ux`elM>Rek#0 zO=#-(4p5B+wFzlEU7^k{3EdL6sIp|K*>xrriI`}E8ze|z-$YpN`^_teL_7P`%e>IN z7tNiH619P+0Q1hBR|W#POOta)1|LkIRtgz zMJ9VOxXN#o)mlXS=u%`Q>~PBuKEmOWsIuQRp{y%!ty{fEyL0gV)$LQeL#pqX3L@SR zJ2Gb^E9+KVd?;joVOXlGie3?z6>(>u(i!(qGz(W( ze~^xj&IRF<98ypEis{Y_FoHn%C0bW(XeF#Lj=2WUEBqKNPPFppEH?_a3}-h906X}C zSYKcZFU`Om5YlWhh@ogzCn3NvuM~F9jOX|xe-X*!YL+#ceh_tJoHXz`aTnvSrOAZ| zOtdGz?QdT!oAJr3(XL2G(p%2X4{xEohU&vd_zQ(U%ihHOlKPWnb$&YYhx48?|R++>`5?sxvM?!;ru|9 zZ#nwuTK^S%ce<+ggdJBE&fRrXN7O!{nu`%q`M{2Ef_+IRad2cf01P9pST9AOK>y75c!9}~)Et^6$`&Nm{wzWcm4c0j9DF!xJTpGrMp3esI4D_iiDe`sswXSu{dQZE_`^A11 z?Z@Hw=65mVu^%X`>;$mciK}XiZ{xw7I_!t)S00^JuxdCXhIRO~S*lPS(S^je`DH4E zxbKNs8RL`N?gCQ@YSOU=>0FE#Ku#DRO7JA&fu-X8b;3!^#{=7`WsDXUxfUsE(FKSQ z&=N`A7IwLq%+vt(F;z+T=uZNl=@K4|E%p{p^o5(BGjsE|WOR`%8+XgGW8xJTFJc4L zVY#L`OdnSM{HyS$fX1)3_JuNNH1aDsDqi>CzCT5=kY5zV<~29bX)c^I8R5n&ymHkx zj(QC4t#mDK;2xi8O%V;C{HqDQeM64=b4@sa*N_K0a&ro4+8LY6cFHz< ze|!g}zF|tDrP=`+U7KwKl20gdW1%!iN>1=uxA|NZJ2peruBOj?RBPb~8G;s6xIi6- z?_odhafsxoxiBf zwZZ)c*)FLc0#wE~bXw0TPBYl+h9hs|DYr_B4LR_YL@S1hQs=p zNEh%_fUvWZCbJtaF#kP5=(O#{8|g&Kmz1&8{@Lufw^DhtvKx955~aqxi2C=)Z-!Kd z+m-u+#^U4(HYn6a1w652kO0bYBt&goyx(n?MR^kI+{Q?0Y{G~W2) z0dS3fuJ?SU(6ZDp=kUley%PK}K_;YQyK|U|?7t9SHiyIfpT4a_kUVIhH4PSaj@3mo z`z}|mHhx1Pq?@(3vTBb5HTXuFAzFZEt0D-fw_kd=XvwIUh3VXTm{wbDA~cESd5cI1 zd>6=&AvG3yu+)`9oxmfrDQ(1fzv(_0l?bp{a364dXLRRBI8kBv!KsL;brY)#E3`o{ z3TlWUsS0{Voci?6MejccG9x_KiqN>So*1{25r6BSl9jUyR}1TgXBLL7Pr6Wv~Nu47;fbiU7TbL}>qmtl36YSZ() zVf@nqW(As~#`@bIC+AxSw!O5Pocf&rYaCFm?Jd?XR)p#@{!|5^Ws@wd855)mI^8y{ zws+VvGXW6%xoj@JkGb=~%oJ~7m6+uhOv?bH+jJJ~eFgp+}~*^C+3>R-MY!IZQoabCh( zN(T+z@Oyc^C)WqQESmh{d!!T8zS(!wX=R#hEKxMXy(eg zZ+Cwm1a%?;RH$h2_ws|nRjn8ZY!>3gn+6Ep4xT|AeFox7!rac2Lw?jsz}JqPE?5JG zok0}q1P;cuzs%Yrze|&d$oTr<`Lx{fbq2OV=!3v-ODq(n?|WxuhtmwJBIoW^^FB+D z-?Ok9HBKc5@)L(W&vmI{prL?4^OE9TR)bELS=<>*w%&aKjzi*@;5#P3moG@dm{Eke zhE#Is;&=o|{2GWai}7LYEI+gmc^Kj4K7w7n)+9godg?yB2?xs}pF1<*!Sv?D~Uvbkgs9xx9s#6zBv9l@ox>d#H6eqw^KZO;Vg}h!q zI33^$4}yF*q+q{DsJsa(SsV!YQ#zi^IF9MQV6i{SiN4dWWCi%YQ+hNc1r!^+<(YnB zG62-D`M3w3Q2;@X{S`n`{QO>migDpz0FK`->sYDOESs6u>-~<}_XN_6><2g7U#XC{ z$#Ig;n{_yEMnlvx-lP*;ts#DHV0r8j518>~33?Ak#jocW>uk>6V||p7{4rov#RS9c zdPD6r`qF1om9r!zS4Jk1>7fn#GCnmD=JIt1Na`X)=*LP7R!3XATgk`;&U*P<(0d z9p<0T&eYqQ9jot39FxpfuPSPYlfQ$s-*;+c1KL+cHIVcG5`H~^Ryu1Hk7%Nf$TCwR!SzG31@NHpm`mcp8v!wyWM49TjTxASJ-8JP*MTHLC}hF==PUOh8kaaXeGFGd<|e29vSDaS ztPeu&zv0^wN}Hahi`$pcDs~FVt2F;K!q}q*Y@{7i#stWfU`u2La4aerBKhV`^zG~j zJWvtZpcHIP7x*tfLSQcng6D(`HVp4=LWp_0Xt=2wEHjK)!DSz_Z?5J@>awRyk?azj zU-kdSs~cp))*pfJ_q7u`IsCq8F|OShB~D56S(Mwwlt?{yURE7#eI&WcpVq(@9Fd~g zeUiD!a4w51Nj(YzLnau+O3MDub|?loF0=<#jLztAM>PruE7yNDD0L}y=Ayuc?^?Ni zf~%GK=iEhn2}xKp7GonJx!JpDmDsco$|$XtRdUDwbM9$9s7x9-of2nKNj~?b@UOKz z9{`=Irz^ba-c&1vSQxSh;I2`cKc8-4)aCy%#bam;3_8vSJ-jw`_}lyukEC~z00EbC zI*dU3F21A)dSZr{qA5QF+{a%D`h#?8o%M?)*hWxuqnQD(TpcmfNq&UN$BmB)0!r8) zxno@Q?$_D&*4(rW6b+?-Y^5|*P`DHmJ%pI<6*yP)o}2^?>d7P#bd2j=vvx2mfLW@R zQLD`%buR*}nzNYNf%68w-D$7%v|=bXg1mYrdZy~}(@RRZ-U+Gx=nmCjVxr5Ag# zLw3R29-MHJl|`mRxj#sv@EfyR#-q>BE-XFEENbV$#dWM?!VjU8~kKZsd@G=HPrI{HiqN&j<92*-3$^M*;n@rG*i! zvi#?j;lc5w>@+r!6*CVUrN9as=S3?(ZBT979$5R#ZpPm?2VjIyQcEFp9orGR>f;G? zK<~FiYY6ow-&}|v7k?+03TC++so$)2~rN``u z>N%j$AbNQLX_!evzG8abf=15260vIXdz7K^a$YS)iw{@x5<|Rr#ii|ov=LJ{eu>dZYe_ip$ZuzvRu1dpjQK1BvP zH~m#t=2_wy>9+YkdNF-z` zQ*#7=^r%R*pIi2AI`>n9>(QJVE1k8?Ilav<)NUjW^O$}^yZZ{_Uwn!4Fq1`aslX;Y zj`XDIm`E1sz|wShA=?a@ZGKDSMU#Z3$E!1nZ)g^Eg3ZDoSN6@RXrGVCHvMIauS7d> zuJltXf9)LdTWdF!n%-iA9b#2$W#i??K)zYho^((ZqluvhAr@{H{diy0%@-~VW zKYC|2Ma)2^=skdLT@ZVqJfiCDqS@~qIGexL(BKy6Aw9ch0hoHN&E+m3*uka9+AIh3gTWdSe~W({-&^oFw`!j7$DcsF$7`pO?kRMK<9h=SV?cmyJIe`$4|zoI(6u9#qY9zM?#zNe^!Dl2>Z^dH`>`wSY# ztU;V*+g0R0DH6EnJA$U{QL&T~&s{`smeC2I-5mzv=v$l@iF;yN0hMibU=CG^e>J;+9k`Si9PzLaj$>}QKI6lWmO_o+_( zmhxA*0|-Na`+*J1qEMIXZf9rb#;pcOw>EDeDjb!|GumQ2!1ac;YqU|X;F@l1_lemzTN0J|U zFJF(kO21aHg)*KfuKT=BA{VDkOvlx(b{f|A9D69_BHUm#S$F>~`Mt@GesjLp3;reY zP~q>6Tt;`XkjqV?i7lqPbWGh`y<7dq<}pDHl-dDA4QG6`QDq)+vq_&HfW!}P6Cp4d zt>Qnli5ri*I1ILEOGD~3Y!@2^Jmcy1xDXmKolC?at}_6;neEfca0rLHT}NLpoUYh` zDbCtfZnYN&>}m-(F{5d1=)bBuZ?OcP`GmsQV@kn%JMJUIep`Avon#8=ATpEo-@hg& z12f-)R=HCD%pUjvbWa|P!}u)=wInpZG*LHKrZDMeC>Qils^IyY)x;kDRs4c3!DDOG zAptSsf#1X>kSli|Qka@S)6O4un-2aKL?bcV;$*>KSxHovjrfZ^-+c#>;(42yj71K| zzRyFiLrwv$rPcNA{mtv=o(*JDA0kS93>OE0D{KMJzLk$cc_5dCLWnJcFJd6_>BpE< z?aW9;^!;arQcIjloW&YL+~MkNO&a>N=pmhg>{SM<@`a&VeUA`ay*P@R$_+WS2%r?_ zs&Z%c`>ie+%!I=Lz>$9$7a`-`hoc&*dl60^whsaQ;~9~@JYn1Oc_bmgVVyAzUOYgZ z#j{`#D_YZ)(wa5;qzR#zo4a|-ANJjBB90r4Iun3*BkMxw_Ti>SjhktsmR|BPCLt>9 zZ_3eQjweI*-8+HNt)$9^s|+10w@sU!PY{`#BnF!ULS=#{k0Zr5`yOS?p8PfWbKT`6 z@T+PeRJ4`fj5t8bMs)0>o9|C>mBTlfQ*nFG#Rri-Q7}E}+eaz`LmO!`Y_pHkoAruu z`&!5VNnA3IG$}Pz)V&pt&AF!$E{J-;or3vWv3&Sl&9KzG+ae73Zf}=aP*SCI1{?0T z9SAC)W(?DSKOkcmW$(K5Bl?c@(5#>J#j@eq#ctX~$TIjkl>Wrfv%Ey+bl1Z-v?NxJ zwZ9!ae-MsHPUx&_W22?9$mCE%&~lzVG?hDXM%~gXGk+Q!Jf0BspkMWxy;^!n<6JIrSYjv z6F%~$8)0^qbUho9Sdf97b_n({$;|XH9-RHrohHuPcro@03KEPFejN&q?&nJFoIQY; zSI#uL6>2^^yOR!51OLO65xGas55dPG;3=uQ35ZYW04#+~byXQf^7Vq`G z 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 Pe7HEdAEg|=c=LY&YVNkY literal 0 HcmV?d00001 diff --git a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000000000000000000000000000000000000..13b35eba55c6dabc3aac36f33d859266c18fa0d0 GIT binary patch literal 5680 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 literal 0 HcmV?d00001 diff --git a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000000000000000000000000000000000000..0a3f5fa40fb3d1e0710331a48de5d256da3f275d GIT binary patch literal 520 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 literal 0 HcmV?d00001 diff --git a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000000000000000000000000000000000000..2f1632cfddf3d9dade342351e627a0a75609fb46 GIT binary patch literal 2218 zcmV;b2vzrqP)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91K%fHv1ONa40RR91KmY&$07g+lumAuE6iGxuRCodHTWf3-RTMruyW6Fu zQYeUM04eX6D5c0FCjKKPrco1(K`<0SL=crI{PC3-^hZU0kQie$gh-5!7z6SH6Q0J% zqot*`H1q{R5fHFYS}dje@;kG=v$L0(yY0?wY2%*c?A&{2?!D*x?m71{of2gv!$5|C z3>qG_BW}7K_yUcT3A5C6QD<+{aq?x;MAUyAiJn#Jv8_zZtQ{P zTRzbL3U9!qVuZzS$xKU10KiW~Bgdcv1-!uAhQxf3a7q+dU6lj?yoO4Lq4TUN4}h{N z*fIM=SS8|C2$(T>w$`t@3Tka!(r!7W`x z-isCVgQD^mG-MJ;XtJuK3V{Vy72GQ83KRWsHU?e*wrhKk=ApIYeDqLi;JI1e zuvv}5^Dc=k7F7?nm3nIw$NVmU-+R>> zyqOR$-2SDpJ}Pt;^RkJytDVXNTsu|mI1`~G7yw`EJR?VkGfNdqK9^^8P`JdtTV&tX4CNcV4 z&N06nZa??Fw1AgQOUSE2AmPE@WO(Fvo`%m`cDgiv(fAeRA%3AGXUbsGw{7Q`cY;1BI#ac3iN$$Hw z0LT0;xc%=q)me?Y*$xI@GRAw?+}>=9D+KTk??-HJ4=A>`V&vKFS75@MKdSF1JTq{S zc1!^8?YA|t+uKigaq!sT;Z!&0F2=k7F0PIU;F$leJLaw2UI6FL^w}OG&!;+b%ya1c z1n+6-inU<0VM-Y_s5iTElq)ThyF?StVcebpGI znw#+zLx2@ah{$_2jn+@}(zJZ{+}_N9BM;z)0yr|gF-4=Iyu@hI*Lk=-A8f#bAzc9f z`Kd6K--x@t04swJVC3JK1cHY-Hq+=|PN-VO;?^_C#;coU6TDP7Bt`;{JTG;!+jj(` zw5cLQ-(Cz-Tlb`A^w7|R56Ce;Wmr0)$KWOUZ6ai0PhzPeHwdl0H(etP zUV`va_i0s-4#DkNM8lUlqI7>YQLf)(lz9Q3Uw`)nc(z3{m5ZE77Ul$V%m)E}3&8L0 z-XaU|eB~Is08eORPk;=<>!1w)Kf}FOVS2l&9~A+@R#koFJ$Czd%Y(ENTV&A~U(IPI z;UY+gf+&6ioZ=roly<0Yst8ck>(M=S?B-ys3mLdM&)ex!hbt+ol|T6CTS+Sc0jv(& z7ijdvFwBq;0a{%3GGwkDKTeG`b+lyj0jjS1OMkYnepCdoosNY`*zmBIo*981BU%%U z@~$z0V`OVtIbEx5pa|Tct|Lg#ZQf5OYMUMRD>Wdxm5SAqV2}3!ceE-M2 z@O~lQ0OiKQp}o9I;?uxCgYVV?FH|?Riri*U$Zi_`V2eiA>l zdSm6;SEm6#T+SpcE8Ro_f2AwxzI z44hfe^WE3!h@W3RDyA_H440cpmYkv*)6m1XazTqw%=E5Xv7^@^^T7Q2wxr+Z2kVYr + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/macos/Runner/Configs/AppInfo.xcconfig b/mobile/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..7baec73 --- /dev/null +++ b/mobile/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = privacy_maps + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.privacymaps.privacyMaps + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 com.privacymaps. All rights reserved. diff --git a/mobile/macos/Runner/Configs/Debug.xcconfig b/mobile/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/mobile/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/mobile/macos/Runner/Configs/Release.xcconfig b/mobile/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/mobile/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/mobile/macos/Runner/Configs/Warnings.xcconfig b/mobile/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/mobile/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/mobile/macos/Runner/DebugProfile.entitlements b/mobile/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/mobile/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/mobile/macos/Runner/Info.plist b/mobile/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/mobile/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/mobile/macos/Runner/MainFlutterWindow.swift b/mobile/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/mobile/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/mobile/macos/Runner/Release.entitlements b/mobile/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/mobile/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/mobile/macos/RunnerTests/RunnerTests.swift b/mobile/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/mobile/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock new file mode 100644 index 0000000..578c82a --- /dev/null +++ b/mobile/pubspec.lock @@ -0,0 +1,1026 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + url: "https://pub.dev" + source: hosted + version: "85.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: f4ad0fea5f102201015c9aae9d93bc02f75dd9491529a8c21f88d17a8523d44c + url: "https://pub.dev" + source: hosted + version: "7.6.0" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: a5ab7590c27b779f3d4de67f31c4109dbe13dd7339f86461a6f2a8ab2594d8ce + url: "https://pub.dev" + source: hosted + version: "0.13.4" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 + url: "https://pub.dev" + source: hosted + version: "2.12.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + url: "https://pub.dev" + source: hosted + version: "4.1.1" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62 + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792" + url: "https://pub.dev" + source: hosted + version: "9.1.2" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af" + url: "https://pub.dev" + source: hosted + version: "8.12.5" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + url: "https://pub.dev" + source: hosted + version: "2.0.3" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + url: "https://pub.dev" + source: hosted + version: "4.11.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + custom_lint_core: + dependency: transitive + description: + name: custom_lint_core + sha256: "31110af3dde9d29fb10828ca33f1dce24d2798477b167675543ce3d208dee8be" + url: "https://pub.dev" + source: hosted + version: "0.7.5" + custom_lint_visitor: + dependency: transitive + description: + name: custom_lint_visitor + sha256: "4a86a0d8415a91fbb8298d6ef03e9034dc8e323a599ddc4120a0e36c433983a2" + url: "https://pub.dev" + source: hosted + version: "1.0.0+7.7.0" + dart_earcut: + dependency: transitive + description: + name: dart_earcut + sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b + url: "https://pub.dev" + source: hosted + version: "1.2.0" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + dio: + dependency: "direct main" + description: + name: dio + sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c + url: "https://pub.dev" + source: hosted + version: "5.9.2" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + drift: + dependency: "direct main" + description: + name: drift + sha256: "540cf382a3bfa99b76e51514db5b0ebcd81ce3679b7c1c9cb9478ff3735e47a1" + url: "https://pub.dev" + source: hosted + version: "2.28.2" + drift_dev: + dependency: "direct dev" + description: + name: drift_dev + sha256: "68c138e884527d2bd61df2ade276c3a144df84d1adeb0ab8f3196b5afe021bd4" + url: "https://pub.dev" + source: hosted + version: "2.28.0" + executor_lib: + dependency: transitive + description: + name: executor_lib + sha256: "95ddf2957d9942d9702855b38dd49677f0ee6a8b77d7b16c0e509c7669d17386" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" + url: "https://pub.dev" + source: hosted + version: "1.3.2" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_map: + dependency: "direct main" + description: + name: flutter_map + sha256: "2ecb34619a4be19df6f40c2f8dce1591675b4eff7a6857bd8f533706977385da" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + flutter_map_cancellable_tile_provider: + dependency: "direct main" + description: + name: flutter_map_cancellable_tile_provider + sha256: "03662220ce0cd784ad2f2a45c36fc379b8b315c74f5c12b5ff4a0515eab1acd1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + freezed: + dependency: "direct dev" + description: + name: freezed + sha256: "59a584c24b3acdc5250bb856d0d3e9c0b798ed14a4af1ddb7dc1c7b41df91c9c" + url: "https://pub.dev" + source: hosted + version: "2.5.8" + freezed_annotation: + dependency: "direct main" + description: + name: freezed_annotation + sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 + url: "https://pub.dev" + source: hosted + version: "2.4.4" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: "149876cc5207a0f5daf4fdd3bfcf0a0f27258b3fe95108fa084f527ad0568f1b" + url: "https://pub.dev" + source: hosted + version: "12.0.0" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d + url: "https://pub.dev" + source: hosted + version: "4.6.2" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22 + url: "https://pub.dev" + source: hosted + version: "2.3.13" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67" + url: "https://pub.dev" + source: hosted + version: "4.2.6" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172 + url: "https://pub.dev" + source: hosted + version: "4.1.3" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 + url: "https://pub.dev" + source: hosted + version: "14.8.1" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c + url: "https://pub.dev" + source: hosted + version: "6.9.5" + latlong2: + dependency: "direct main" + description: + name: latlong2 + sha256: "98227922caf49e6056f91b6c56945ea1c7b166f28ffcd5fb8e72fc0b453cc8fe" + url: "https://pub.dev" + source: hosted + version: "0.9.1" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec + url: "https://pub.dev" + source: hosted + version: "10.0.8" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + url: "https://pub.dev" + source: hosted + version: "3.0.9" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + lists: + dependency: transitive + description: + name: lists + sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + logger: + dependency: transitive + description: + name: logger + sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + mgrs_dart: + dependency: transitive + description: + name: mgrs_dart + sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: "direct main" + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37" + url: "https://pub.dev" + source: hosted + version: "2.2.19" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + polylabel: + dependency: transitive + description: + name: polylabel + sha256: "41b9099afb2aa6c1730bdd8a0fab1400d287694ec7615dd8516935fa3144214b" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + proj4dart: + dependency: transitive + description: + name: proj4dart + sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e + url: "https://pub.dev" + source: hosted + version: "2.1.0" + protobuf: + dependency: transitive + description: + name: protobuf + sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + recase: + dependency: transitive + description: + name: recase + sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 + url: "https://pub.dev" + source: hosted + version: "4.1.0" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + riverpod_analyzer_utils: + dependency: transitive + description: + name: riverpod_analyzer_utils + sha256: "837a6dc33f490706c7f4632c516bcd10804ee4d9ccc8046124ca56388715fdf3" + url: "https://pub.dev" + source: hosted + version: "0.5.9" + riverpod_annotation: + dependency: "direct main" + description: + name: riverpod_annotation + sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8 + url: "https://pub.dev" + source: hosted + version: "2.6.1" + riverpod_generator: + dependency: "direct dev" + description: + name: riverpod_generator + sha256: "120d3310f687f43e7011bb213b90a436f1bbc300f0e4b251a72c39bccb017a4f" + url: "https://pub.dev" + source: hosted + version: "2.6.4" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca + url: "https://pub.dev" + source: hosted + version: "1.3.7" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2" + url: "https://pub.dev" + source: hosted + version: "2.9.4" + sqlite3_flutter_libs: + dependency: "direct main" + description: + name: sqlite3_flutter_libs + sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad + url: "https://pub.dev" + source: hosted + version: "0.5.42" + sqlparser: + dependency: transitive + description: + name: sqlparser + sha256: "57090342af1ce32bb499aa641f4ecdd2d6231b9403cea537ac059e803cc20d67" + url: "https://pub.dev" + source: hosted + version: "0.41.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + url: "https://pub.dev" + source: hosted + version: "0.7.4" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + unicode: + dependency: transitive + description: + name: unicode + sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" + vector_map_tiles: + dependency: "direct main" + description: + name: vector_map_tiles + sha256: "4dc9243195c1a49c7be82cc1caed0d300242bb94381752af5f6868d9d1404e25" + url: "https://pub.dev" + source: hosted + version: "8.0.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vector_tile: + dependency: transitive + description: + name: vector_tile + sha256: "7ae290246e3a8734422672dbe791d3f7b8ab631734489fc6d405f1cc2080e38c" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + vector_tile_renderer: + dependency: transitive + description: + name: vector_tile_renderer + sha256: "89746f1108eccbc0b6f33fbbef3fcf394cda3733fc0d5064ea03d53a459b56d3" + url: "https://pub.dev" + source: hosted + version: "5.2.1" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" + url: "https://pub.dev" + source: hosted + version: "14.3.1" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + wkt_parser: + dependency: transitive + description: + name: wkt_parser + sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.7.2 <4.0.0" + flutter: ">=3.29.0" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml new file mode 100644 index 0000000..3faa416 --- /dev/null +++ b/mobile/pubspec.yaml @@ -0,0 +1,99 @@ +name: privacy_maps +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.7.2 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + flutter_map: ^7.0.0 + flutter_map_cancellable_tile_provider: ^3.0.0 + vector_map_tiles: ^8.0.0 + flutter_riverpod: ^2.5.0 + riverpod_annotation: ^2.3.0 + dio: ^5.4.0 + drift: ^2.16.0 + sqlite3_flutter_libs: ^0.5.0 + path_provider: ^2.1.0 + geolocator: ^12.0.0 + go_router: ^14.0.0 + freezed_annotation: ^2.4.0 + json_annotation: ^4.9.0 + latlong2: ^0.9.0 + path: ^1.9.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + build_runner: ^2.4.0 + freezed: ^2.5.0 + json_serializable: ^6.7.0 + drift_dev: ^2.16.0 + riverpod_generator: ^2.4.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/mobile/test/widget_test.dart b/mobile/test/widget_test.dart new file mode 100644 index 0000000..27bbadf --- /dev/null +++ b/mobile/test/widget_test.dart @@ -0,0 +1,8 @@ +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('App smoke test placeholder', (WidgetTester tester) async { + // Smoke tests to be added once app dependencies are available in test environment. + expect(true, isTrue); + }); +} diff --git a/mobile/web/favicon.png b/mobile/web/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..8aaa46ac1ae21512746f852a42ba87e4165dfdd1 GIT binary patch literal 917 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|I14-?iy0X7 zltGxWVyS%@P(fs7NJL45ua8x7ey(0(N`6wRUPW#JP&EUCO@$SZnVVXYs8ErclUHn2 zVXFjIVFhG^g!Ppaz)DK8ZIvQ?0~DO|i&7O#^-S~(l1AfjnEK zjFOT9D}DX)@^Za$W4-*MbbUihOG|wNBYh(yU7!lx;>x^|#0uTKVr7USFmqf|i<65o z3raHc^AtelCMM;Vme?vOfh>Xph&xL%(-1c06+^uR^q@XSM&D4+Kp$>4P^%3{)XKjo zGZknv$b36P8?Z_gF{nK@`XI}Z90TzwSQO}0J1!f2c(B=V`5aP@1P1a|PZ!4!3&Gl8 zTYqUsf!gYFyJnXpu0!n&N*SYAX-%d(5gVjrHJWqXQshj@!Zm{!01WsQrH~9=kTxW#6SvuapgMqt>$=j#%eyGrQzr zP{L-3gsMA^$I1&gsBAEL+vxi1*Igl=8#8`5?A-T5=z-sk46WA1IUT)AIZHx1rdUrf zVJrJn<74DDw`j)Ki#gt}mIT-Q`XRa2-jQXQoI%w`nb|XblvzK${ZzlV)m-XcwC(od z71_OEC5Bt9GEXosOXaPTYOia#R4ID2TiU~`zVMl08TV_C%DnU4^+HE>9(CE4D6?Fz oujB08i7adh9xk7*FX66dWH6F5TM;?E2b5PlUHx3vIVCg!0Dx9vYXATM literal 0 HcmV?d00001 diff --git a/mobile/web/icons/Icon-192.png b/mobile/web/icons/Icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..b749bfef07473333cf1dd31e9eed89862a5d52aa GIT binary patch literal 5292 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 literal 0 HcmV?d00001 diff --git a/mobile/web/icons/Icon-512.png b/mobile/web/icons/Icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..88cfd48dff1169879ba46840804b412fe02fefd6 GIT binary patch literal 8252 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 literal 0 HcmV?d00001 diff --git a/mobile/web/icons/Icon-maskable-192.png b/mobile/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000000000000000000000000000000000000..eb9b4d76e525556d5d89141648c724331630325d GIT binary patch literal 5594 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 X>c!)3PgKfL&lX^$Z>Cpu&6)6jvi^Z! literal 0 HcmV?d00001 diff --git a/mobile/web/icons/Icon-maskable-512.png b/mobile/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..d69c56691fbdb0b7efa65097c7cc1edac12a6d3e GIT binary patch literal 20998 zcmeFZ_gj-)&^4Nb2tlbLMU<{!p(#yjqEe+=0IA_oih%ScH9@5#MNp&}Y#;;(h=A0@ zh7{>lT2MkSQ344eAvrhici!td|HJuyvJm#Y_w1Q9Yu3!26dNlO-oxUDK_C#XnW^Co z5C{VN6#{~B0)K2j7}*1Xq(Nqemv23A-6&=ZpEijkVnSwVGqLv40?n0=p;k3-U5e5+ z+z3>aS`u9DS=!wg8ROu?X4TFoW6CFLL&{GzoVT)ldhLekLM|+j3tIxRd|*5=c{=s&*vfPdBr(Fyj(v@%eQj1Soy7m4^@VRl1~@-PV7y+c!xz$8436WBn$t{=}mEdK#k`aystimGgI{(IBx$!pAwFoE9Y`^t^;> zKAD)C(Dl^s%`?q5$P|fZf8Xymrtu^Pv(7D`rn>Z-w$Ahs!z9!94WNVxrJuXfHAaxg zC6s@|Z1$7R$(!#t%Jb{{s6(Y?NoQXDYq)!}X@jKPhe`{9KQ@sAU8y-5`xt?S9$jKH zoi}6m5PcG*^{kjvt+kwPpyQzVg4o)a>;LK`aaN2x4@itBD3Aq?yWTM20VRn1rrd+2 zKO=P0rMjEGq_UqpMa`~7B|p?xAN1SCoCp}QxAv8O`jLJ5CVh@umR%c%i^)6!o+~`F zaalSTQcl5iwOLC&H)efzd{8(88mo`GI(56T<(&p7>Qd^;R1hn1Y~jN~tApaL8>##U zd65bo8)79CplWxr#z4!6HvLz&N7_5AN#x;kLG?zQ(#p|lj<8VUlKY=Aw!ATqeL-VG z42gA!^cMNPj>(`ZMEbCrnkg*QTsn*u(nQPWI9pA{MQ=IsPTzd7q5E#7+z>Ch=fx$~ z;J|?(5jTo5UWGvsJa(Sx0?S#56+8SD!I^tftyeh_{5_31l6&Hywtn`bbqYDqGZXI( zCG7hBgvksX2ak8+)hB4jnxlO@A32C_RM&g&qDSb~3kM&)@A_j1*oTO@nicGUyv+%^ z=vB)4(q!ykzT==Z)3*3{atJ5}2PV*?Uw+HhN&+RvKvZL3p9E?gHjv{6zM!A|z|UHK z-r6jeLxbGn0D@q5aBzlco|nG2tr}N@m;CJX(4#Cn&p&sLKwzLFx1A5izu?X_X4x8r@K*d~7>t1~ zDW1Mv5O&WOxbzFC`DQ6yNJ(^u9vJdj$fl2dq`!Yba_0^vQHXV)vqv1gssZYzBct!j zHr9>ydtM8wIs}HI4=E}qAkv|BPWzh3^_yLH(|kdb?x56^BlDC)diWyPd*|f!`^12_U>TD^^94OCN0lVv~Sgvs94ecpE^}VY$w`qr_>Ue zTfH~;C<3H<0dS5Rkf_f@1x$Gms}gK#&k()IC0zb^QbR!YLoll)c$Agfi6MKI0dP_L z=Uou&u~~^2onea2%XZ@>`0x^L8CK6=I{ge;|HXMj)-@o~h&O{CuuwBX8pVqjJ*o}5 z#8&oF_p=uSo~8vn?R0!AMWvcbZmsrj{ZswRt(aEdbi~;HeVqIe)-6*1L%5u$Gbs}| zjFh?KL&U(rC2izSGtwP5FnsR@6$-1toz?RvLD^k~h9NfZgzHE7m!!7s6(;)RKo2z} zB$Ci@h({l?arO+vF;s35h=|WpefaOtKVx>l399}EsX@Oe3>>4MPy%h&^3N_`UTAHJ zI$u(|TYC~E4)|JwkWW3F!Tib=NzjHs5ii2uj0^m|Qlh-2VnB#+X~RZ|`SA*}}&8j9IDv?F;(Y^1=Z0?wWz;ikB zewU>MAXDi~O7a~?jx1x=&8GcR-fTp>{2Q`7#BE#N6D@FCp`?ht-<1|y(NArxE_WIu zP+GuG=Qq>SHWtS2M>34xwEw^uvo4|9)4s|Ac=ud?nHQ>ax@LvBqusFcjH0}{T3ZPQ zLO1l<@B_d-(IS682}5KA&qT1+{3jxKolW+1zL4inqBS-D>BohA!K5++41tM@ z@xe<-qz27}LnV#5lk&iC40M||JRmZ*A##K3+!j93eouU8@q-`W0r%7N`V$cR&JV;iX(@cS{#*5Q>~4BEDA)EikLSP@>Oo&Bt1Z~&0d5)COI%3$cLB_M?dK# z{yv2OqW!al-#AEs&QFd;WL5zCcp)JmCKJEdNsJlL9K@MnPegK23?G|O%v`@N{rIRa zi^7a}WBCD77@VQ-z_v{ZdRsWYrYgC$<^gRQwMCi6);%R~uIi31OMS}=gUTE(GKmCI z$zM>mytL{uNN+a&S38^ez(UT=iSw=l2f+a4)DyCA1Cs_N-r?Q@$3KTYosY!;pzQ0k zzh1G|kWCJjc(oZVBji@kN%)UBw(s{KaYGy=i{g3{)Z+&H8t2`^IuLLKWT6lL<-C(! zSF9K4xd-|VO;4}$s?Z7J_dYqD#Mt)WCDnsR{Kpjq275uUq6`v0y*!PHyS(}Zmv)_{>Vose9-$h8P0|y;YG)Bo}$(3Z%+Gs0RBmFiW!^5tBmDK-g zfe5%B*27ib+7|A*Fx5e)2%kIxh7xWoc3pZcXS2zik!63lAG1;sC1ja>BqH7D zODdi5lKW$$AFvxgC-l-)!c+9@YMC7a`w?G(P#MeEQ5xID#<}W$3bSmJ`8V*x2^3qz zVe<^^_8GHqYGF$nIQm0Xq2kAgYtm#UC1A(=&85w;rmg#v906 zT;RyMgbMpYOmS&S9c38^40oUp?!}#_84`aEVw;T;r%gTZkWeU;;FwM@0y0adt{-OK z(vGnPSlR=Nv2OUN!2=xazlnHPM9EWxXg2EKf0kI{iQb#FoP>xCB<)QY>OAM$Dcdbm zU6dU|%Mo(~avBYSjRc13@|s>axhrPl@Sr81{RSZUdz4(=|82XEbV*JAX6Lfbgqgz584lYgi0 z2-E{0XCVON$wHfvaLs;=dqhQJ&6aLn$D#0i(FkAVrXG9LGm3pSTf&f~RQb6|1_;W> z?n-;&hrq*~L=(;u#jS`*Yvh@3hU-33y_Kv1nxqrsf>pHVF&|OKkoC)4DWK%I!yq?P z=vXo8*_1iEWo8xCa{HJ4tzxOmqS0&$q+>LroMKI*V-rxhOc%3Y!)Y|N6p4PLE>Yek>Y(^KRECg8<|%g*nQib_Yc#A5q8Io z6Ig&V>k|~>B6KE%h4reAo*DfOH)_01tE0nWOxX0*YTJgyw7moaI^7gW*WBAeiLbD?FV9GSB zPv3`SX*^GRBM;zledO`!EbdBO_J@fEy)B{-XUTVQv}Qf~PSDpK9+@I`7G7|>Dgbbu z_7sX9%spVo$%qwRwgzq7!_N;#Td08m5HV#?^dF-EV1o)Q=Oa+rs2xH#g;ykLbwtCh znUnA^dW!XjspJ;otq$yV@I^s9Up(5k7rqhQd@OLMyyxVLj_+$#Vc*}Usevp^I(^vH zmDgHc0VMme|K&X?9&lkN{yq_(If)O`oUPW8X}1R5pSVBpfJe0t{sPA(F#`eONTh_) zxeLqHMfJX#?P(@6w4CqRE@Eiza; z;^5)Kk=^5)KDvd9Q<`=sJU8rjjxPmtWMTmzcH={o$U)j=QBuHarp?=}c??!`3d=H$nrJMyr3L-& zA#m?t(NqLM?I3mGgWA_C+0}BWy3-Gj7bR+d+U?n*mN$%5P`ugrB{PeV>jDUn;eVc- zzeMB1mI4?fVJatrNyq|+zn=!AiN~<}eoM#4uSx^K?Iw>P2*r=k`$<3kT00BE_1c(02MRz4(Hq`L^M&xt!pV2 zn+#U3@j~PUR>xIy+P>51iPayk-mqIK_5rlQMSe5&tDkKJk_$i(X&;K(11YGpEc-K= zq4Ln%^j>Zi_+Ae9eYEq_<`D+ddb8_aY!N;)(&EHFAk@Ekg&41ABmOXfWTo)Z&KotA zh*jgDGFYQ^y=m)<_LCWB+v48DTJw*5dwMm_YP0*_{@HANValf?kV-Ic3xsC}#x2h8 z`q5}d8IRmqWk%gR)s~M}(Qas5+`np^jW^oEd-pzERRPMXj$kS17g?H#4^trtKtq;C?;c ztd|%|WP2w2Nzg@)^V}!Gv++QF2!@FP9~DFVISRW6S?eP{H;;8EH;{>X_}NGj^0cg@ z!2@A>-CTcoN02^r6@c~^QUa={0xwK0v4i-tQ9wQq^=q*-{;zJ{Qe%7Qd!&X2>rV@4 z&wznCz*63_vw4>ZF8~%QCM?=vfzW0r_4O^>UA@otm_!N%mH)!ERy&b!n3*E*@?9d^ zu}s^By@FAhG(%?xgJMuMzuJw2&@$-oK>n z=UF}rt%vuaP9fzIFCYN-1&b#r^Cl6RDFIWsEsM|ROf`E?O(cy{BPO2Ie~kT+^kI^i zp>Kbc@C?}3vy-$ZFVX#-cx)Xj&G^ibX{pWggtr(%^?HeQL@Z( zM-430g<{>vT*)jK4aY9(a{lSy{8vxLbP~n1MXwM527ne#SHCC^F_2@o`>c>>KCq9c(4c$VSyMl*y3Nq1s+!DF| z^?d9PipQN(mw^j~{wJ^VOXDCaL$UtwwTpyv8IAwGOg<|NSghkAR1GSNLZ1JwdGJYm zP}t<=5=sNNUEjc=g(y)1n5)ynX(_$1-uGuDR*6Y^Wgg(LT)Jp><5X|}bt z_qMa&QP?l_n+iVS>v%s2Li_;AIeC=Ca^v1jX4*gvB$?H?2%ndnqOaK5-J%7a} zIF{qYa&NfVY}(fmS0OmXA70{znljBOiv5Yod!vFU{D~*3B3Ka{P8?^ zfhlF6o7aNT$qi8(w<}OPw5fqA7HUje*r*Oa(YV%*l0|9FP9KW@U&{VSW{&b0?@y)M zs%4k1Ax;TGYuZ9l;vP5@?3oQsp3)rjBeBvQQ>^B;z5pc=(yHhHtq6|0m(h4envn_j787fizY@V`o(!SSyE7vlMT zbo=Z1c=atz*G!kwzGB;*uPL$Ei|EbZLh8o+1BUMOpnU(uX&OG1MV@|!&HOOeU#t^x zr9=w2ow!SsTuJWT7%Wmt14U_M*3XiWBWHxqCVZI0_g0`}*^&yEG9RK9fHK8e+S^m? zfCNn$JTswUVbiC#>|=wS{t>-MI1aYPLtzO5y|LJ9nm>L6*wpr_m!)A2Fb1RceX&*|5|MwrvOk4+!0p99B9AgP*9D{Yt|x=X}O% zgIG$MrTB=n-!q%ROT|SzH#A$Xm;|ym)0>1KR}Yl0hr-KO&qMrV+0Ej3d@?FcgZ+B3 ztEk16g#2)@x=(ko8k7^Tq$*5pfZHC@O@}`SmzT1(V@x&NkZNM2F#Q-Go7-uf_zKC( zB(lHZ=3@dHaCOf6C!6i8rDL%~XM@rVTJbZL09?ht@r^Z_6x}}atLjvH^4Vk#Ibf(^LiBJFqorm?A=lE zzFmwvp4bT@Nv2V>YQT92X;t9<2s|Ru5#w?wCvlhcHLcsq0TaFLKy(?nzezJ>CECqj zggrI~Hd4LudM(m{L@ezfnpELsRFVFw>fx;CqZtie`$BXRn#Ns%AdoE$-Pf~{9A8rV zf7FbgpKmVzmvn-z(g+&+-ID=v`;6=)itq8oM*+Uz**SMm_{%eP_c0{<%1JGiZS19o z@Gj7$Se~0lsu}w!%;L%~mIAO;AY-2i`9A*ZfFs=X!LTd6nWOZ7BZH2M{l2*I>Xu)0 z`<=;ObglnXcVk!T>e$H?El}ra0WmPZ$YAN0#$?|1v26^(quQre8;k20*dpd4N{i=b zuN=y}_ew9SlE~R{2+Rh^7%PA1H5X(p8%0TpJ=cqa$65XL)$#ign-y!qij3;2>j}I; ziO@O|aYfn&up5F`YtjGw68rD3{OSGNYmBnl?zdwY$=RFsegTZ=kkzRQ`r7ZjQP!H( zp4>)&zf<*N!tI00xzm-ME_a{_I!TbDCr;8E;kCH4LlL-tqLxDuBn-+xgPk37S&S2^ z2QZumkIimwz!c@!r0)j3*(jPIs*V!iLTRl0Cpt_UVNUgGZzdvs0(-yUghJfKr7;=h zD~y?OJ-bWJg;VdZ^r@vlDoeGV&8^--!t1AsIMZ5S440HCVr%uk- z2wV>!W1WCvFB~p$P$$_}|H5>uBeAe>`N1FI8AxM|pq%oNs;ED8x+tb44E) zTj{^fbh@eLi%5AqT?;d>Es5D*Fi{Bpk)q$^iF!!U`r2hHAO_?#!aYmf>G+jHsES4W zgpTKY59d?hsb~F0WE&dUp6lPt;Pm zcbTUqRryw^%{ViNW%Z(o8}dd00H(H-MmQmOiTq{}_rnwOr*Ybo7*}3W-qBT!#s0Ie z-s<1rvvJx_W;ViUD`04%1pra*Yw0BcGe)fDKUK8aF#BwBwMPU;9`!6E(~!043?SZx z13K%z@$$#2%2ovVlgFIPp7Q6(vO)ud)=*%ZSucL2Dh~K4B|%q4KnSpj#n@(0B})!9 z8p*hY@5)NDn^&Pmo;|!>erSYg`LkO?0FB@PLqRvc>4IsUM5O&>rRv|IBRxi(RX(gJ ztQ2;??L~&Mv;aVr5Q@(?y^DGo%pO^~zijld41aA0KKsy_6FeHIn?fNHP-z>$OoWer zjZ5hFQTy*-f7KENRiCE$ZOp4|+Wah|2=n@|W=o}bFM}Y@0e62+_|#fND5cwa3;P{^pEzlJbF1Yq^}>=wy8^^^$I2M_MH(4Dw{F6hm+vrWV5!q;oX z;tTNhz5`-V={ew|bD$?qcF^WPR{L(E%~XG8eJx(DoGzt2G{l8r!QPJ>kpHeOvCv#w zr=SSwMDaUX^*~v%6K%O~i)<^6`{go>a3IdfZ8hFmz&;Y@P%ZygShQZ2DSHd`m5AR= zx$wWU06;GYwXOf(%MFyj{8rPFXD};JCe85Bdp4$YJ2$TzZ7Gr#+SwCvBI1o$QP0(c zy`P51FEBV2HTisM3bHqpmECT@H!Y2-bv2*SoSPoO?wLe{M#zDTy@ujAZ!Izzky~3k zRA1RQIIoC*Mej1PH!sUgtkR0VCNMX(_!b65mo66iM*KQ7xT8t2eev$v#&YdUXKwGm z7okYAqYF&bveHeu6M5p9xheRCTiU8PFeb1_Rht0VVSbm%|1cOVobc8mvqcw!RjrMRM#~=7xibH&Fa5Imc|lZ{eC|R__)OrFg4@X_ ze+kk*_sDNG5^ELmHnZ7Ue?)#6!O)#Nv*Dl2mr#2)w{#i-;}0*_h4A%HidnmclH#;Q zmQbq+P4DS%3}PpPm7K_K3d2s#k~x+PlTul7+kIKol0@`YN1NG=+&PYTS->AdzPv!> zQvzT=)9se*Jr1Yq+C{wbK82gAX`NkbXFZ)4==j4t51{|-v!!$H8@WKA={d>CWRW+g z*`L>9rRucS`vbXu0rzA1#AQ(W?6)}1+oJSF=80Kf_2r~Qm-EJ6bbB3k`80rCv(0d` zvCf3;L2ovYG_TES%6vSuoKfIHC6w;V31!oqHM8-I8AFzcd^+_86!EcCOX|Ta9k1!s z_Vh(EGIIsI3fb&dF$9V8v(sTBC%!#<&KIGF;R+;MyC0~}$gC}}= zR`DbUVc&Bx`lYykFZ4{R{xRaUQkWCGCQlEc;!mf=+nOk$RUg*7 z;kP7CVLEc$CA7@6VFpsp3_t~m)W0aPxjsA3e5U%SfY{tp5BV5jH-5n?YX7*+U+Zs%LGR>U- z!x4Y_|4{gx?ZPJobISy991O znrmrC3otC;#4^&Rg_iK}XH(XX+eUHN0@Oe06hJk}F?`$)KmH^eWz@@N%wEc)%>?Ft z#9QAroDeyfztQ5Qe{m*#R#T%-h*&XvSEn@N$hYRTCMXS|EPwzF3IIysD2waj`vQD{ zv_#^Pgr?s~I*NE=acf@dWVRNWTr(GN0wrL)Z2=`Dr>}&ZDNX|+^Anl{Di%v1Id$_p zK5_H5`RDjJx`BW7hc85|> zHMMsWJ4KTMRHGu+vy*kBEMjz*^K8VtU=bXJYdhdZ-?jTXa$&n)C?QQIZ7ln$qbGlr zS*TYE+ppOrI@AoPP=VI-OXm}FzgXRL)OPvR$a_=SsC<3Jb+>5makX|U!}3lx4tX&L z^C<{9TggZNoeX!P1jX_K5HkEVnQ#s2&c#umzV6s2U-Q;({l+j^?hi7JnQ7&&*oOy9 z(|0asVTWUCiCnjcOnB2pN0DpuTglKq;&SFOQ3pUdye*eT<2()7WKbXp1qq9=bhMWlF-7BHT|i3TEIT77AcjD(v=I207wi-=vyiw5mxgPdTVUC z&h^FEUrXwWs9en2C{ywZp;nvS(Mb$8sBEh-*_d-OEm%~p1b2EpcwUdf<~zmJmaSTO zSX&&GGCEz-M^)G$fBvLC2q@wM$;n4jp+mt0MJFLuJ%c`tSp8$xuP|G81GEd2ci$|M z4XmH{5$j?rqDWoL4vs!}W&!?!rtj=6WKJcE>)?NVske(p;|#>vL|M_$as=mi-n-()a*OU3Okmk0wC<9y7t^D(er-&jEEak2!NnDiOQ99Wx8{S8}=Ng!e0tzj*#T)+%7;aM$ z&H}|o|J1p{IK0Q7JggAwipvHvko6>Epmh4RFRUr}$*2K4dz85o7|3#Bec9SQ4Y*;> zXWjT~f+d)dp_J`sV*!w>B%)#GI_;USp7?0810&3S=WntGZ)+tzhZ+!|=XlQ&@G@~3 z-dw@I1>9n1{+!x^Hz|xC+P#Ab`E@=vY?3%Bc!Po~e&&&)Qp85!I|U<-fCXy*wMa&t zgDk!l;gk;$taOCV$&60z+}_$ykz=Ea*)wJQ3-M|p*EK(cvtIre0Pta~(95J7zoxBN zS(yE^3?>88AL0Wfuou$BM{lR1hkrRibz=+I9ccwd`ZC*{NNqL)3pCcw^ygMmrG^Yp zn5f}Xf>%gncC=Yq96;rnfp4FQL#{!Y*->e82rHgY4Zwy{`JH}b9*qr^VA{%~Z}jtp z_t$PlS6}5{NtTqXHN?uI8ut8rOaD#F1C^ls73S=b_yI#iZDOGz3#^L@YheGd>L;<( z)U=iYj;`{>VDNzIxcjbTk-X3keXR8Xbc`A$o5# zKGSk-7YcoBYuAFFSCjGi;7b<;n-*`USs)IX z=0q6WZ=L!)PkYtZE-6)azhXV|+?IVGTOmMCHjhkBjfy@k1>?yFO3u!)@cl{fFAXnRYsWk)kpT?X{_$J=|?g@Q}+kFw|%n!;Zo}|HE@j=SFMvT8v`6Y zNO;tXN^036nOB2%=KzxB?n~NQ1K8IO*UE{;Xy;N^ZNI#P+hRZOaHATz9(=)w=QwV# z`z3+P>9b?l-@$@P3<;w@O1BdKh+H;jo#_%rr!ute{|YX4g5}n?O7Mq^01S5;+lABE+7`&_?mR_z7k|Ja#8h{!~j)| zbBX;*fsbUak_!kXU%HfJ2J+G7;inu#uRjMb|8a){=^))y236LDZ$$q3LRlat1D)%7K0!q5hT5V1j3qHc7MG9 z_)Q=yQ>rs>3%l=vu$#VVd$&IgO}Za#?aN!xY>-<3PhzS&q!N<=1Q7VJBfHjug^4|) z*fW^;%3}P7X#W3d;tUs3;`O&>;NKZBMR8au6>7?QriJ@gBaorz-+`pUWOP73DJL=M z(33uT6Gz@Sv40F6bN|H=lpcO z^AJl}&=TIjdevuDQ!w0K*6oZ2JBOhb31q!XDArFyKpz!I$p4|;c}@^bX{>AXdt7Bm zaLTk?c%h@%xq02reu~;t@$bv`b3i(P=g}~ywgSFpM;}b$zAD+=I!7`V~}ARB(Wx0C(EAq@?GuxOL9X+ffbkn3+Op0*80TqmpAq~EXmv%cq36celXmRz z%0(!oMp&2?`W)ALA&#|fu)MFp{V~~zIIixOxY^YtO5^FSox8v$#d0*{qk0Z)pNTt0QVZ^$`4vImEB>;Lo2!7K05TpY-sl#sWBz_W-aDIV`Ksabi zvpa#93Svo!70W*Ydh)Qzm{0?CU`y;T^ITg-J9nfWeZ-sbw)G@W?$Eomf%Bg2frfh5 zRm1{|E0+(4zXy){$}uC3%Y-mSA2-^I>Tw|gQx|7TDli_hB>``)Q^aZ`LJC2V3U$SABP}T)%}9g2pF9dT}aC~!rFFgkl1J$ z`^z{Arn3On-m%}r}TGF8KQe*OjSJ=T|caa_E;v89A{t@$yT^(G9=N9F?^kT*#s3qhJq!IH5|AhnqFd z0B&^gm3w;YbMNUKU>naBAO@fbz zqw=n!@--}o5;k6DvTW9pw)IJVz;X}ncbPVrmH>4x);8cx;q3UyiML1PWp%bxSiS|^ zC5!kc4qw%NSOGQ*Kcd#&$30=lDvs#*4W4q0u8E02U)7d=!W7+NouEyuF1dyH$D@G& zaFaxo9Ex|ZXA5y{eZT*i*dP~INSMAi@mvEX@q5i<&o&#sM}Df?Og8n8Ku4vOux=T% zeuw~z1hR}ZNwTn8KsQHKLwe2>p^K`YWUJEdVEl|mO21Bov!D0D$qPoOv=vJJ`)|%_ z>l%`eexY7t{BlVKP!`a^U@nM?#9OC*t76My_E_<16vCz1x_#82qj2PkWiMWgF8bM9 z(1t4VdHcJ;B~;Q%x01k_gQ0>u2*OjuEWNOGX#4}+N?Gb5;+NQMqp}Puqw2HnkYuKA zzKFWGHc&K>gwVgI1Sc9OT1s6fq=>$gZU!!xsilA$fF`kLdGoX*^t}ao@+^WBpk>`8 z4v_~gK|c2rCq#DZ+H)$3v~Hoi=)=1D==e3P zpKrRQ+>O^cyTuWJ%2}__0Z9SM_z9rptd*;-9uC1tDw4+A!=+K%8~M&+Zk#13hY$Y$ zo-8$*8dD5@}XDi19RjK6T^J~DIXbF5w&l?JLHMrf0 zLv0{7*G!==o|B%$V!a=EtVHdMwXLtmO~vl}P6;S(R2Q>*kTJK~!}gloxj)m|_LYK{ zl(f1cB=EON&wVFwK?MGn^nWuh@f95SHatPs(jcwSY#Dnl1@_gkOJ5=f`%s$ZHljRH0 z+c%lrb=Gi&N&1>^L_}#m>=U=(oT^vTA&3!xXNyqi$pdW1BDJ#^{h|2tZc{t^vag3& zAD7*8C`chNF|27itjBUo^CCDyEpJLX3&u+(L;YeeMwnXEoyN(ytoEabcl$lSgx~Ltatn}b$@j_yyMrBb03)shJE*$;Mw=;mZd&8e>IzE+4WIoH zCSZE7WthNUL$|Y#m!Hn?x7V1CK}V`KwW2D$-7&ODy5Cj;!_tTOOo1Mm%(RUt)#$@3 zhurA)t<7qik%%1Et+N1?R#hdBB#LdQ7{%-C zn$(`5e0eFh(#c*hvF>WT*07fk$N_631?W>kfjySN8^XC9diiOd#s?4tybICF;wBjp zIPzilX3{j%4u7blhq)tnaOBZ_`h_JqHXuI7SuIlNTgBk9{HIS&3|SEPfrvcE<@}E` zKk$y*nzsqZ{J{uWW9;#n=de&&h>m#A#q)#zRonr(?mDOYU&h&aQWD;?Z(22wY?t$U3qo`?{+amA$^TkxL+Ex2dh`q7iR&TPd0Ymwzo#b? zP$#t=elB5?k$#uE$K>C$YZbYUX_JgnXA`oF_Ifz4H7LEOW~{Gww&3s=wH4+j8*TU| zSX%LtJWqhr-xGNSe{;(16kxnak6RnZ{0qZ^kJI5X*It_YuynSpi(^-}Lolr{)#z_~ zw!(J-8%7Ybo^c3(mED`Xz8xecP35a6M8HarxRn%+NJBE;dw>>Y2T&;jzRd4FSDO3T zt*y+zXCtZQ0bP0yf6HRpD|WmzP;DR^-g^}{z~0x~z4j8m zucTe%k&S9Nt-?Jb^gYW1w6!Y3AUZ0Jcq;pJ)Exz%7k+mUOm6%ApjjSmflfKwBo6`B zhNb@$NHTJ>guaj9S{@DX)!6)b-Shav=DNKWy(V00k(D!v?PAR0f0vDNq*#mYmUp6> z76KxbFDw5U{{qx{BRj(>?|C`82ICKbfLxoldov-M?4Xl+3;I4GzLHyPOzYw7{WQST zPNYcx5onA%MAO9??41Po*1zW(Y%Zzn06-lUp{s<3!_9vv9HBjT02On0Hf$}NP;wF) zP<`2p3}A^~1YbvOh{ePMx$!JGUPX-tbBzp3mDZMY;}h;sQ->!p97GA)9a|tF(Gh{1$xk7 zUw?ELkT({Xw!KIr);kTRb1b|UL`r2_`a+&UFVCdJ)1T#fdh;71EQl9790Br0m_`$x z9|ZANuchFci8GNZ{XbP=+uXSJRe(;V5laQz$u18#?X*9}x7cIEbnr%<=1cX3EIu7$ zhHW6pe5M(&qEtsqRa>?)*{O;OJT+YUhG5{km|YI7I@JL_3Hwao9aXneiSA~a* z|Lp@c-oMNyeAEuUz{F?kuou3x#C*gU?lon!RC1s37gW^0Frc`lqQWH&(J4NoZg3m8 z;Lin#8Q+cFPD7MCzj}#|ws7b@?D9Q4dVjS4dpco=4yX5SSH=A@U@yqPdp@?g?qeia zH=Tt_9)G=6C2QIPsi-QipnK(mc0xXIN;j$WLf@n8eYvMk;*H-Q4tK%(3$CN}NGgO8n}fD~+>?<3UzvsrMf*J~%i;VKQHbF%TPalFi=#sgj)(P#SM^0Q=Tr>4kJVw8X3iWsP|e8tj}NjlMdWp z@2+M4HQu~3!=bZpjh;;DIDk&X}=c8~kn)FWWH z2KL1w^rA5&1@@^X%MjZ7;u(kH=YhH2pJPFQe=hn>tZd5RC5cfGYis8s9PKaxi*}-s6*W zRA^PwR=y^5Z){!(4D9-KC;0~;b*ploznFOaU`bJ_7U?qAi#mTo!&rIECRL$_y@yI27x2?W+zqDBD5~KCVYKFZLK+>ABC(Kj zeAll)KMgIlAG`r^rS{loBrGLtzhHY8$)<_S<(Dpkr(Ym@@vnQ&rS@FC*>2@XCH}M+an74WcRDcoQ+a3@A z9tYhl5$z7bMdTvD2r&jztBuo37?*k~wcU9GK2-)MTFS-lux-mIRYUuGUCI~V$?s#< z?1qAWb(?ZLm(N>%S%y10COdaq_Tm5c^%ooIxpR=`3e4C|@O5wY+eLik&XVi5oT7oe zmxH)Jd*5eo@!7t`x8!K=-+zJ-Sz)B_V$)s1pW~CDU$=q^&ABvf6S|?TOMB-RIm@CoFg>mjIQE)?+A1_3s6zmFU_oW&BqyMz1mY*IcP_2knjq5 zqw~JK(cVsmzc7*EvTT2rvpeqhg)W=%TOZ^>f`rD4|7Z5fq*2D^lpCttIg#ictgqZ$P@ru6P#f$x#KfnfTZj~LG6U_d-kE~`;kU_X)`H5so@?C zWmb!7x|xk@0L~0JFall*@ltyiL^)@3m4MqC7(7H0sH!WidId1#f#6R{Q&A!XzO1IAcIx;$k66dumt6lpUw@nL2MvqJ5^kbOVZ<^2jt5-njy|2@`07}0w z;M%I1$FCoLy`8xp8Tk)bFr;7aJeQ9KK6p=O$U0-&JYYy8woV*>b+FB?xLX`=pirYM z5K$BA(u)+jR{?O2r$c_Qvl?M{=Ar{yQ!UVsVn4k@0!b?_lA;dVz9uaQUgBH8Oz(Sb zrEs;&Ey>_ex8&!N{PmQjp+-Hlh|OA&wvDai#GpU=^-B70V0*LF=^bi+Nhe_o|azZ%~ZZ1$}LTmWt4aoB1 zPgccm$EwYU+jrdBaQFxQfn5gd(gM`Y*Ro1n&Zi?j=(>T3kmf94vdhf?AuS8>$Va#P zGL5F+VHpxdsCUa}+RqavXCobI-@B;WJbMphpK2%6t=XvKWWE|ruvREgM+|V=i6;;O zx$g=7^`$XWn0fu!gF=Xe9cMB8Z_SelD>&o&{1XFS`|nInK3BXlaeD*rc;R-#osyIS zWv&>~^TLIyBB6oDX+#>3<_0+2C4u2zK^wmHXXDD9_)kmLYJ!0SzM|%G9{pi)`X$uf zW}|%%#LgyK7m(4{V&?x_0KEDq56tk|0YNY~B(Sr|>WVz-pO3A##}$JCT}5P7DY+@W z#gJv>pA5>$|E3WO2tV7G^SuymB?tY`ooKcN3!vaQMnBNk-WATF{-$#}FyzgtJ8M^; zUK6KWSG)}6**+rZ&?o@PK3??uN{Q)#+bDP9i1W&j)oaU5d0bIWJ_9T5ac!qc?x66Q z$KUSZ`nYY94qfN_dpTFr8OW~A?}LD;Yty-BA)-be5Z3S#t2Io%q+cAbnGj1t$|qFR z9o?8B7OA^KjCYL=-!p}w(dkC^G6Nd%_I=1))PC0w5}ZZGJxfK)jP4Fwa@b-SYBw?% zdz9B-<`*B2dOn(N;mcTm%Do)rIvfXRNFX&1h`?>Rzuj~Wx)$p13nrDlS8-jwq@e@n zNIj_|8or==8~1h*Ih?w*8K7rYkGlwlTWAwLKc5}~dfz3y`kM&^Q|@C%1VAp_$wnw6zG~W4O+^ z>i?NY?oXf^Puc~+fDM$VgRNBpOZj{2cMP~gCqWAX4 z7>%$ux8@a&_B(pt``KSt;r+sR-$N;jdpY>|pyvPiN)9ohd*>mVST3wMo)){`B(&eX z1?zZJ-4u9NZ|~j1rdZYq4R$?swf}<6(#ex%7r{kh%U@kT)&kWuAszS%oJts=*OcL9 zaZwK<5DZw%1IFHXgFplP6JiL^dk8+SgM$D?8X+gE4172hXh!WeqIO>}$I9?Nry$*S zQ#f)RuH{P7RwA3v9f<-w>{PSzom;>(i&^l{E0(&Xp4A-*q-@{W1oE3K;1zb{&n28dSC2$N+6auXe0}e4b z)KLJ?5c*>@9K#I^)W;uU_Z`enquTUxr>mNq z1{0_puF-M7j${rs!dxxo3EelGodF1TvjV;Zpo;s{5f1pyCuRp=HDZ?s#IA4f?h|-p zGd|Mq^4hDa@Bh!c4ZE?O&x&XZ_ptZGYK4$9F4~{%R!}G1leCBx`dtNUS|K zL-7J5s4W@%mhXg1!}a4PD%!t&Qn%f_oquRajn3@C*)`o&K9o7V6DwzVMEhjVdDJ1fjhr#@=lp#@4EBqi=CCQ>73>R(>QKPNM&_Jpe5G`n4wegeC`FYEPJ{|vwS>$-`fuRSp3927qOv|NC3T3G-0 zA{K`|+tQy1yqE$ShWt8ny&5~)%ITb@^+x$w0)f&om;P8B)@}=Wzy59BwUfZ1vqw87 za2lB8J(&*l#(V}Id8SyQ0C(2amzkz3EqG&Ed0Jq1)$|&>4_|NIe=5|n=3?siFV0fI z{As5DLW^gs|B-b4C;Hd(SM-S~GQhzb>HgF2|2Usww0nL^;x@1eaB)=+Clj+$fF@H( z-fqP??~QMT$KI-#m;QC*&6vkp&8699G3)Bq0*kFZXINw=b9OVaed(3(3kS|IZ)CM? zJdnW&%t8MveBuK21uiYj)_a{Fnw0OErMzMN?d$QoPwkhOwcP&p+t>P)4tHlYw-pPN z^oJ=uc$Sl>pv@fZH~ZqxSvdhF@F1s=oZawpr^-#l{IIOGG=T%QXjtwPhIg-F@k@uIlr?J->Ia zpEUQ*=4g|XYn4Gez&aHr*;t$u3oODPmc2Ku)2Og|xjc%w;q!Zz+zY)*3{7V8bK4;& zYV82FZ+8?v)`J|G1w4I0fWdKg|2b#iaazCv;|?(W-q}$o&Y}Q5d@BRk^jL7#{kbCK zSgkyu;=DV+or2)AxCBgq-nj5=@n^`%T#V+xBGEkW4lCqrE)LMv#f;AvD__cQ@Eg3`~x| zW+h9mofSXCq5|M)9|ez(#X?-sxB%Go8};sJ?2abp(Y!lyi>k)|{M*Z$c{e1-K4ky` MPgg&ebxsLQ025IeI{*Lx literal 0 HcmV?d00001 diff --git a/mobile/web/index.html b/mobile/web/index.html new file mode 100644 index 0000000..4398e82 --- /dev/null +++ b/mobile/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + privacy_maps + + + + + + diff --git a/mobile/web/manifest.json b/mobile/web/manifest.json new file mode 100644 index 0000000..4417de2 --- /dev/null +++ b/mobile/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "privacy_maps", + "short_name": "privacy_maps", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/mobile/windows/.gitignore b/mobile/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/mobile/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/mobile/windows/CMakeLists.txt b/mobile/windows/CMakeLists.txt new file mode 100644 index 0000000..7c005f3 --- /dev/null +++ b/mobile/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(privacy_maps LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "privacy_maps") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/mobile/windows/flutter/CMakeLists.txt b/mobile/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/mobile/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/mobile/windows/flutter/generated_plugin_registrant.cc b/mobile/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..601a9f0 --- /dev/null +++ b/mobile/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,17 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + GeolocatorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GeolocatorWindows")); + Sqlite3FlutterLibsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin")); +} diff --git a/mobile/windows/flutter/generated_plugin_registrant.h b/mobile/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/mobile/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/mobile/windows/flutter/generated_plugins.cmake b/mobile/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..3f94227 --- /dev/null +++ b/mobile/windows/flutter/generated_plugins.cmake @@ -0,0 +1,25 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + geolocator_windows + sqlite3_flutter_libs +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/mobile/windows/runner/CMakeLists.txt b/mobile/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/mobile/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/mobile/windows/runner/Runner.rc b/mobile/windows/runner/Runner.rc new file mode 100644 index 0000000..3ef99c3 --- /dev/null +++ b/mobile/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.privacymaps" "\0" + VALUE "FileDescription", "privacy_maps" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "privacy_maps" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.privacymaps. All rights reserved." "\0" + VALUE "OriginalFilename", "privacy_maps.exe" "\0" + VALUE "ProductName", "privacy_maps" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/mobile/windows/runner/flutter_window.cpp b/mobile/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/mobile/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/mobile/windows/runner/flutter_window.h b/mobile/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/mobile/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/mobile/windows/runner/main.cpp b/mobile/windows/runner/main.cpp new file mode 100644 index 0000000..a0afb37 --- /dev/null +++ b/mobile/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"privacy_maps", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/mobile/windows/runner/resource.h b/mobile/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/mobile/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/mobile/windows/runner/resources/app_icon.ico b/mobile/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..c04e20caf6370ebb9253ad831cc31de4a9c965f6 GIT binary patch literal 33772 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 literal 0 HcmV?d00001 diff --git a/mobile/windows/runner/runner.exe.manifest b/mobile/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/mobile/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/mobile/windows/runner/utils.cpp b/mobile/windows/runner/utils.cpp new file mode 100644 index 0000000..3a0b465 --- /dev/null +++ b/mobile/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/mobile/windows/runner/utils.h b/mobile/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/mobile/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/mobile/windows/runner/win32_window.cpp b/mobile/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/mobile/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/mobile/windows/runner/win32_window.h b/mobile/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/mobile/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_