diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8980ce5..a6b117b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,32 @@
All notable changes to this project will be documented in this file.
+## [1.0.1+2] - 2026-02-23
+
+### Added
+
+- **Release & Store Readiness**
+ - Added Play Store listing documentation with app metadata, SEO-oriented copy, ASO keyword strategy, release note templates, and launch checklist.
+ - Added MDX-ready privacy policy document for website and Play Console reuse.
+- **Product Features & Platform Integrations**
+ - Expanded optional backend integration flow for auth and sync readiness gating via runtime feature flags.
+ - Added/extended Firebase integrations for runtime config, analytics consent behavior, crash diagnostics, performance monitoring, and FCM-related flows.
+
+### Changed
+
+- **Versioning**
+ - Bumped mobile app version from `1.0.0+1` to `1.0.1+2`.
+ - Bumped workspace version from `1.0.0` to `1.0.1`.
+- **UX & Architecture**
+ - Continued UI/UX refinements across core screens and settings structure.
+ - Continued localization coverage and language improvements across key user flows.
+
+### Fixed
+
+- Navigation stability issues related to route stack/hero key conflicts in shell navigation flows.
+- Multiple layout overflow cases on compact/Android device sizes (bottom sheets, grids, and list end visibility with custom navigation shell).
+- State/controller lifecycle issues in edit/update dialogs and related action handlers.
+
## [1.0.0+1] - 2026-01-28
### Added
diff --git a/README.md b/README.md
index 33b71dc..fdc044a 100644
--- a/README.md
+++ b/README.md
@@ -100,6 +100,8 @@ If either is `false`, sync UI/actions are disabled. Full explanation: [documenta
- [documentation/APP_PROGRESS.md](documentation/APP_PROGRESS.md) - implemented vs in-progress features
- [documentation/SYNC_AND_AUTH.md](documentation/SYNC_AND_AUTH.md) - sync/auth integration details
- [documentation/LOCALIZATION.md](documentation/LOCALIZATION.md) - i18n status and workflow
+- [documentation/PLAY_STORE_LISTING.md](documentation/PLAY_STORE_LISTING.md) - Play Store listing and ASO package
+- [documentation/PRIVACY_POLICY.md](documentation/PRIVACY_POLICY.md) - MDX-ready privacy policy template
## CI/CD
diff --git a/apps/mobile/README.md b/apps/mobile/README.md
index 4a5aaba..0f61143 100644
--- a/apps/mobile/README.md
+++ b/apps/mobile/README.md
@@ -1,6 +1,6 @@
-# Collection Tracker Mobile App
+# Collectra Mobile App
-This is the Flutter app module for the Collection Tracker workspace.
+This is the Flutter app module for the Collectra workspace.
## Main References
diff --git a/apps/mobile/android/app/src/main/AndroidManifest.xml b/apps/mobile/android/app/src/main/AndroidManifest.xml
index 0a19bcd..015a192 100644
--- a/apps/mobile/android/app/src/main/AndroidManifest.xml
+++ b/apps/mobile/android/app/src/main/AndroidManifest.xml
@@ -17,7 +17,7 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/mobile/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png b/apps/mobile/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png
new file mode 100644
index 0000000..b37a05b
Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png differ
diff --git a/apps/mobile/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/apps/mobile/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png
index a4a617b..4b00731 100644
Binary files a/apps/mobile/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png and b/apps/mobile/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ
diff --git a/apps/mobile/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png b/apps/mobile/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png
new file mode 100644
index 0000000..689c5b9
Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png differ
diff --git a/apps/mobile/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/apps/mobile/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png
index e54c4ac..78de3d8 100644
Binary files a/apps/mobile/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png and b/apps/mobile/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ
diff --git a/apps/mobile/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png b/apps/mobile/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png
new file mode 100644
index 0000000..8127e5a
Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png differ
diff --git a/apps/mobile/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/apps/mobile/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png
index f252cd2..f656cd1 100644
Binary files a/apps/mobile/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png and b/apps/mobile/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ
diff --git a/apps/mobile/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png b/apps/mobile/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png
new file mode 100644
index 0000000..90a8a0b
Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png differ
diff --git a/apps/mobile/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/apps/mobile/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png
index b26f1a9..4db058c 100644
Binary files a/apps/mobile/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png and b/apps/mobile/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ
diff --git a/apps/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png b/apps/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png
new file mode 100644
index 0000000..346570e
Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png differ
diff --git a/apps/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/apps/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png
index b8626c4..05b31a5 100644
Binary files a/apps/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png and b/apps/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/apps/mobile/android/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml b/apps/mobile/android/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml
index c79c58a..06e8430 100644
--- a/apps/mobile/android/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml
+++ b/apps/mobile/android/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml
@@ -1,9 +1,9 @@
-
+
+ android:inset="8%" />
diff --git a/apps/mobile/android/app/src/main/res/mipmap-hdpi/launcher_icon.png b/apps/mobile/android/app/src/main/res/mipmap-hdpi/launcher_icon.png
index e403f5e..c954937 100644
Binary files a/apps/mobile/android/app/src/main/res/mipmap-hdpi/launcher_icon.png and b/apps/mobile/android/app/src/main/res/mipmap-hdpi/launcher_icon.png differ
diff --git a/apps/mobile/android/app/src/main/res/mipmap-mdpi/launcher_icon.png b/apps/mobile/android/app/src/main/res/mipmap-mdpi/launcher_icon.png
index 4040f94..beee052 100644
Binary files a/apps/mobile/android/app/src/main/res/mipmap-mdpi/launcher_icon.png and b/apps/mobile/android/app/src/main/res/mipmap-mdpi/launcher_icon.png differ
diff --git a/apps/mobile/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png
index 05f6297..1e813f2 100644
Binary files a/apps/mobile/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png and b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png differ
diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png
index 1783d08..e317d10 100644
Binary files a/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png and b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png differ
diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png
index 23cbc2d..c7ca37e 100644
Binary files a/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png and b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png differ
diff --git a/apps/mobile/android/app/src/main/res/values/colors.xml b/apps/mobile/android/app/src/main/res/values/colors.xml
index 093bed2..b7eea4b 100644
--- a/apps/mobile/android/app/src/main/res/values/colors.xml
+++ b/apps/mobile/android/app/src/main/res/values/colors.xml
@@ -1,4 +1,4 @@
- #667eea
+ #1A91E6
\ No newline at end of file
diff --git a/apps/mobile/assets/branding/play_store_feature_graphic.png b/apps/mobile/assets/branding/play_store_feature_graphic.png
new file mode 100644
index 0000000..0b8546d
Binary files /dev/null and b/apps/mobile/assets/branding/play_store_feature_graphic.png differ
diff --git a/apps/mobile/assets/icons/logo_background.png b/apps/mobile/assets/icons/logo_background.png
new file mode 100644
index 0000000..c5be07b
Binary files /dev/null and b/apps/mobile/assets/icons/logo_background.png differ
diff --git a/apps/mobile/assets/icons/logo_dark.png b/apps/mobile/assets/icons/logo_dark.png
index 37e838b..96e6c2c 100644
Binary files a/apps/mobile/assets/icons/logo_dark.png and b/apps/mobile/assets/icons/logo_dark.png differ
diff --git a/apps/mobile/assets/icons/logo_foreground.png b/apps/mobile/assets/icons/logo_foreground.png
index 86e9553..a8df34a 100644
Binary files a/apps/mobile/assets/icons/logo_foreground.png and b/apps/mobile/assets/icons/logo_foreground.png differ
diff --git a/apps/mobile/assets/icons/logo_light.png b/apps/mobile/assets/icons/logo_light.png
index 80de0c3..def353b 100644
Binary files a/apps/mobile/assets/icons/logo_light.png and b/apps/mobile/assets/icons/logo_light.png differ
diff --git a/apps/mobile/flutter_launcher_icons.yaml b/apps/mobile/flutter_launcher_icons.yaml
index 31b9927..bbb057a 100644
--- a/apps/mobile/flutter_launcher_icons.yaml
+++ b/apps/mobile/flutter_launcher_icons.yaml
@@ -3,17 +3,17 @@ flutter_launcher_icons:
image_path: "assets/icons/logo_light.png"
android: "launcher_icon"
- # image_path_android: "assets/icon/icon.png"
+ # image_path_android: "assets/icons/logo_light.png"
min_sdk_android: 21 # android min sdk min:16, default 21
- adaptive_icon_background: "#667eea"
+ adaptive_icon_background: "assets/icons/logo_background.png"
adaptive_icon_foreground: "assets/icons/logo_foreground.png"
- # adaptive_icon_foreground_inset: 16
+ adaptive_icon_foreground_inset: 8
# adaptive_icon_monochrome: "assets/icon/monochrome.png"
ios: true
- # image_path_ios: "assets/icon/icon.png"
+ image_path_ios: "assets/icons/logo_light.png"
remove_alpha_ios: true
- # image_path_ios_dark_transparent: "assets/icon/icon_dark.png"
+ # image_path_ios_dark_transparent: "assets/icons/logo_dark.png"
# image_path_ios_tinted_grayscale: "assets/icon/icon_tinted.png"
# desaturate_tinted_to_grayscale_ios: true
# background_color_ios: "#ffffff"
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png
index dd9ff3d..6f38102 100644
Binary files a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png
index cd284ab..48a78a1 100644
Binary files a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png
index 44bf73f..1800445 100644
Binary files a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png
index 0b8dad2..b2f29c1 100644
Binary files a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png
index ab6d81d..44870ac 100644
Binary files a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png
index 05347c4..d7169ec 100644
Binary files a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png
index 3521bf3..9b564b1 100644
Binary files a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-38x38@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-38x38@2x.png
new file mode 100644
index 0000000..6cca4fb
Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-38x38@2x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-38x38@3x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-38x38@3x.png
new file mode 100644
index 0000000..a5ee05c
Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-38x38@3x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png
index 44bf73f..1800445 100644
Binary files a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png
index e3d7f1d..59be976 100644
Binary files a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png
index c61fda8..17be475 100644
Binary files a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png
index 48adeda..b01d780 100644
Binary files a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png
index d25b8b2..6e11b4f 100644
Binary files a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png
index 4e7d1a4..712c72b 100644
Binary files a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png
index af523fd..ec24da7 100644
Binary files a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png
index c61fda8..17be475 100644
Binary files a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png
index 26ec4cd..ab1c5c4 100644
Binary files a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-64x64@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-64x64@2x.png
new file mode 100644
index 0000000..b4e847c
Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-64x64@2x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-64x64@3x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-64x64@3x.png
new file mode 100644
index 0000000..91366c1
Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-64x64@3x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-68x68@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-68x68@2x.png
new file mode 100644
index 0000000..ed24769
Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-68x68@2x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png
index f5091b3..a10a592 100644
Binary files a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png
index ee75930..28b0cb7 100644
Binary files a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png
index ba136af..2bc38a6 100644
Binary files a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png
index ee08860..f124a64 100644
Binary files a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png
index aeaaa09..991e4f3 100644
Binary files a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-1024x1024@1x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-1024x1024@1x.png
new file mode 100644
index 0000000..2a891f0
Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-1024x1024@1x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-20x20@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-20x20@2x.png
new file mode 100644
index 0000000..68383f2
Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-20x20@2x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-20x20@3x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-20x20@3x.png
new file mode 100644
index 0000000..6e0b098
Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-20x20@3x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-29x29@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-29x29@2x.png
new file mode 100644
index 0000000..114f4e2
Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-29x29@2x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-29x29@3x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-29x29@3x.png
new file mode 100644
index 0000000..c1e8dd6
Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-29x29@3x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-38x38@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-38x38@2x.png
new file mode 100644
index 0000000..aebb652
Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-38x38@2x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-38x38@3x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-38x38@3x.png
new file mode 100644
index 0000000..b9523c1
Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-38x38@3x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-40x40@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-40x40@2x.png
new file mode 100644
index 0000000..07f9a5b
Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-40x40@2x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-40x40@3x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-40x40@3x.png
new file mode 100644
index 0000000..4434b9c
Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-40x40@3x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-60x60@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-60x60@2x.png
new file mode 100644
index 0000000..4434b9c
Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-60x60@2x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-60x60@3x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-60x60@3x.png
new file mode 100644
index 0000000..8d508eb
Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-60x60@3x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-64x64@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-64x64@2x.png
new file mode 100644
index 0000000..ebb0edf
Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-64x64@2x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-64x64@3x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-64x64@3x.png
new file mode 100644
index 0000000..011a520
Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-64x64@3x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-68x68@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-68x68@2x.png
new file mode 100644
index 0000000..5900329
Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-68x68@2x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-76x76@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-76x76@2x.png
new file mode 100644
index 0000000..f606580
Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-76x76@2x.png differ
diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-83.5x83.5@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-83.5x83.5@2x.png
new file mode 100644
index 0000000..ecb21bd
Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-83.5x83.5@2x.png differ
diff --git a/apps/mobile/ios/Runner/Info.plist b/apps/mobile/ios/Runner/Info.plist
index c62937c..b766746 100644
--- a/apps/mobile/ios/Runner/Info.plist
+++ b/apps/mobile/ios/Runner/Info.plist
@@ -7,7 +7,7 @@
CFBundleDevelopmentRegion
$(DEVELOPMENT_LANGUAGE)
CFBundleDisplayName
- Collection Tracker
+ Collectra
CFBundleExecutable
$(EXECUTABLE_NAME)
CFBundleIdentifier
@@ -15,7 +15,7 @@
CFBundleInfoDictionaryVersion
6.0
CFBundleName
- collection_tracker
+ Collectra
CFBundlePackageType
APPL
CFBundleShortVersionString
@@ -71,5 +71,12 @@
UIInterfaceOrientationLandscapeLeft
UIInterfaceOrientationLandscapeRight
+ LSApplicationQueriesSchemes
+
+ sms
+ tel
+ http
+ https
+
diff --git a/apps/mobile/lib/core/auth/backend_auth_service.dart b/apps/mobile/lib/core/auth/backend_auth_service.dart
index 93e2193..8aece39 100644
--- a/apps/mobile/lib/core/auth/backend_auth_service.dart
+++ b/apps/mobile/lib/core/auth/backend_auth_service.dart
@@ -2,13 +2,14 @@ import 'dart:io';
import 'package:auth_session/auth_session.dart';
import 'package:backend_api/backend_api.dart';
+import 'package:package_info_plus/package_info_plus.dart';
class BackendAuthService {
BackendAuthService({
required BackendAuthClient client,
required AuthSessionStore sessionStore,
required Future Function() resolveDeviceId,
- this.appVersion = '1.0.0',
+ this.appVersion,
}) : _client = client,
_sessionStore = sessionStore,
_resolveDeviceId = resolveDeviceId;
@@ -16,13 +17,14 @@ class BackendAuthService {
final BackendAuthClient _client;
final AuthSessionStore _sessionStore;
final Future Function() _resolveDeviceId;
- final String appVersion;
+ final String? appVersion;
Future signIn({
required String email,
required String password,
}) async {
final deviceId = await _resolveDeviceId();
+ final resolvedAppVersion = await _resolveAppVersion();
final response = await _client.login(
BackendLoginRequest(
email: email,
@@ -30,7 +32,7 @@ class BackendAuthService {
deviceId: deviceId,
deviceName: _deviceName(),
deviceOs: _deviceOs(),
- appVersion: appVersion,
+ appVersion: resolvedAppVersion,
),
);
@@ -43,6 +45,7 @@ class BackendAuthService {
String? displayName,
}) async {
final deviceId = await _resolveDeviceId();
+ final resolvedAppVersion = await _resolveAppVersion();
final response = await _client.register(
BackendRegisterRequest(
email: email,
@@ -51,7 +54,7 @@ class BackendAuthService {
deviceId: deviceId,
deviceName: _deviceName(),
deviceOs: _deviceOs(),
- appVersion: appVersion,
+ appVersion: resolvedAppVersion,
),
);
@@ -115,6 +118,26 @@ class BackendAuthService {
return response.user;
}
+ Future requestAccountDeletion({String? reason}) async {
+ final existing = await _sessionStore.readSession();
+ if (!existing.hasAccessToken || existing.accessToken == null) {
+ throw const BackendApiException(
+ message: 'Sign in is required to request account deletion.',
+ code: 'AUTH_REQUIRED',
+ );
+ }
+
+ await _client.requestAccountDeletion(
+ accessToken: existing.accessToken!,
+ request: BackendAccountDeletionRequest(
+ reason: reason,
+ source: 'mobile_app',
+ ),
+ );
+
+ await _sessionStore.clearSession();
+ }
+
Future _persistAuthResponse(BackendAuthResponse response) async {
final session = AuthSession(
status: AuthSessionStatus.signedIn,
@@ -143,4 +166,23 @@ class BackendAuthService {
final version = Platform.operatingSystemVersion.trim();
return '$os $version'.trim();
}
+
+ Future _resolveAppVersion() async {
+ final configured = appVersion?.trim();
+ if (configured != null && configured.isNotEmpty) {
+ return configured;
+ }
+
+ try {
+ final packageInfo = await PackageInfo.fromPlatform();
+ final detected = packageInfo.version.trim();
+ if (detected.isNotEmpty) {
+ return detected;
+ }
+ } catch (_) {
+ // Keep auth non-blocking if package info is unavailable.
+ }
+
+ return null;
+ }
}
diff --git a/apps/mobile/lib/core/bootstrap/app_bootstrap.dart b/apps/mobile/lib/core/bootstrap/app_bootstrap.dart
index acd0c86..5582bca 100644
--- a/apps/mobile/lib/core/bootstrap/app_bootstrap.dart
+++ b/apps/mobile/lib/core/bootstrap/app_bootstrap.dart
@@ -40,6 +40,7 @@ abstract final class AppBootstrap {
performanceEnabled: firebaseRuntimeConfig.performanceCollectionEnabled,
appCheckEnabled: firebaseRuntimeConfig.appCheckEnabled,
fcmEnabled: firebaseRuntimeConfig.fcmEnabled,
+ metadataEnabled: firebaseRuntimeConfig.metadataFeatureEnabled,
backendEnabled: firebaseRuntimeConfig.backendIntegrationEnabled,
authEnabled: firebaseRuntimeConfig.authFeatureEnabled,
syncEnabled: firebaseRuntimeConfig.syncFeatureEnabled,
diff --git a/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart b/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart
index 20ee3cc..b8dce08 100644
--- a/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart
+++ b/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart
@@ -27,10 +27,26 @@ abstract final class FirebaseServicesBootstrap {
'app_performance_collection_enabled';
static const _appCheckEnabledKey = 'app_app_check_enabled';
static const _fcmEnabledKey = 'app_fcm_enabled';
+ static const _metadataFeatureEnabledKey = 'app_metadata_feature_enabled';
static const _backendIntegrationEnabledKey =
'app_backend_integration_enabled';
static const _authFeatureEnabledKey = 'app_auth_feature_enabled';
static const _syncFeatureEnabledKey = 'app_sync_feature_enabled';
+ static const _appUpdateFeatureEnabledKey = 'app_update_feature_enabled';
+ static const _appUpdateUseBackendKey = 'app_update_use_backend';
+ static const _appUpdateCheckIntervalHoursKey =
+ 'app_update_check_interval_hours';
+ static const _appUpdateSnoozeHoursKey = 'app_update_snooze_hours';
+ static const _appUpdateForceModeKey = 'app_update_force_mode';
+ static const _appUpdateMinSupportedAndroidKey =
+ 'app_update_min_supported_android';
+ static const _appUpdateMinSupportedIosKey = 'app_update_min_supported_ios';
+ static const _appUpdateLatestAndroidKey = 'app_update_latest_android';
+ static const _appUpdateLatestIosKey = 'app_update_latest_ios';
+ static const _appUpdateStoreUrlAndroidKey = 'app_update_store_url_android';
+ static const _appUpdateStoreUrlIosKey = 'app_update_store_url_ios';
+ static const _appUpdateTitleKey = 'app_update_title';
+ static const _appUpdateMessageKey = 'app_update_message';
static Future initialize() async {
final remoteConfigService = FirebaseRemoteConfigService.instance;
@@ -42,9 +58,23 @@ abstract final class FirebaseServicesBootstrap {
_performanceCollectionEnabledKey: true,
_appCheckEnabledKey: false,
_fcmEnabledKey: false,
+ _metadataFeatureEnabledKey: true,
_backendIntegrationEnabledKey: false,
_authFeatureEnabledKey: true,
_syncFeatureEnabledKey: false,
+ _appUpdateFeatureEnabledKey: true,
+ _appUpdateUseBackendKey: true,
+ _appUpdateCheckIntervalHoursKey: 12,
+ _appUpdateSnoozeHoursKey: 24,
+ _appUpdateForceModeKey: false,
+ _appUpdateMinSupportedAndroidKey: '',
+ _appUpdateMinSupportedIosKey: '',
+ _appUpdateLatestAndroidKey: '',
+ _appUpdateLatestIosKey: '',
+ _appUpdateStoreUrlAndroidKey: '',
+ _appUpdateStoreUrlIosKey: '',
+ _appUpdateTitleKey: '',
+ _appUpdateMessageKey: '',
},
minimumFetchInterval: kDebugMode
? const Duration(minutes: 5)
@@ -69,6 +99,7 @@ abstract final class FirebaseServicesBootstrap {
'performance: ${runtimeConfig.performanceCollectionEnabled}, '
'appCheck: ${runtimeConfig.appCheckEnabled}, '
'fcm: ${runtimeConfig.fcmEnabled}, '
+ 'metadata: ${runtimeConfig.metadataFeatureEnabled}, '
'backend: ${runtimeConfig.backendIntegrationEnabled}, '
'auth: ${runtimeConfig.authFeatureEnabled}, '
'sync: ${runtimeConfig.syncFeatureEnabled}).',
@@ -115,6 +146,7 @@ abstract final class FirebaseServicesBootstrap {
'performance: ${runtimeConfig.performanceCollectionEnabled}, '
'appCheck: ${runtimeConfig.appCheckEnabled}, '
'fcm: ${runtimeConfig.fcmEnabled}, '
+ 'metadata: ${runtimeConfig.metadataFeatureEnabled}, '
'backend: ${runtimeConfig.backendIntegrationEnabled}, '
'auth: ${runtimeConfig.authFeatureEnabled}, '
'sync: ${runtimeConfig.syncFeatureEnabled}).',
@@ -126,6 +158,7 @@ abstract final class FirebaseServicesBootstrap {
performanceEnabled: runtimeConfig.performanceCollectionEnabled,
appCheckEnabled: runtimeConfig.appCheckEnabled,
fcmEnabled: runtimeConfig.fcmEnabled,
+ metadataEnabled: runtimeConfig.metadataFeatureEnabled,
backendEnabled: runtimeConfig.backendIntegrationEnabled,
authEnabled: runtimeConfig.authFeatureEnabled,
syncEnabled: runtimeConfig.syncFeatureEnabled,
@@ -160,6 +193,10 @@ abstract final class FirebaseServicesBootstrap {
fallback: false,
),
fcmEnabled: remoteConfigService.getBool(_fcmEnabledKey, fallback: false),
+ metadataFeatureEnabled: remoteConfigService.getBool(
+ _metadataFeatureEnabledKey,
+ fallback: true,
+ ),
backendIntegrationEnabled: remoteConfigService.getBool(
_backendIntegrationEnabledKey,
fallback: false,
diff --git a/apps/mobile/lib/core/firebase/firebase_runtime_config.dart b/apps/mobile/lib/core/firebase/firebase_runtime_config.dart
index ba53a07..9d77ed2 100644
--- a/apps/mobile/lib/core/firebase/firebase_runtime_config.dart
+++ b/apps/mobile/lib/core/firebase/firebase_runtime_config.dart
@@ -5,6 +5,7 @@ class FirebaseRuntimeConfig {
required this.performanceCollectionEnabled,
required this.appCheckEnabled,
required this.fcmEnabled,
+ required this.metadataFeatureEnabled,
required this.backendIntegrationEnabled,
required this.authFeatureEnabled,
required this.syncFeatureEnabled,
@@ -16,6 +17,7 @@ class FirebaseRuntimeConfig {
performanceCollectionEnabled: true,
appCheckEnabled: false,
fcmEnabled: false,
+ metadataFeatureEnabled: true,
backendIntegrationEnabled: false,
authFeatureEnabled: true,
syncFeatureEnabled: false,
@@ -26,6 +28,7 @@ class FirebaseRuntimeConfig {
final bool performanceCollectionEnabled;
final bool appCheckEnabled;
final bool fcmEnabled;
+ final bool metadataFeatureEnabled;
final bool backendIntegrationEnabled;
final bool authFeatureEnabled;
final bool syncFeatureEnabled;
diff --git a/apps/mobile/lib/core/metadata/metadata_lookup_service.dart b/apps/mobile/lib/core/metadata/metadata_lookup_service.dart
new file mode 100644
index 0000000..d3c0e31
--- /dev/null
+++ b/apps/mobile/lib/core/metadata/metadata_lookup_service.dart
@@ -0,0 +1,606 @@
+import 'dart:collection';
+
+import 'package:dio/dio.dart';
+import 'package:domain/domain.dart';
+import 'package:metadata_api/metadata_api.dart';
+import 'package:storage/storage.dart';
+
+class MetadataApiConfig {
+ const MetadataApiConfig({
+ required this.googleBooksApiKey,
+ required this.igdbClientId,
+ required this.igdbClientSecret,
+ required this.tmdbReadAccessToken,
+ });
+
+ final String googleBooksApiKey;
+ final String igdbClientId;
+ final String igdbClientSecret;
+ final String tmdbReadAccessToken;
+
+ bool get hasIgdbConfig =>
+ igdbClientId.trim().isNotEmpty && igdbClientSecret.trim().isNotEmpty;
+
+ bool get hasTmdbConfig => tmdbReadAccessToken.trim().isNotEmpty;
+}
+
+class MetadataLookupMatch {
+ const MetadataLookupMatch({
+ required this.metadata,
+ required this.confidence,
+ required this.source,
+ });
+
+ const MetadataLookupMatch.none()
+ : metadata = null,
+ confidence = 0,
+ source = 'none';
+
+ final MetadataBase? metadata;
+ final double confidence;
+ final String source;
+
+ bool get hasMetadata => metadata != null;
+}
+
+class MetadataLookupService {
+ MetadataLookupService({
+ required MetadataApiConfig config,
+ required SecureStorageService secureStorage,
+ }) : _config = config,
+ _secureStorage = secureStorage,
+ _oauthDio = Dio(
+ BaseOptions(
+ connectTimeout: const Duration(seconds: 10),
+ receiveTimeout: const Duration(seconds: 10),
+ headers: const {'Accept': 'application/json'},
+ ),
+ );
+
+ static const _igdbTokenStorageKey = 'metadata_igdb_access_token';
+ static const _igdbTokenSkewSeconds = 120;
+ static const _searchCacheTtl = Duration(minutes: 5);
+ static const _barcodeCacheTtl = Duration(minutes: 10);
+ static const _maxSearchCacheEntries = 120;
+ static const _maxBarcodeCacheEntries = 120;
+
+ final MetadataApiConfig _config;
+ final SecureStorageService _secureStorage;
+ final Dio _oauthDio;
+
+ GoogleBooksClient? _booksClient;
+ TMDBClient? _moviesClient;
+ IGDBClient? _gamesClient;
+ String? _gamesClientToken;
+ Future? _tokenRefreshInFlight;
+ final LinkedHashMap>>
+ _searchCache = LinkedHashMap();
+ final LinkedHashMap>
+ _barcodeCache = LinkedHashMap();
+ final Map>> _searchInFlight = {};
+ final Map> _barcodeInFlight = {};
+
+ bool supportsSearch(CollectionType collectionType) {
+ return switch (collectionType) {
+ CollectionType.book => true,
+ CollectionType.game => _config.hasIgdbConfig,
+ CollectionType.movie => _config.hasTmdbConfig,
+ CollectionType.comic ||
+ CollectionType.music ||
+ CollectionType.custom => false,
+ };
+ }
+
+ bool supportsBarcodeLookup({
+ required CollectionType primaryType,
+ List? fallbackTypes,
+ }) {
+ if (supportsSearch(primaryType)) {
+ return true;
+ }
+
+ final fallbackOrder = _resolveFallbackTypes(
+ primaryType: primaryType,
+ providedFallbackTypes: fallbackTypes,
+ );
+ return fallbackOrder.any(supportsSearch);
+ }
+
+ Future> search({
+ required String query,
+ required CollectionType collectionType,
+ int limit = 10,
+ }) async {
+ final normalizedQuery = query.trim();
+ if (normalizedQuery.isEmpty) {
+ return const [];
+ }
+
+ final key = _searchCacheKey(
+ collectionType: collectionType,
+ query: normalizedQuery,
+ limit: limit,
+ );
+ final cached = _getSearchCache(key);
+ if (cached != null) {
+ return cached;
+ }
+
+ final inFlight = _searchInFlight[key];
+ if (inFlight != null) {
+ return inFlight;
+ }
+
+ final request = _searchInternal(
+ query: normalizedQuery,
+ collectionType: collectionType,
+ limit: limit,
+ );
+ _searchInFlight[key] = request;
+
+ try {
+ final results = await request;
+ _putSearchCache(key, results);
+ return results;
+ } finally {
+ _searchInFlight.remove(key);
+ }
+ }
+
+ Future findBestBarcodeMatch({
+ required String barcode,
+ required CollectionType primaryType,
+ List? fallbackTypes,
+ }) async {
+ final normalizedBarcode = barcode.trim();
+ if (normalizedBarcode.isEmpty) {
+ return const MetadataLookupMatch.none();
+ }
+
+ final fallbackOrder = _resolveFallbackTypes(
+ primaryType: primaryType,
+ providedFallbackTypes: fallbackTypes,
+ );
+ final key = _barcodeCacheKey(
+ primaryType: primaryType,
+ barcode: normalizedBarcode,
+ fallbackTypes: fallbackOrder,
+ );
+ final cached = _getBarcodeCache(key);
+ if (cached != null) {
+ return cached;
+ }
+
+ final inFlight = _barcodeInFlight[key];
+ if (inFlight != null) {
+ return inFlight;
+ }
+
+ final request = _findBestBarcodeMatchInternal(
+ barcode: normalizedBarcode,
+ primaryType: primaryType,
+ fallbackTypes: fallbackOrder,
+ );
+ _barcodeInFlight[key] = request;
+
+ try {
+ final result = await request;
+ _putBarcodeCache(key, result);
+ return result;
+ } finally {
+ _barcodeInFlight.remove(key);
+ }
+ }
+
+ Future> _searchInternal({
+ required String query,
+ required CollectionType collectionType,
+ required int limit,
+ }) async {
+ final results = switch (collectionType) {
+ CollectionType.book => await _searchBooks(query, limit),
+ CollectionType.game => await _searchGames(query, limit),
+ CollectionType.movie => await _searchMovies(query, limit),
+ CollectionType.comic ||
+ CollectionType.music ||
+ CollectionType.custom => const [],
+ };
+
+ return _filterSearchResults(
+ collectionType: collectionType,
+ results: results,
+ );
+ }
+
+ Future _findBestBarcodeMatchInternal({
+ required String barcode,
+ required CollectionType primaryType,
+ required List fallbackTypes,
+ }) async {
+ final primary = await _safeFetchByBarcode(
+ barcode: barcode,
+ collectionType: primaryType,
+ );
+
+ if (primary != null) {
+ return MetadataLookupMatch(
+ metadata: primary,
+ confidence: 1,
+ source: primaryType.name,
+ );
+ }
+
+ for (final fallbackType in fallbackTypes) {
+ final fallback = await _safeFetchByBarcode(
+ barcode: barcode,
+ collectionType: fallbackType,
+ );
+ if (fallback != null) {
+ return MetadataLookupMatch(
+ metadata: fallback,
+ confidence: 0.7,
+ source: fallbackType.name,
+ );
+ }
+ }
+
+ return const MetadataLookupMatch.none();
+ }
+
+ Future _safeFetchByBarcode({
+ required String barcode,
+ required CollectionType collectionType,
+ }) async {
+ try {
+ return _fetchByBarcode(barcode: barcode, collectionType: collectionType);
+ } catch (_) {
+ return null;
+ }
+ }
+
+ Future _fetchByBarcode({
+ required String barcode,
+ required CollectionType collectionType,
+ }) async {
+ switch (collectionType) {
+ case CollectionType.book:
+ return _fetchBookByBarcode(barcode);
+ case CollectionType.game:
+ final games = _filterSearchResults(
+ collectionType: CollectionType.game,
+ results: await _searchGames(barcode, 1),
+ );
+ return games.isEmpty ? null : games.first;
+ case CollectionType.movie:
+ final movies = _filterSearchResults(
+ collectionType: CollectionType.movie,
+ results: await _searchMovies(barcode, 1),
+ );
+ return movies.isEmpty ? null : movies.first;
+ case CollectionType.comic:
+ case CollectionType.music:
+ case CollectionType.custom:
+ return null;
+ }
+ }
+
+ Future _fetchBookByBarcode(String barcode) async {
+ final client = _resolveBooksClient();
+ if (client == null) {
+ return null;
+ }
+
+ if (_isValidIsbn(barcode)) {
+ return _unwrapOrThrow(await client.getBookByISBN(barcode));
+ }
+
+ final response = _unwrapOrThrow>(
+ await client.searchBooks(query: barcode, pageSize: 1),
+ );
+ if (response.items.isEmpty) {
+ return null;
+ }
+ return response.items.first;
+ }
+
+ Future> _searchBooks(String query, int limit) async {
+ final client = _resolveBooksClient();
+ if (client == null) {
+ return const [];
+ }
+
+ final response = _unwrapOrThrow>(
+ await client.searchBooks(query: query, pageSize: limit),
+ );
+ return response.items.cast();
+ }
+
+ Future> _searchGames(String query, int limit) async {
+ try {
+ return await _withGamesClient((client) async {
+ final response = _unwrapOrThrow>(
+ await client.searchGames(query: query, pageSize: limit),
+ );
+ return response.items.cast();
+ });
+ } on AppException catch (error) {
+ if (!_isInvalidIgdbTokenError(error)) {
+ rethrow;
+ }
+
+ _gamesClient = null;
+ _gamesClientToken = null;
+ await _secureStorage.delete(_igdbTokenStorageKey);
+
+ return _withGamesClient((client) async {
+ final response = _unwrapOrThrow>(
+ await client.searchGames(query: query, pageSize: limit),
+ );
+ return response.items.cast();
+ });
+ }
+ }
+
+ Future> _searchMovies(String query, int limit) async {
+ final client = _resolveMoviesClient();
+ if (client == null) {
+ return const [];
+ }
+
+ final response = _unwrapOrThrow>(
+ await client.searchMovies(query: query),
+ );
+ return response.items.take(limit).toList().cast();
+ }
+
+ GoogleBooksClient? _resolveBooksClient() {
+ _booksClient ??= GoogleBooksClient(
+ apiKey: _config.googleBooksApiKey.trim().isEmpty
+ ? null
+ : _config.googleBooksApiKey,
+ );
+ return _booksClient;
+ }
+
+ TMDBClient? _resolveMoviesClient() {
+ if (!_config.hasTmdbConfig) {
+ return null;
+ }
+
+ _moviesClient ??= TMDBClient(apiKey: _config.tmdbReadAccessToken);
+ return _moviesClient;
+ }
+
+ Future _resolveGamesClient() async {
+ if (!_config.hasIgdbConfig) {
+ return null;
+ }
+
+ final token = await _resolveIgdbAccessToken();
+ if (token == null || token.trim().isEmpty) {
+ return null;
+ }
+
+ if (_gamesClient != null && _gamesClientToken == token) {
+ return _gamesClient;
+ }
+
+ _gamesClientToken = token;
+ _gamesClient = IGDBClient(
+ clientId: _config.igdbClientId,
+ accessToken: token,
+ );
+ return _gamesClient;
+ }
+
+ Future _resolveIgdbAccessToken() async {
+ final cached = await _secureStorage.get(_igdbTokenStorageKey);
+ if (cached != null && cached.trim().isNotEmpty) {
+ return cached;
+ }
+
+ final inFlight = _tokenRefreshInFlight;
+ if (inFlight != null) {
+ return inFlight;
+ }
+
+ final refreshFuture = _requestIgdbAccessToken();
+ _tokenRefreshInFlight = refreshFuture;
+
+ try {
+ return await refreshFuture;
+ } finally {
+ _tokenRefreshInFlight = null;
+ }
+ }
+
+ Future _requestIgdbAccessToken() async {
+ if (!_config.hasIgdbConfig) {
+ return null;
+ }
+
+ try {
+ final response = await _oauthDio.post