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>( + 'https://id.twitch.tv/oauth2/token', + queryParameters: { + 'client_id': _config.igdbClientId, + 'client_secret': _config.igdbClientSecret, + 'grant_type': 'client_credentials', + }, + ); + + final data = response.data; + if (data == null) { + return null; + } + + final token = (data['access_token'] as String?)?.trim(); + if (token == null || token.isEmpty) { + return null; + } + + final expiresIn = (data['expires_in'] as num?)?.toInt(); + final ttlSeconds = expiresIn == null + ? null + : (expiresIn - _igdbTokenSkewSeconds).clamp( + _igdbTokenSkewSeconds, + expiresIn, + ); + + await _secureStorage.save( + _igdbTokenStorageKey, + token, + ttl: ttlSeconds, + ); + return token; + } on DioException { + return null; + } + } + + Future _withGamesClient( + Future Function(IGDBClient client) run, + ) async { + final client = await _resolveGamesClient(); + if (client == null) { + throw AppException.business( + message: 'IGDB metadata source is unavailable', + ); + } + return run(client); + } + + T _unwrapOrThrow(dynamic eitherResult) { + return eitherResult.fold( + (exception) => throw exception as AppException, + (value) => value as T, + ) + as T; + } + + bool _isInvalidIgdbTokenError(AppException exception) { + return exception.maybeWhen( + business: (_, code) => code == 'INVALID_TOKEN', + orElse: () => false, + ); + } + + bool _isValidIsbn(String value) { + final cleaned = value.replaceAll(RegExp(r'[^0-9X]'), ''); + return cleaned.length == 10 || cleaned.length == 13; + } + + List _filterSearchResults({ + required CollectionType collectionType, + required List results, + }) { + return switch (collectionType) { + CollectionType.book => results.whereType().toList(), + CollectionType.game => results.whereType().toList(), + CollectionType.movie => results.whereType().toList(), + CollectionType.comic || + CollectionType.music || + CollectionType.custom => const [], + }; + } + + List _resolveFallbackTypes({ + required CollectionType primaryType, + required List? providedFallbackTypes, + }) { + final defaults = switch (primaryType) { + CollectionType.book => const [], + CollectionType.game => const [], + CollectionType.movie => const [], + CollectionType.comic || CollectionType.music || CollectionType.custom => + const [CollectionType.book, CollectionType.movie, CollectionType.game], + }; + + final candidates = providedFallbackTypes ?? defaults; + final resolved = []; + for (final type in candidates) { + if (type == primaryType || resolved.contains(type)) { + continue; + } + resolved.add(type); + } + return resolved; + } + + String _searchCacheKey({ + required CollectionType collectionType, + required String query, + required int limit, + }) { + return '${collectionType.name}|${query.toLowerCase()}|$limit'; + } + + String _barcodeCacheKey({ + required CollectionType primaryType, + required String barcode, + required List fallbackTypes, + }) { + final fallback = fallbackTypes.map((type) => type.name).join(','); + return '${primaryType.name}|${barcode.toLowerCase()}|$fallback'; + } + + List? _getSearchCache(String key) { + final entry = _searchCache.remove(key); + if (entry == null) { + return null; + } + if (entry.isExpired) { + return null; + } + _searchCache[key] = entry; + return entry.value; + } + + MetadataLookupMatch? _getBarcodeCache(String key) { + final entry = _barcodeCache.remove(key); + if (entry == null) { + return null; + } + if (entry.isExpired) { + return null; + } + _barcodeCache[key] = entry; + return entry.value; + } + + void _putSearchCache(String key, List value) { + _searchCache.remove(key); + _searchCache[key] = _MetadataCacheEntry( + value: List.unmodifiable(value), + expiresAt: DateTime.now().add(_searchCacheTtl), + ); + _trimCache(_searchCache, _maxSearchCacheEntries); + } + + void _putBarcodeCache(String key, MetadataLookupMatch value) { + _barcodeCache.remove(key); + _barcodeCache[key] = _MetadataCacheEntry( + value: value, + expiresAt: DateTime.now().add(_barcodeCacheTtl), + ); + _trimCache(_barcodeCache, _maxBarcodeCacheEntries); + } + + void _trimCache( + LinkedHashMap> cache, + int maxEntries, + ) { + while (cache.length > maxEntries) { + cache.remove(cache.keys.first); + } + } +} + +class _MetadataCacheEntry { + const _MetadataCacheEntry({required this.value, required this.expiresAt}); + + final T value; + final DateTime expiresAt; + + bool get isExpired => DateTime.now().isAfter(expiresAt); +} diff --git a/apps/mobile/lib/core/notifications/local_notification_service.dart b/apps/mobile/lib/core/notifications/local_notification_service.dart index 7208117..9d34d68 100644 --- a/apps/mobile/lib/core/notifications/local_notification_service.dart +++ b/apps/mobile/lib/core/notifications/local_notification_service.dart @@ -10,7 +10,7 @@ class LocalNotificationService { : _plugin = plugin ?? FlutterLocalNotificationsPlugin(); static const _channelId = 'collection_tracker_general'; - static const _channelName = 'Collection Tracker'; + static const _channelName = 'Collectra'; static const _channelDescription = 'Sync updates, price alerts, reminders, and account notifications.'; @@ -42,7 +42,7 @@ class LocalNotificationService { } const initializationSettings = InitializationSettings( - android: AndroidInitializationSettings('launcher_icon'), + android: AndroidInitializationSettings('@mipmap/launcher_icon'), // Permission prompts are handled by FirebaseMessagingService to keep // consent flow centralized (onboarding/settings). iOS: DarwinInitializationSettings( @@ -158,7 +158,7 @@ class LocalNotificationService { 'price_alert' => 'Price alert', 'reminder' => 'Reminder', 'account_security' => 'Account security', - _ => 'Collection Tracker', + _ => 'Collectra', }; } @@ -171,7 +171,7 @@ class LocalNotificationService { return switch (type) { 'sync_needed' => 'Open the app to sync your latest changes.', 'price_alert' => 'One of your tracked item prices changed.', - 'reminder' => 'You have a reminder from Collection Tracker.', + 'reminder' => 'You have a reminder from Collectra.', 'account_security' => 'Please review a recent account security event.', _ => 'You have a new notification.', }; diff --git a/apps/mobile/lib/core/notifications/push_notification_bridge.dart b/apps/mobile/lib/core/notifications/push_notification_bridge.dart index a16947a..120661b 100644 --- a/apps/mobile/lib/core/notifications/push_notification_bridge.dart +++ b/apps/mobile/lib/core/notifications/push_notification_bridge.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:app_analytics/app_analytics.dart'; import 'package:app_firebase/app_firebase.dart'; import 'package:collection_tracker/core/observability/operational_telemetry.dart'; +import 'package:collection_tracker/core/providers/backend_api_providers.dart'; import 'package:collection_tracker/core/providers/push_notifications_provider.dart'; import 'package:collection_tracker/core/router/routes.dart'; import 'package:flutter/material.dart'; @@ -171,11 +172,13 @@ class _PushNotificationBridgeState return '/collections/$collectionId'; } + final authFeatureEnabled = ref.read(backendAuthFeatureFlagProvider); return switch (_notificationType(message.data)) { 'sync_needed' => Routes.settings, 'price_alert' => Routes.statistics, 'reminder' => Routes.collections, - 'account_security' => '${Routes.auth}?mode=signin', + 'account_security' => + authFeatureEnabled ? '${Routes.auth}?mode=signin' : Routes.settings, _ => null, }; } diff --git a/apps/mobile/lib/core/observability/operational_telemetry.dart b/apps/mobile/lib/core/observability/operational_telemetry.dart index dcbaa1c..bdcfddc 100644 --- a/apps/mobile/lib/core/observability/operational_telemetry.dart +++ b/apps/mobile/lib/core/observability/operational_telemetry.dart @@ -50,13 +50,16 @@ class OperationalTelemetry { required int syncedCollections, required int syncedItems, required int syncedTags, + int syncedLoans = 0, required int conflictCount, required int appliedServerCollections, required int appliedServerItems, required int appliedServerTags, + int appliedServerLoans = 0, required int skippedServerCollections, required int skippedServerItems, required int skippedServerTags, + int skippedServerLoans = 0, String? message, Object? error, StackTrace? stackTrace, @@ -73,13 +76,16 @@ class OperationalTelemetry { 'synced_collections': syncedCollections, 'synced_items': syncedItems, 'synced_tags': syncedTags, + 'synced_loans': syncedLoans, 'conflicts': conflictCount, 'applied_server_collections': appliedServerCollections, 'applied_server_items': appliedServerItems, 'applied_server_tags': appliedServerTags, + 'applied_server_loans': appliedServerLoans, 'skipped_server_collections': skippedServerCollections, 'skipped_server_items': skippedServerItems, 'skipped_server_tags': skippedServerTags, + 'skipped_server_loans': skippedServerLoans, if (message != null && message.trim().isNotEmpty) 'message': message, }, crashlyticsLog: true, @@ -94,9 +100,15 @@ class OperationalTelemetry { 'sync_pending_ops': pendingOperations, 'sync_processed_ops': processedOperations, 'sync_applied_server': - appliedServerCollections + appliedServerItems + appliedServerTags, + appliedServerCollections + + appliedServerItems + + appliedServerTags + + appliedServerLoans, 'sync_skipped_server': - skippedServerCollections + skippedServerItems + skippedServerTags, + skippedServerCollections + + skippedServerItems + + skippedServerTags + + skippedServerLoans, }, ); } @@ -153,6 +165,7 @@ class OperationalTelemetry { required bool performanceEnabled, required bool appCheckEnabled, required bool fcmEnabled, + required bool metadataEnabled, required bool backendEnabled, required bool authEnabled, required bool syncEnabled, @@ -168,6 +181,7 @@ class OperationalTelemetry { 'performance_enabled': performanceEnabled, 'app_check_enabled': appCheckEnabled, 'fcm_enabled': fcmEnabled, + 'metadata_enabled': metadataEnabled, 'backend_enabled': backendEnabled, 'auth_enabled': authEnabled, 'sync_enabled': syncEnabled, @@ -180,6 +194,7 @@ class OperationalTelemetry { 'flag_performance_enabled': performanceEnabled, 'flag_app_check_enabled': appCheckEnabled, 'flag_fcm_enabled': fcmEnabled, + 'flag_metadata_enabled': metadataEnabled, 'flag_backend_enabled': backendEnabled, 'flag_auth_enabled': authEnabled, 'flag_sync_enabled': syncEnabled, diff --git a/apps/mobile/lib/core/providers/app_info_provider.dart b/apps/mobile/lib/core/providers/app_info_provider.dart new file mode 100644 index 0000000..6c148c7 --- /dev/null +++ b/apps/mobile/lib/core/providers/app_info_provider.dart @@ -0,0 +1,57 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +final appPackageInfoProvider = FutureProvider((ref) async { + return PackageInfo.fromPlatform(); +}); + +final appDisplayVersionProvider = Provider((ref) { + final packageInfo = ref.watch(appPackageInfoProvider).asData?.value; + if (packageInfo != null) { + final version = packageInfo.version.trim(); + final buildNumber = packageInfo.buildNumber.trim(); + if (version.isNotEmpty && buildNumber.isNotEmpty) { + return '$version+$buildNumber'; + } + if (version.isNotEmpty) { + return version; + } + } + + return _fallbackDisplayVersion; +}); + +final appSemanticVersionProvider = Provider((ref) { + final packageVersion = ref.watch(appPackageInfoProvider).asData?.value; + final normalizedPackageVersion = packageVersion?.version.trim(); + if (normalizedPackageVersion != null && normalizedPackageVersion.isNotEmpty) { + return normalizedPackageVersion; + } + + final normalizedEnvVersion = _envAppVersion.trim(); + if (normalizedEnvVersion.isNotEmpty) { + return normalizedEnvVersion; + } + + return null; +}); + +const _envAppVersion = String.fromEnvironment('APP_VERSION', defaultValue: ''); +const _envAppBuildNumber = String.fromEnvironment( + 'APP_BUILD_NUMBER', + defaultValue: '', +); + +String get _fallbackDisplayVersion { + final version = _envAppVersion.trim(); + final buildNumber = _envAppBuildNumber.trim(); + + if (version.isNotEmpty && buildNumber.isNotEmpty) { + return '$version+$buildNumber'; + } + if (version.isNotEmpty) { + return version; + } + + return '-'; +} diff --git a/apps/mobile/lib/core/providers/backend_api_providers.dart b/apps/mobile/lib/core/providers/backend_api_providers.dart index 1d9876e..ae0c4af 100644 --- a/apps/mobile/lib/core/providers/backend_api_providers.dart +++ b/apps/mobile/lib/core/providers/backend_api_providers.dart @@ -4,9 +4,11 @@ import 'package:collection_tracker/core/auth/backend_auth_service.dart'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:native_id/native_id.dart'; import 'package:storage/storage.dart'; import 'package:uuid/uuid.dart'; +import 'app_info_provider.dart'; import 'auth_session_providers.dart'; import 'firebase_runtime_config_provider.dart'; @@ -242,25 +244,63 @@ final backendAuthClientProvider = Provider((ref) { ); }); +final backendAppUpdateClientProvider = Provider((ref) { + final readiness = ref.watch(backendApiReadinessProvider); + if (!readiness.enabled) { + return null; + } + + final root = ref.watch(backendApiRootProvider); + if (root.isEmpty) { + return null; + } + + return BackendAppUpdateClient( + dio: ref.watch(backendApiDioProvider), + apiBaseUrl: root, + ); +}); + final backendDeviceIdProvider = FutureProvider((ref) async { final storage = SecureStorageService.instance; const currentKey = 'backend_device_id'; const legacyKey = 'sync_device_id'; - String? deviceId = await storage.get(currentKey); - if (deviceId == null || deviceId.trim().isEmpty) { - deviceId = await storage.get(legacyKey); + Future persistDeviceId(String value) async { + await storage.save(currentKey, value); + await storage.save(legacyKey, value); + } + + String? normalize(String? value) { + final normalized = value?.trim(); + if (normalized == null || normalized.isEmpty) { + return null; + } + return normalized; + } + + final nativeId = normalize(await NativeIdService.getDeviceId()); + if (nativeId != null) { + await persistDeviceId(nativeId); + return nativeId; } - if (deviceId == null || deviceId.trim().isEmpty) { - deviceId = const Uuid().v4(); + final storedCurrent = normalize(await storage.get(currentKey)); + if (storedCurrent != null) { + await persistDeviceId(storedCurrent); + return storedCurrent; } - await storage.save(currentKey, deviceId); - await storage.save(legacyKey, deviceId); + final storedLegacy = normalize(await storage.get(legacyKey)); + if (storedLegacy != null) { + await persistDeviceId(storedLegacy); + return storedLegacy; + } - return deviceId; + final fallbackId = const Uuid().v4(); + await persistDeviceId(fallbackId); + return fallbackId; }); final backendAuthServiceProvider = Provider((ref) { @@ -270,18 +310,40 @@ final backendAuthServiceProvider = Provider((ref) { } final sessionStore = ref.watch(authSessionStoreProvider); + final detectedAppVersion = ref.watch(appSemanticVersionProvider); + const envAppVersion = String.fromEnvironment('APP_VERSION', defaultValue: ''); + final appVersion = envAppVersion.trim().isNotEmpty + ? envAppVersion.trim() + : detectedAppVersion; return BackendAuthService( client: client, sessionStore: sessionStore, resolveDeviceId: () => ref.read(backendDeviceIdProvider.future), - appVersion: const String.fromEnvironment( - 'APP_VERSION', - defaultValue: '1.0.0', - ), + appVersion: appVersion, ); }); +final backendAuthProfileProvider = FutureProvider(( + ref, +) async { + final service = ref.watch(backendAuthServiceProvider); + if (service == null) { + return null; + } + + final session = ref.watch(authSessionProvider).value; + if (session == null || !session.isAuthenticated) { + return null; + } + + try { + return await service.fetchProfile(); + } on BackendApiException { + return null; + } +}); + final backendSessionStoreProvider = Provider((ref) { return ref.watch(authSessionStoreProvider); }); diff --git a/apps/mobile/lib/core/providers/data_providers.dart b/apps/mobile/lib/core/providers/data_providers.dart index 1573c1f..52bfa05 100644 --- a/apps/mobile/lib/core/providers/data_providers.dart +++ b/apps/mobile/lib/core/providers/data_providers.dart @@ -24,3 +24,12 @@ ItemRepository itemRepository(Ref ref) { : null; return ItemRepositoryImpl(dao, syncDao: syncDao); } + +@riverpod +LoanRepository loanRepository(Ref ref) { + final dao = ref.watch(loanDaoProvider); + final syncDao = ref.watch(syncOutboxWritesEnabledProvider) + ? ref.watch(syncDaoProvider) + : null; + return LoanRepositoryImpl(dao, syncDao: syncDao); +} diff --git a/apps/mobile/lib/core/providers/database_providers.dart b/apps/mobile/lib/core/providers/database_providers.dart index 207730b..75c486b 100644 --- a/apps/mobile/lib/core/providers/database_providers.dart +++ b/apps/mobile/lib/core/providers/database_providers.dart @@ -21,3 +21,9 @@ ItemDao itemDao(Ref ref) { final database = ref.watch(appDatabaseProvider); return database.itemDao; } + +@riverpod +LoanDao loanDao(Ref ref) { + final database = ref.watch(appDatabaseProvider); + return database.loanDao; +} diff --git a/apps/mobile/lib/core/providers/metadata_preferences_provider.dart b/apps/mobile/lib/core/providers/metadata_preferences_provider.dart new file mode 100644 index 0000000..43be7d0 --- /dev/null +++ b/apps/mobile/lib/core/providers/metadata_preferences_provider.dart @@ -0,0 +1,93 @@ +import 'package:collection_tracker/core/firebase/firebase_runtime_config.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:storage/storage.dart'; + +import 'firebase_runtime_config_provider.dart'; + +class MetadataPreferencesState { + const MetadataPreferencesState({ + required this.preferenceEnabled, + required this.autoFetchBarcodeEnabled, + required this.fillOnlyEmptyFields, + required this.runtimeFeatureEnabled, + }); + + final bool preferenceEnabled; + final bool autoFetchBarcodeEnabled; + final bool fillOnlyEmptyFields; + final bool runtimeFeatureEnabled; + + bool get isEnabled => runtimeFeatureEnabled && preferenceEnabled; + bool get canAutoFetchFromBarcode => isEnabled && autoFetchBarcodeEnabled; + + MetadataPreferencesState copyWith({ + bool? preferenceEnabled, + bool? autoFetchBarcodeEnabled, + bool? fillOnlyEmptyFields, + bool? runtimeFeatureEnabled, + }) { + return MetadataPreferencesState( + preferenceEnabled: preferenceEnabled ?? this.preferenceEnabled, + autoFetchBarcodeEnabled: + autoFetchBarcodeEnabled ?? this.autoFetchBarcodeEnabled, + fillOnlyEmptyFields: fillOnlyEmptyFields ?? this.fillOnlyEmptyFields, + runtimeFeatureEnabled: + runtimeFeatureEnabled ?? this.runtimeFeatureEnabled, + ); + } +} + +final metadataPreferencesProvider = + NotifierProvider( + MetadataPreferencesController.new, + ); + +class MetadataPreferencesController extends Notifier { + static const _enabledKey = 'metadata_feature_enabled'; + static const _autoFetchBarcodeKey = 'metadata_auto_fetch_barcode'; + static const _fillEmptyOnlyKey = 'metadata_fill_empty_only'; + + late final PrefsStorageService _prefs; + + @override + MetadataPreferencesState build() { + _prefs = PrefsStorageService.instance; + + ref.listen(firebaseRuntimeConfigProvider, ( + previous, + next, + ) { + if (previous?.metadataFeatureEnabled == next.metadataFeatureEnabled) { + return; + } + state = state.copyWith( + runtimeFeatureEnabled: next.metadataFeatureEnabled, + ); + }); + + return MetadataPreferencesState( + preferenceEnabled: _prefs.readSync(_enabledKey) ?? true, + autoFetchBarcodeEnabled: + _prefs.readSync(_autoFetchBarcodeKey) ?? true, + fillOnlyEmptyFields: _prefs.readSync(_fillEmptyOnlyKey) ?? true, + runtimeFeatureEnabled: ref + .read(firebaseRuntimeConfigProvider) + .metadataFeatureEnabled, + ); + } + + Future setPreferenceEnabled(bool enabled) async { + await _prefs.save(_enabledKey, enabled); + state = state.copyWith(preferenceEnabled: enabled); + } + + Future setAutoFetchBarcodeEnabled(bool enabled) async { + await _prefs.save(_autoFetchBarcodeKey, enabled); + state = state.copyWith(autoFetchBarcodeEnabled: enabled); + } + + Future setFillOnlyEmptyFields(bool enabled) async { + await _prefs.save(_fillEmptyOnlyKey, enabled); + state = state.copyWith(fillOnlyEmptyFields: enabled); + } +} diff --git a/apps/mobile/lib/core/providers/metadata_providers.dart b/apps/mobile/lib/core/providers/metadata_providers.dart index 508acb7..ca00f7f 100644 --- a/apps/mobile/lib/core/providers/metadata_providers.dart +++ b/apps/mobile/lib/core/providers/metadata_providers.dart @@ -1,169 +1,24 @@ import 'package:common_env/common_env.dart'; -import 'package:flutter/foundation.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:dio/dio.dart'; -import 'package:metadata_api/metadata_api.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:storage/storage.dart'; -part 'metadata_providers.g.dart'; +import '../metadata/metadata_lookup_service.dart'; -@riverpod -Dio metadataDio(Ref ref) { - return Dio( - BaseOptions( - connectTimeout: const Duration(seconds: 10), - receiveTimeout: const Duration(seconds: 10), - headers: {'Accept': 'application/json'}, - ), - ) - ..interceptors.addAll([ - LogInterceptor( - requestBody: true, - responseBody: true, - error: true, - logPrint: (obj) { - // Use your logging package here - // logger.debug(obj.toString()); - debugPrint(obj.toString()); - }, - ), - ]); -} - -@riverpod -SecureStorageService secureStorageService(Ref ref) { +final metadataSecureStorageProvider = Provider((ref) { return SecureStorageService.instance; -} - -// ============================================================ -// API Keys Providers -// These should be loaded from secure storage or environment -// ============================================================ - -@riverpod -String googleBooksApiKey(Ref ref) { - return AppEnv.googleBooksApiKey; -} - -@riverpod -Future igdbClientId(Ref ref) async { - return AppEnv.igdbClientId; -} - -@riverpod -Future igdbAccessToken(Ref ref) async { - final secureStorage = ref.watch(secureStorageServiceProvider); - final clientIdFuture = ref.watch(igdbClientIdProvider.future); - final dio = ref.watch(metadataDioProvider); - - final clientId = await clientIdFuture; - final clientSecret = AppEnv.igdbClientSecret; - - if (clientId.isEmpty || clientSecret.isEmpty) { - return null; - } - - const tokenKey = 'igdb_access_token'; - - // Try to load cached token - final cachedToken = await secureStorage.get(tokenKey); - if (cachedToken != null) { - return cachedToken; - } - - final result = await IGDBAuth.getAccessToken( - clientId: clientId, - clientSecret: clientSecret, - dio: dio, - ); - - return result.fold( - (exception) { - debugPrint('IGDB Auth Error: $exception'); - return null; - }, - (token) async { - // Cache the new token - await secureStorage.save(tokenKey, token); - return token; - }, - ); -} - -@riverpod -String tmdbApiKey(Ref ref) { - return AppEnv.tmdbReadAccessToken; -} - -// ============================================================ -// Client Providers -// ============================================================ - -@riverpod -GoogleBooksClient googleBooksClient(Ref ref) { - final apiKey = ref.watch(googleBooksApiKeyProvider); - final dio = ref.watch(metadataDioProvider); - - return GoogleBooksClient(apiKey: apiKey.isEmpty ? null : apiKey, dio: dio); -} - -@riverpod -Future igdbClient(Ref ref) async { - final clientIdFuture = ref.watch(igdbClientIdProvider.future); - final accessTokenFuture = ref.watch(igdbAccessTokenProvider.future); - final dio = ref.watch(metadataDioProvider); - - final clientId = await clientIdFuture; - final accessToken = await accessTokenFuture; - - if (clientId.isEmpty || accessToken == null) { - return null; - } - - return IGDBClient(clientId: clientId, accessToken: accessToken, dio: dio); -} - -@riverpod -TMDBClient? tmdbClient(Ref ref) { - final apiKey = ref.watch(tmdbApiKeyProvider); - final dio = ref.watch(metadataDioProvider); - - if (apiKey.isEmpty) { - return null; - } - - return TMDBClient(apiKey: apiKey, dio: dio); -} - -// ============================================================ -// Unified Metadata Service Provider -// ============================================================ - -@riverpod -class MetadataService extends _$MetadataService { - @override - FutureOr build() async { - // Initialize service - } - - GoogleBooksClient get booksClient => ref.read(googleBooksClientProvider); - - Future get gamesClient => ref.read(igdbClientProvider.future); - - TMDBClient? get moviesClient => ref.read(tmdbClientProvider); -} - -@riverpod -Future unifiedMetadataService(Ref ref) async { - return UnifiedMetadataService( - booksClient: () => ref.read(googleBooksClientProvider), - gamesClient: () => ref.read(igdbClientProvider.future), - moviesClient: () => ref.read(tmdbClientProvider), +}); + +final metadataApiConfigProvider = Provider((ref) { + return MetadataApiConfig( + googleBooksApiKey: AppEnv.googleBooksApiKey, + igdbClientId: AppEnv.igdbClientId, + igdbClientSecret: AppEnv.igdbClientSecret, + tmdbReadAccessToken: AppEnv.tmdbReadAccessToken, ); -} +}); -@riverpod -Future smartMetadataMatcher(Ref ref) async { - final service = await ref.watch(unifiedMetadataServiceProvider.future); - return SmartMetadataMatcher(service); -} +final metadataLookupServiceProvider = Provider((ref) { + final config = ref.watch(metadataApiConfigProvider); + final secureStorage = ref.watch(metadataSecureStorageProvider); + return MetadataLookupService(config: config, secureStorage: secureStorage); +}); diff --git a/apps/mobile/lib/core/providers/providers.dart b/apps/mobile/lib/core/providers/providers.dart index b8512ee..eb3e1de 100644 --- a/apps/mobile/lib/core/providers/providers.dart +++ b/apps/mobile/lib/core/providers/providers.dart @@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; export 'data_providers.dart'; export 'database_providers.dart'; +export 'app_info_provider.dart'; export 'analytics_preferences_provider.dart'; export 'auth_session_providers.dart'; export 'backend_api_providers.dart'; @@ -13,5 +14,6 @@ export 'collections_view_mode_provider.dart'; export 'locale_provider.dart'; export 'push_notifications_provider.dart'; export 'sync_providers.dart'; +export 'metadata_preferences_provider.dart'; final onboardingCompleteProvider = Provider((ref) => false); diff --git a/apps/mobile/lib/core/providers/sync_providers.dart b/apps/mobile/lib/core/providers/sync_providers.dart index dca23c6..3fe1d4a 100644 --- a/apps/mobile/lib/core/providers/sync_providers.dart +++ b/apps/mobile/lib/core/providers/sync_providers.dart @@ -253,10 +253,12 @@ final syncOutboxBootstrapperProvider = Provider((ref) { final syncDao = ref.watch(syncDaoProvider); final collectionDao = ref.watch(collectionDaoProvider); final itemDao = ref.watch(itemDaoProvider); + final loanDao = ref.watch(loanDaoProvider); return SyncOutboxBootstrapper( syncDao: syncDao, collectionDao: collectionDao, itemDao: itemDao, + loanDao: loanDao, ); }); diff --git a/apps/mobile/lib/core/router/app_router.dart b/apps/mobile/lib/core/router/app_router.dart index 52f9743..38f1aa8 100644 --- a/apps/mobile/lib/core/router/app_router.dart +++ b/apps/mobile/lib/core/router/app_router.dart @@ -1,5 +1,6 @@ import 'package:collection_tracker/core/providers/providers.dart'; import 'package:collection_tracker/core/analytics/analytics_consent_gate.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -16,11 +17,16 @@ import '../../features/items/presentation/views/tag_items_screen.dart'; import '../../features/onboarding/presentation/views/onboarding_screen.dart'; import '../../features/scanner/presentation/views/scanner_screen.dart'; import '../../features/search/presentation/views/search_screen.dart'; +import '../../features/app_update/presentation/views/settings_app_update_screen.dart'; +import '../../features/app_update/presentation/widgets/app_update_gate.dart'; import '../../features/settings/presentation/views/settings_screen.dart'; import '../../features/settings/presentation/views/settings_devtools_screen.dart'; +import '../../features/settings/presentation/views/settings_data_transfer_screen.dart'; +import '../../features/settings/presentation/views/settings_metadata_screen.dart'; import '../../features/settings/presentation/views/settings_notifications_screen.dart'; import '../../features/statistics/presentation/views/statistics_screen.dart'; import '../../features/items/presentation/views/tag_management_screen.dart'; +import '../../features/loans/presentation/views/loan_tracking_screen.dart'; import 'app_shell.dart'; import 'package:collection_tracker/core/observers/analytics_observer.dart'; import 'routes.dart'; @@ -28,17 +34,31 @@ import '../../features/items/presentation/views/global_items_screen.dart'; part 'app_router.g.dart'; -final _rootNavigatorKey = GlobalKey(); +final rootNavigatorKey = GlobalKey(); @riverpod GoRouter appRouter(Ref ref) { final onboardingComplete = ref.watch(onboardingCompleteProvider); + final authFeatureEnabled = ref.watch(backendAuthFeatureFlagProvider); Widget withAnalyticsConsent(Widget child) => AnalyticsConsentGate(child: child); return GoRouter( - navigatorKey: _rootNavigatorKey, + navigatorKey: rootNavigatorKey, observers: [AnalyticsObserver()], + redirect: (context, state) { + final path = state.uri.path; + + if (path == Routes.auth && !authFeatureEnabled) { + return Routes.settings; + } + + if (!kDebugMode && path == Routes.settingsDevtools) { + return Routes.settings; + } + + return null; + }, initialLocation: onboardingComplete ? Routes.collections : Routes.onboarding, @@ -52,7 +72,7 @@ GoRouter appRouter(Ref ref) { GoRoute( path: Routes.auth, name: 'auth', - parentNavigatorKey: _rootNavigatorKey, + parentNavigatorKey: rootNavigatorKey, builder: (context, state) { final mode = state.uri.queryParameters['mode']; final initialMode = mode == 'register' @@ -66,7 +86,7 @@ GoRouter appRouter(Ref ref) { StatefulShellRoute.indexedStack( builder: (context, state, navigationShell) { return withAnalyticsConsent( - AppShell(navigationShell: navigationShell), + AppUpdateGate(child: AppShell(navigationShell: navigationShell)), ); }, branches: [ @@ -80,7 +100,7 @@ GoRouter appRouter(Ref ref) { GoRoute( path: 'create', name: 'create-collection', - parentNavigatorKey: _rootNavigatorKey, + parentNavigatorKey: rootNavigatorKey, builder: (context, state) => const CreateCollectionScreen(), ), GoRoute( @@ -94,7 +114,7 @@ GoRouter appRouter(Ref ref) { GoRoute( path: 'edit', name: 'edit-collection', - parentNavigatorKey: _rootNavigatorKey, + parentNavigatorKey: rootNavigatorKey, builder: (context, state) { final id = state.pathParameters['id']!; return EditCollectionScreen(collectionId: id); @@ -103,7 +123,7 @@ GoRouter appRouter(Ref ref) { GoRoute( path: 'search', name: 'search', - parentNavigatorKey: _rootNavigatorKey, + parentNavigatorKey: rootNavigatorKey, builder: (context, state) { final id = state.pathParameters['id']!; return SearchScreen(collectionId: id); @@ -112,7 +132,7 @@ GoRouter appRouter(Ref ref) { GoRoute( path: 'add-item', name: 'add-item', - parentNavigatorKey: _rootNavigatorKey, + parentNavigatorKey: rootNavigatorKey, builder: (context, state) { final id = state.pathParameters['id']!; return AddItemScreen(collectionId: id); @@ -154,21 +174,46 @@ GoRouter appRouter(Ref ref) { GoRoute( path: 'tags', name: 'manage-tags', - parentNavigatorKey: _rootNavigatorKey, + parentNavigatorKey: rootNavigatorKey, builder: (_, _) => const TagManagementScreen(), ), + GoRoute( + path: 'data-transfer', + name: 'settings-data-transfer', + parentNavigatorKey: rootNavigatorKey, + builder: (_, _) => const SettingsDataTransferScreen(), + ), GoRoute( path: 'notifications', name: 'settings-notifications', - parentNavigatorKey: _rootNavigatorKey, + parentNavigatorKey: rootNavigatorKey, builder: (_, _) => const SettingsNotificationsScreen(), ), GoRoute( - path: 'devtools', - name: 'settings-devtools', - parentNavigatorKey: _rootNavigatorKey, - builder: (_, _) => const SettingsDevToolsScreen(), + path: 'metadata', + name: 'settings-metadata', + parentNavigatorKey: rootNavigatorKey, + builder: (_, _) => const SettingsMetadataScreen(), + ), + GoRoute( + path: 'app-update', + name: 'settings-app-update', + parentNavigatorKey: rootNavigatorKey, + builder: (_, _) => const SettingsAppUpdateScreen(), + ), + GoRoute( + path: 'loans', + name: 'settings-loans', + parentNavigatorKey: rootNavigatorKey, + builder: (_, _) => const LoanTrackingScreen(), ), + if (kDebugMode) + GoRoute( + path: 'devtools', + name: 'settings-devtools', + parentNavigatorKey: rootNavigatorKey, + builder: (_, _) => const SettingsDevToolsScreen(), + ), ], ), ], diff --git a/apps/mobile/lib/core/router/routes.dart b/apps/mobile/lib/core/router/routes.dart index 4e74f1b..b597451 100644 --- a/apps/mobile/lib/core/router/routes.dart +++ b/apps/mobile/lib/core/router/routes.dart @@ -8,8 +8,12 @@ abstract final class Routes { static const scanner = '/scanner'; static const statistics = '/statistics'; static const settings = '/settings'; + static const settingsDataTransfer = '/settings/data-transfer'; static const settingsTags = '/settings/tags'; + static const settingsLoans = '/settings/loans'; + static const settingsMetadata = '/settings/metadata'; static const settingsNotifications = '/settings/notifications'; + static const settingsAppUpdate = '/settings/app-update'; static const settingsDevtools = '/settings/devtools'; static const tagItems = '/tags/items'; diff --git a/apps/mobile/lib/core/sync/sync_auto_retry_on_resume.dart b/apps/mobile/lib/core/sync/sync_auto_retry_on_resume.dart index 745a3c3..3126fe9 100644 --- a/apps/mobile/lib/core/sync/sync_auto_retry_on_resume.dart +++ b/apps/mobile/lib/core/sync/sync_auto_retry_on_resume.dart @@ -98,13 +98,16 @@ class _SyncAutoRetryOnResumeState extends ConsumerState syncedCollections: result.syncedCollections, syncedItems: result.syncedItems, syncedTags: result.syncedTags, + syncedLoans: result.syncedLoans, conflictCount: result.conflictCount, appliedServerCollections: result.appliedServerCollections, appliedServerItems: result.appliedServerItems, appliedServerTags: result.appliedServerTags, + appliedServerLoans: result.appliedServerLoans, skippedServerCollections: result.skippedServerCollections, skippedServerItems: result.skippedServerItems, skippedServerTags: result.skippedServerTags, + skippedServerLoans: result.skippedServerLoans, message: result.message, error: result.error, stackTrace: result.stackTrace, diff --git a/apps/mobile/lib/core/sync/sync_orchestrator.dart b/apps/mobile/lib/core/sync/sync_orchestrator.dart index dc7701a..64a553b 100644 --- a/apps/mobile/lib/core/sync/sync_orchestrator.dart +++ b/apps/mobile/lib/core/sync/sync_orchestrator.dart @@ -16,7 +16,7 @@ typedef SyncTraceRunner = Map? attributes, }); -enum SyncEntityType { collection, item, tag } +enum SyncEntityType { collection, item, tag, loan } enum SyncOperationType { upsert, delete } @@ -32,14 +32,17 @@ class SyncAttemptResult { this.syncedCollections = 0, this.syncedItems = 0, this.syncedTags = 0, + this.syncedLoans = 0, this.conflictCount = 0, this.partial = false, this.appliedServerCollections = 0, this.appliedServerItems = 0, this.appliedServerTags = 0, + this.appliedServerLoans = 0, this.skippedServerCollections = 0, this.skippedServerItems = 0, this.skippedServerTags = 0, + this.skippedServerLoans = 0, }); final bool executed; @@ -52,14 +55,17 @@ class SyncAttemptResult { final int syncedCollections; final int syncedItems; final int syncedTags; + final int syncedLoans; final int conflictCount; final bool partial; final int appliedServerCollections; final int appliedServerItems; final int appliedServerTags; + final int appliedServerLoans; final int skippedServerCollections; final int skippedServerItems; final int skippedServerTags; + final int skippedServerLoans; } class SyncOrchestrator { @@ -141,6 +147,13 @@ class SyncOrchestrator { bool forceFullSync = false, }) async { final pending = await _syncDao.getPendingOperations(limit: _maxBatchSize); + final capability = await _evaluateCapabilities(); + final operationsToSync = capability.loansSupported + ? pending + : pending + .where((op) => op.entityType != SyncEntityType.loan.name) + .toList(growable: false); + final deferredLoanOperationCount = pending.length - operationsToSync.length; if (_backendClient is NoopSyncBackendClient) { final client = _backendClient; @@ -157,9 +170,10 @@ class SyncOrchestrator { await _syncDao.upsertSyncState(lastAttemptedSyncAt: DateTime.now()); - final changes = _buildChangesPayload(pending); + final changes = _buildChangesPayload(operationsToSync); final requestPayload = SyncRequestPayload( deviceId: deviceId, + schemaVersion: capability.schemaVersion, clientRequestId: _uuid.v4(), lastSyncAt: forceFullSync ? null : state?.lastSuccessfulSyncAt, changes: changes.isEmpty ? null : changes, @@ -168,17 +182,17 @@ class SyncOrchestrator { try { final response = await _syncWithRetry( request: requestPayload, - pendingOperationCount: pending.length, + pendingOperationCount: operationsToSync.length, forceFullSync: forceFullSync, ); final processedOperations = _processedOperationCount(response); - if (processedOperations < pending.length) { + if (processedOperations < operationsToSync.length) { final errorText = 'Sync response did not process all operations ' - '(processed: $processedOperations, pending: ${pending.length}).'; + '(processed: $processedOperations, pending: ${operationsToSync.length}).'; - for (final op in pending) { + for (final op in operationsToSync) { await _syncDao.markOperationFailed(op.id, errorText); } @@ -192,7 +206,7 @@ class SyncOrchestrator { success: false, message: 'Sync partially processed. Local queue kept for retry. ' - 'Processed $processedOperations of ${pending.length} change(s).', + 'Processed $processedOperations of ${operationsToSync.length} change(s).', error: errorText, stackTrace: null, pendingOperations: pending.length, @@ -200,6 +214,7 @@ class SyncOrchestrator { syncedCollections: response.syncedCollections, syncedItems: response.syncedItems, syncedTags: response.syncedTags, + syncedLoans: response.syncedLoans, conflictCount: response.conflicts.length, partial: true, ); @@ -209,7 +224,7 @@ class SyncOrchestrator { response.serverChanges, ); - for (final op in pending) { + for (final op in operationsToSync) { await _syncDao.markOperationSynced(op.id); } @@ -224,21 +239,26 @@ class SyncOrchestrator { success: true, message: 'Sync completed: ${response.syncedCollections} collections, ' - '${response.syncedItems} items, ${response.syncedTags} tags. ' - 'Applied ${applyResult.appliedTotal} remote change(s).', + '${response.syncedItems} items, ${response.syncedTags} tags, ' + '${response.syncedLoans} loans. ' + 'Applied ${applyResult.appliedTotal} remote change(s).' + '${deferredLoanOperationCount > 0 ? ' Deferred $deferredLoanOperationCount loan change(s) until backend loan sync support is enabled.' : ''}', pendingOperations: pending.length, processedOperations: processedOperations, syncedCollections: response.syncedCollections, syncedItems: response.syncedItems, syncedTags: response.syncedTags, + syncedLoans: response.syncedLoans, conflictCount: response.conflicts.length, partial: false, appliedServerCollections: applyResult.appliedCollections, appliedServerItems: applyResult.appliedItems, appliedServerTags: applyResult.appliedTags, + appliedServerLoans: applyResult.appliedLoans, skippedServerCollections: applyResult.skippedCollections, skippedServerItems: applyResult.skippedItems, skippedServerTags: applyResult.skippedTags, + skippedServerLoans: applyResult.skippedLoans, ); } on SyncAuthRequiredException catch (error) { await _syncDao.upsertSyncState(clearNextRetryAt: true); @@ -253,7 +273,7 @@ class SyncOrchestrator { ); } on DioException catch (error, stackTrace) { final errorText = _buildDioErrorMessage(error); - for (final op in pending) { + for (final op in operationsToSync) { await _syncDao.markOperationFailed(op.id, errorText); } @@ -278,7 +298,7 @@ class SyncOrchestrator { ); } catch (error, stackTrace) { final errorText = '$error'; - for (final op in pending) { + for (final op in operationsToSync) { await _syncDao.markOperationFailed(op.id, errorText); } @@ -343,6 +363,7 @@ class SyncOrchestrator { final collections = >[]; final items = >[]; final tags = >[]; + final loans = >[]; for (final op in pending) { final decoded = jsonDecode(op.payload); @@ -357,6 +378,8 @@ class SyncOrchestrator { items.add(payload); } else if (op.entityType == SyncEntityType.tag.name) { tags.add(payload); + } else if (op.entityType == SyncEntityType.loan.name) { + loans.add(payload); } } @@ -364,6 +387,7 @@ class SyncOrchestrator { collections: collections, items: items, tags: tags, + loans: loans, ); } @@ -371,9 +395,43 @@ class SyncOrchestrator { return response.syncedCollections + response.syncedItems + response.syncedTags + + response.syncedLoans + response.conflicts.length; } + Future<_SyncCapabilityEvaluation> _evaluateCapabilities() async { + var schemaVersion = 'v1'; + var loansSupported = false; + + try { + final capabilities = await _backendClient.getCapabilities(); + final acceptedVersions = capabilities.acceptedSchemaVersions + .map((version) => version.trim().toLowerCase()) + .toSet(); + if (acceptedVersions.contains('v2') || + acceptedVersions.contains('v2-loans')) { + schemaVersion = 'v2'; + } + + final supportedEntities = capabilities.supportedEntities + .map((entity) => entity.trim().toLowerCase()) + .toSet(); + if (supportedEntities.contains('loan') || + supportedEntities.contains('loans')) { + loansSupported = true; + } else if (schemaVersion == 'v2') { + loansSupported = true; + } + } catch (_) { + // Treat capabilities as unknown and stay with safest defaults. + } + + return _SyncCapabilityEvaluation( + schemaVersion: schemaVersion, + loansSupported: loansSupported, + ); + } + Future _syncWithRetry({ required SyncRequestPayload request, required int pendingOperationCount, @@ -469,3 +527,13 @@ class SyncOrchestrator { ); } } + +class _SyncCapabilityEvaluation { + const _SyncCapabilityEvaluation({ + required this.schemaVersion, + required this.loansSupported, + }); + + final String schemaVersion; + final bool loansSupported; +} diff --git a/apps/mobile/lib/core/sync/sync_outbox_bootstrapper.dart b/apps/mobile/lib/core/sync/sync_outbox_bootstrapper.dart index 5ed0ee4..24dd471 100644 --- a/apps/mobile/lib/core/sync/sync_outbox_bootstrapper.dart +++ b/apps/mobile/lib/core/sync/sync_outbox_bootstrapper.dart @@ -8,16 +8,18 @@ class SyncOutboxBootstrapResult { required this.collectionOperations, required this.itemOperations, required this.tagOperations, + required this.loanOperations, required this.skipped, }); final int collectionOperations; final int itemOperations; final int tagOperations; + final int loanOperations; final bool skipped; int get totalOperations => - collectionOperations + itemOperations + tagOperations; + collectionOperations + itemOperations + tagOperations + loanOperations; } class SyncOutboxBootstrapper { @@ -29,13 +31,16 @@ class SyncOutboxBootstrapper { required SyncDao syncDao, required CollectionDao collectionDao, required ItemDao itemDao, + required LoanDao loanDao, }) : _syncDao = syncDao, _collectionDao = collectionDao, - _itemDao = itemDao; + _itemDao = itemDao, + _loanDao = loanDao; final SyncDao _syncDao; final CollectionDao _collectionDao; final ItemDao _itemDao; + final LoanDao _loanDao; Future seedFromLocalDataIfNeeded() async { final pending = await _syncDao.getPendingOperations(limit: 1); @@ -44,6 +49,7 @@ class SyncOutboxBootstrapper { collectionOperations: 0, itemOperations: 0, tagOperations: 0, + loanOperations: 0, skipped: true, ); } @@ -60,6 +66,7 @@ class SyncOutboxBootstrapper { collectionOperations: 0, itemOperations: 0, tagOperations: 0, + loanOperations: 0, skipped: true, ); } @@ -94,6 +101,7 @@ class SyncOutboxBootstrapper { var collectionOperations = 0; var itemOperations = 0; var tagOperations = 0; + var loanOperations = 0; for (final tag in tags) { await _queueUpsert( @@ -132,10 +140,21 @@ class SyncOutboxBootstrapper { } } + final loans = await _loanDao.getAllLoans(); + for (final loan in loans) { + await _queueUpsert( + entityType: 'loan', + entityId: loan.id, + payload: _loanPayload(loan), + ); + loanOperations++; + } + return SyncOutboxBootstrapResult( collectionOperations: collectionOperations, itemOperations: itemOperations, tagOperations: tagOperations, + loanOperations: loanOperations, skipped: false, ); } @@ -222,4 +241,21 @@ class SyncOutboxBootstrapper { 'updatedAt': tag.updatedAt.toUtc().toIso8601String(), }; } + + Map _loanPayload(ItemLoanData loan) { + return { + 'id': loan.id, + 'itemId': loan.itemId, + 'borrowerName': loan.borrowerName, + 'borrowerContact': loan.borrowerContact, + 'notes': loan.notes, + 'loanedAt': loan.loanedAt.toUtc().toIso8601String(), + 'dueAt': loan.dueAt?.toUtc().toIso8601String(), + 'returnedAt': loan.returnedAt?.toUtc().toIso8601String(), + 'version': 1, + 'isDeleted': false, + 'createdAt': loan.createdAt.toUtc().toIso8601String(), + 'updatedAt': loan.updatedAt.toUtc().toIso8601String(), + }; + } } diff --git a/apps/mobile/lib/core/sync/sync_server_changes_applier.dart b/apps/mobile/lib/core/sync/sync_server_changes_applier.dart index 601541b..882c820 100644 --- a/apps/mobile/lib/core/sync/sync_server_changes_applier.dart +++ b/apps/mobile/lib/core/sync/sync_server_changes_applier.dart @@ -8,20 +8,26 @@ class SyncServerChangeApplyResult { required this.appliedCollections, required this.appliedItems, required this.appliedTags, + required this.appliedLoans, required this.skippedCollections, required this.skippedItems, required this.skippedTags, + required this.skippedLoans, }); final int appliedCollections; final int appliedItems; final int appliedTags; + final int appliedLoans; final int skippedCollections; final int skippedItems; final int skippedTags; + final int skippedLoans; - int get appliedTotal => appliedCollections + appliedItems + appliedTags; - int get skippedTotal => skippedCollections + skippedItems + skippedTags; + int get appliedTotal => + appliedCollections + appliedItems + appliedTags + appliedLoans; + int get skippedTotal => + skippedCollections + skippedItems + skippedTags + skippedLoans; } class SyncServerChangesApplier { @@ -38,9 +44,11 @@ class SyncServerChangesApplier { var appliedCollections = 0; var appliedItems = 0; var appliedTags = 0; + var appliedLoans = 0; var skippedCollections = 0; var skippedItems = 0; var skippedTags = 0; + var skippedLoans = 0; final affectedCollectionIds = {}; await _database.transaction(() async { @@ -297,6 +305,86 @@ class SyncServerChangesApplier { } } + for (final payload in changes.loans) { + final loanId = _asString(payload['id']); + if (loanId == null || loanId.isEmpty) { + skippedLoans++; + continue; + } + + if (await _hasPendingLocalOperation( + entityType: 'loan', + entityId: loanId, + )) { + skippedLoans++; + continue; + } + + final existingLoan = await (_database.select( + _database.itemLoans, + )..where((tbl) => tbl.id.equals(loanId))).getSingleOrNull(); + final serverUpdatedAt = _asDate(payload['updatedAt']); + if (_isServerPayloadOutdated( + localUpdatedAt: existingLoan?.updatedAt, + serverUpdatedAt: serverUpdatedAt, + )) { + skippedLoans++; + continue; + } + + if (_asBool(payload['isDeleted'])) { + await (_database.delete( + _database.itemLoans, + )..where((tbl) => tbl.id.equals(loanId))).go(); + appliedLoans++; + continue; + } + + final itemId = _asString(payload['itemId']); + final borrowerName = _asString(payload['borrowerName']); + final loanedAt = _asDate(payload['loanedAt']); + if (itemId == null || + itemId.isEmpty || + borrowerName == null || + borrowerName.trim().isEmpty || + loanedAt == null) { + skippedLoans++; + continue; + } + + final existingItem = await (_database.select( + _database.items, + )..where((tbl) => tbl.id.equals(itemId))).getSingleOrNull(); + if (existingItem == null) { + skippedLoans++; + continue; + } + + final now = DateTime.now().toUtc(); + await _database + .into(_database.itemLoans) + .insert( + ItemLoansCompanion( + id: Value(loanId), + itemId: Value(itemId), + borrowerName: Value(borrowerName), + borrowerContact: Value(_asString(payload['borrowerContact'])), + notes: Value(_asString(payload['notes'])), + loanedAt: Value(loanedAt), + dueAt: Value(_asDate(payload['dueAt'])), + returnedAt: Value(_asDate(payload['returnedAt'])), + createdAt: Value( + _asDate(payload['createdAt']) ?? + existingLoan?.createdAt ?? + now, + ), + updatedAt: Value(_asDate(payload['updatedAt']) ?? now), + ), + mode: InsertMode.insertOrReplace, + ); + appliedLoans++; + } + for (final collectionId in affectedCollectionIds) { final countRow = await _database .customSelect( @@ -322,9 +410,11 @@ class SyncServerChangesApplier { appliedCollections: appliedCollections, appliedItems: appliedItems, appliedTags: appliedTags, + appliedLoans: appliedLoans, skippedCollections: skippedCollections, skippedItems: skippedItems, skippedTags: skippedTags, + skippedLoans: skippedLoans, ); } diff --git a/apps/mobile/lib/features/app_update/domain/app_update_models.dart b/apps/mobile/lib/features/app_update/domain/app_update_models.dart new file mode 100644 index 0000000..919911b --- /dev/null +++ b/apps/mobile/lib/features/app_update/domain/app_update_models.dart @@ -0,0 +1,117 @@ +enum AppUpdateStatus { + upToDate, + updateAvailable, + updateRequired, + deferred, + disabled, + notConfigured, + error, +} + +enum AppUpdateSource { backend, remoteConfig, none } + +class AppUpdateResult { + const AppUpdateResult({ + required this.status, + required this.source, + required this.checkedAt, + required this.currentVersion, + this.latestVersion, + this.minSupportedVersion, + this.title, + this.message, + this.storeUrl, + this.snoozeHours = 24, + this.errorMessage, + }); + + final AppUpdateStatus status; + final AppUpdateSource source; + final DateTime checkedAt; + final String currentVersion; + final String? latestVersion; + final String? minSupportedVersion; + final String? title; + final String? message; + final String? storeUrl; + final int snoozeHours; + final String? errorMessage; + + bool get hasUpdate => + status == AppUpdateStatus.updateAvailable || + status == AppUpdateStatus.updateRequired || + status == AppUpdateStatus.deferred; + + bool get isForceUpdate => status == AppUpdateStatus.updateRequired; + + bool get canSnooze => + status == AppUpdateStatus.updateAvailable || + status == AppUpdateStatus.deferred; + + bool get hasStoreUrl => storeUrl != null && storeUrl!.trim().isNotEmpty; + + String get signature { + final latest = latestVersion?.trim() ?? '-'; + final minimum = minSupportedVersion?.trim() ?? '-'; + return '$latest|$minimum|${status.name}'; + } + + AppUpdateResult copyWith({ + AppUpdateStatus? status, + AppUpdateSource? source, + DateTime? checkedAt, + String? currentVersion, + String? latestVersion, + String? minSupportedVersion, + String? title, + String? message, + String? storeUrl, + int? snoozeHours, + String? errorMessage, + }) { + return AppUpdateResult( + status: status ?? this.status, + source: source ?? this.source, + checkedAt: checkedAt ?? this.checkedAt, + currentVersion: currentVersion ?? this.currentVersion, + latestVersion: latestVersion ?? this.latestVersion, + minSupportedVersion: minSupportedVersion ?? this.minSupportedVersion, + title: title ?? this.title, + message: message ?? this.message, + storeUrl: storeUrl ?? this.storeUrl, + snoozeHours: snoozeHours ?? this.snoozeHours, + errorMessage: errorMessage ?? this.errorMessage, + ); + } +} + +class AppUpdateState { + const AppUpdateState({ + this.isChecking = false, + this.lastResult, + this.lastCheckAt, + this.errorMessage, + }); + + final bool isChecking; + final AppUpdateResult? lastResult; + final DateTime? lastCheckAt; + final String? errorMessage; + + AppUpdateState copyWith({ + bool? isChecking, + AppUpdateResult? lastResult, + DateTime? lastCheckAt, + String? errorMessage, + bool clearErrorMessage = false, + }) { + return AppUpdateState( + isChecking: isChecking ?? this.isChecking, + lastResult: lastResult ?? this.lastResult, + lastCheckAt: lastCheckAt ?? this.lastCheckAt, + errorMessage: clearErrorMessage + ? null + : (errorMessage ?? this.errorMessage), + ); + } +} diff --git a/apps/mobile/lib/features/app_update/presentation/providers/app_update_providers.dart b/apps/mobile/lib/features/app_update/presentation/providers/app_update_providers.dart new file mode 100644 index 0000000..1f3c1cf --- /dev/null +++ b/apps/mobile/lib/features/app_update/presentation/providers/app_update_providers.dart @@ -0,0 +1,596 @@ +import 'package:app_firebase/app_firebase.dart'; +import 'package:backend_api/backend_api.dart'; +import 'package:collection_tracker/core/providers/providers.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:storage/storage.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../domain/app_update_models.dart'; + +final appUpdateFeatureFlagProvider = Provider((ref) { + ref.watch(firebaseRuntimeConfigProvider); + return FirebaseRemoteConfigService.instance.getBool( + _keyFeatureEnabled, + fallback: true, + ); +}); + +final appUpdateControllerProvider = + NotifierProvider( + AppUpdateController.new, + ); + +final appUpdateSummaryProvider = Provider((ref) { + final enabled = ref.watch(appUpdateFeatureFlagProvider); + if (!enabled) { + return 'Disabled by runtime config'; + } + + final state = ref.watch(appUpdateControllerProvider); + if (state.isChecking) { + return 'Checking for updates...'; + } + + final result = state.lastResult; + if (result == null) { + return 'Tap to check'; + } + + return switch (result.status) { + AppUpdateStatus.upToDate => 'App is up to date', + AppUpdateStatus.updateAvailable => + 'Update available${_suffixVersion(result.latestVersion)}', + AppUpdateStatus.updateRequired => + 'Update required${_suffixVersion(result.minSupportedVersion ?? result.latestVersion)}', + AppUpdateStatus.deferred => 'Update deferred', + AppUpdateStatus.disabled => 'Disabled by runtime config', + AppUpdateStatus.notConfigured => 'Not configured', + AppUpdateStatus.error => 'Update check failed', + }; +}); + +class AppUpdateController extends Notifier { + static const _lastCheckAtKey = 'app_update_last_check_at_v1'; + static const _snoozeUntilKey = 'app_update_snooze_until_v1'; + + @override + AppUpdateState build() { + final lastCheck = _readDateSync(_lastCheckAtKey); + return AppUpdateState(lastCheckAt: lastCheck); + } + + Future checkNow({String trigger = 'manual'}) { + return _runCheck(userInitiated: true, trigger: trigger); + } + + Future checkIfDue({String trigger = 'auto'}) async { + if (!ref.read(onboardingCompleteProvider)) { + return null; + } + + final featureEnabled = ref.read(appUpdateFeatureFlagProvider); + if (!featureEnabled) { + final disabled = AppUpdateResult( + status: AppUpdateStatus.disabled, + source: AppUpdateSource.none, + checkedAt: DateTime.now().toUtc(), + currentVersion: ref.read(appSemanticVersionProvider) ?? '0.0.0', + ); + state = state.copyWith(lastResult: disabled, clearErrorMessage: true); + return disabled; + } + + final current = state.lastResult; + if (current != null && current.isForceUpdate) { + return current; + } + + final remoteConfig = FirebaseRemoteConfigService.instance; + final intervalHours = _clampHours( + remoteConfig.getInt(_keyCheckIntervalHours, fallback: 12), + fallback: 12, + min: 1, + max: 168, + ); + final now = DateTime.now().toUtc(); + final lastCheck = _readDateSync(_lastCheckAtKey); + if (lastCheck != null && + now.difference(lastCheck) < Duration(hours: intervalHours)) { + return null; + } + + return _runCheck(userInitiated: false, trigger: trigger); + } + + Future snoozeCurrent({int? hours}) async { + final result = state.lastResult; + if (result == null || !result.canSnooze) { + return; + } + + final effectiveHours = _clampHours( + hours ?? result.snoozeHours, + fallback: 24, + min: 1, + max: 720, + ); + final until = DateTime.now().toUtc().add(Duration(hours: effectiveHours)); + await PrefsStorageService.instance.save( + _snoozeUntilKey, + until.toIso8601String(), + ); + + state = state.copyWith( + lastResult: result.copyWith(status: AppUpdateStatus.deferred), + clearErrorMessage: true, + ); + } + + Future clearSnooze() async { + await PrefsStorageService.instance.delete(_snoozeUntilKey); + final current = state.lastResult; + if (current == null) { + return; + } + + if (current.status == AppUpdateStatus.deferred) { + state = state.copyWith( + lastResult: current.copyWith(status: AppUpdateStatus.updateAvailable), + clearErrorMessage: true, + ); + } + } + + Future openStore(AppUpdateResult result) async { + final storeUrl = result.storeUrl?.trim(); + if (storeUrl == null || storeUrl.isEmpty) { + return false; + } + + final uri = Uri.tryParse(storeUrl); + if (uri == null) { + return false; + } + + try { + return await launchUrl(uri, mode: LaunchMode.externalApplication); + } catch (_) { + return false; + } + } + + Future _runCheck({ + required bool userInitiated, + required String trigger, + }) async { + final currentVersion = ref.read(appSemanticVersionProvider) ?? '0.0.0'; + final currentBuildNumber = ref + .read(appPackageInfoProvider) + .asData + ?.value + .buildNumber + .trim(); + final locale = ref.read(localeSettingsProvider).code; + + state = state.copyWith(isChecking: true, clearErrorMessage: true); + + final now = DateTime.now().toUtc(); + + try { + final featureEnabled = ref.read(appUpdateFeatureFlagProvider); + if (!featureEnabled) { + final disabled = AppUpdateResult( + status: AppUpdateStatus.disabled, + source: AppUpdateSource.none, + checkedAt: now, + currentVersion: currentVersion, + ); + await _persistLastCheck(now); + state = state.copyWith( + isChecking: false, + lastResult: disabled, + lastCheckAt: now, + clearErrorMessage: true, + ); + return disabled; + } + + AppUpdateResult? fromBackend; + try { + fromBackend = await _checkFromBackend( + currentVersion: currentVersion, + currentBuildNumber: currentBuildNumber, + localeCode: locale, + ); + } catch (error) { + if (kDebugMode) { + debugPrint('[AppUpdate] Backend check failed: $error'); + } + } + + AppUpdateResult result; + if (fromBackend != null) { + result = fromBackend; + } else { + result = _checkFromRemoteConfig( + currentVersion: currentVersion, + checkedAt: now, + ); + } + + final snoozeUntil = _readDateSync(_snoozeUntilKey); + if (!userInitiated && + result.status == AppUpdateStatus.updateAvailable && + snoozeUntil != null && + snoozeUntil.isAfter(now)) { + result = result.copyWith(status: AppUpdateStatus.deferred); + } + + if (result.isForceUpdate) { + await clearSnooze(); + } + + await _persistLastCheck(now); + + state = state.copyWith( + isChecking: false, + lastResult: result.copyWith(checkedAt: now), + lastCheckAt: now, + clearErrorMessage: true, + ); + + return state.lastResult!; + } catch (error) { + final fallback = AppUpdateResult( + status: AppUpdateStatus.error, + source: AppUpdateSource.none, + checkedAt: now, + currentVersion: currentVersion, + errorMessage: error.toString(), + ); + await _persistLastCheck(now); + state = state.copyWith( + isChecking: false, + lastResult: fallback, + lastCheckAt: now, + errorMessage: error.toString(), + ); + return fallback; + } finally { + if (kDebugMode) { + debugPrint('[AppUpdate] Check completed (trigger: $trigger)'); + } + } + } + + Future _checkFromBackend({ + required String currentVersion, + required String? currentBuildNumber, + required String localeCode, + }) async { + final remoteConfig = FirebaseRemoteConfigService.instance; + final useBackend = remoteConfig.getBool(_keyUseBackend, fallback: true); + if (!useBackend) { + return null; + } + + final client = ref.read(backendAppUpdateClientProvider); + if (client == null) { + return null; + } + + final response = await client.check( + BackendAppUpdateCheckRequest( + platform: _platformCode, + currentVersion: currentVersion, + buildNumber: currentBuildNumber, + channel: _releaseChannel, + locale: localeCode, + ), + ); + + final fallbackStoreUrl = _storeUrlFromRemoteConfig(remoteConfig); + final resolvedStoreUrl = + _normalizeText(response.storeUrl) ?? fallbackStoreUrl; + final resolvedTitle = + _normalizeText(response.title) ?? + remoteConfig.getString(_keyTitle, fallback: ''); + final resolvedMessage = + _normalizeText(response.message) ?? + remoteConfig.getString(_keyMessage, fallback: ''); + final resolvedSnoozeHours = _clampHours( + response.snoozeHours ?? + remoteConfig.getInt(_keySnoozeHours, fallback: 24), + fallback: 24, + min: 1, + max: 720, + ); + + var status = _statusFromBackend(response.status); + + if (_isLowerVersion(currentVersion, response.minSupportedVersion)) { + status = AppUpdateStatus.updateRequired; + } else if (_isLowerVersion(currentVersion, response.latestVersion)) { + status ??= AppUpdateStatus.updateAvailable; + } else { + status ??= AppUpdateStatus.upToDate; + } + + return AppUpdateResult( + status: status, + source: AppUpdateSource.backend, + checkedAt: DateTime.now().toUtc(), + currentVersion: currentVersion, + latestVersion: _normalizeText(response.latestVersion), + minSupportedVersion: _normalizeText(response.minSupportedVersion), + title: _normalizeText(resolvedTitle), + message: _normalizeText(resolvedMessage), + storeUrl: _normalizeText(resolvedStoreUrl), + snoozeHours: resolvedSnoozeHours, + ); + } + + AppUpdateResult _checkFromRemoteConfig({ + required String currentVersion, + required DateTime checkedAt, + }) { + final remoteConfig = FirebaseRemoteConfigService.instance; + final latestVersionKey = _latestVersionKeyForPlatform(); + final minSupportedVersionKey = _minSupportedVersionKeyForPlatform(); + final latestVersion = _normalizeText( + remoteConfig.getString(latestVersionKey, fallback: ''), + ); + final minSupportedVersion = _normalizeText( + remoteConfig.getString(minSupportedVersionKey, fallback: ''), + ); + final forceMode = remoteConfig.getBool(_keyForceMode, fallback: false); + final title = _normalizeText( + remoteConfig.getString(_keyTitle, fallback: ''), + ); + final message = _normalizeText( + remoteConfig.getString(_keyMessage, fallback: ''), + ); + final storeUrl = _storeUrlFromRemoteConfig(remoteConfig); + final snoozeHours = _clampHours( + remoteConfig.getInt(_keySnoozeHours, fallback: 24), + fallback: 24, + min: 1, + max: 720, + ); + + if (latestVersion == null && minSupportedVersion == null) { + return AppUpdateResult( + status: AppUpdateStatus.notConfigured, + source: AppUpdateSource.remoteConfig, + checkedAt: checkedAt, + currentVersion: currentVersion, + title: title, + message: message, + storeUrl: storeUrl, + snoozeHours: snoozeHours, + ); + } + + if (_isLowerVersion(currentVersion, minSupportedVersion)) { + return AppUpdateResult( + status: AppUpdateStatus.updateRequired, + source: AppUpdateSource.remoteConfig, + checkedAt: checkedAt, + currentVersion: currentVersion, + latestVersion: latestVersion, + minSupportedVersion: minSupportedVersion, + title: title, + message: message, + storeUrl: storeUrl, + snoozeHours: snoozeHours, + ); + } + + if (_isLowerVersion(currentVersion, latestVersion)) { + return AppUpdateResult( + status: forceMode + ? AppUpdateStatus.updateRequired + : AppUpdateStatus.updateAvailable, + source: AppUpdateSource.remoteConfig, + checkedAt: checkedAt, + currentVersion: currentVersion, + latestVersion: latestVersion, + minSupportedVersion: minSupportedVersion, + title: title, + message: message, + storeUrl: storeUrl, + snoozeHours: snoozeHours, + ); + } + + return AppUpdateResult( + status: AppUpdateStatus.upToDate, + source: AppUpdateSource.remoteConfig, + checkedAt: checkedAt, + currentVersion: currentVersion, + latestVersion: latestVersion, + minSupportedVersion: minSupportedVersion, + title: title, + message: message, + storeUrl: storeUrl, + snoozeHours: snoozeHours, + ); + } + + Future _persistLastCheck(DateTime now) async { + await PrefsStorageService.instance.save( + _lastCheckAtKey, + now.toIso8601String(), + ); + } + + DateTime? _readDateSync(String key) { + final value = PrefsStorageService.instance.readSync(key); + if (value == null || value.trim().isEmpty) { + return null; + } + return DateTime.tryParse(value)?.toUtc(); + } +} + +String _suffixVersion(String? version) { + final normalized = _normalizeText(version); + if (normalized == null) { + return ''; + } + return ' ($normalized)'; +} + +String? _normalizeText(String? value) { + final normalized = value?.trim(); + if (normalized == null || normalized.isEmpty) { + return null; + } + return normalized; +} + +bool _isLowerVersion(String currentVersion, String? targetVersion) { + final normalizedTarget = _normalizeText(targetVersion); + if (normalizedTarget == null) { + return false; + } + return _compareVersion(currentVersion, normalizedTarget) < 0; +} + +int _compareVersion(String left, String right) { + final leftParts = _parseVersionParts(left); + final rightParts = _parseVersionParts(right); + final maxLength = leftParts.length > rightParts.length + ? leftParts.length + : rightParts.length; + + for (var i = 0; i < maxLength; i++) { + final leftValue = i < leftParts.length ? leftParts[i] : 0; + final rightValue = i < rightParts.length ? rightParts[i] : 0; + if (leftValue != rightValue) { + return leftValue.compareTo(rightValue); + } + } + + return 0; +} + +List _parseVersionParts(String raw) { + final cleaned = raw.split('+').first.trim(); + if (cleaned.isEmpty) { + return const [0]; + } + + final matches = RegExp(r'\d+') + .allMatches(cleaned) + .map((match) => int.tryParse(match.group(0) ?? '0') ?? 0) + .toList(growable: false); + if (matches.isEmpty) { + return const [0]; + } + return matches; +} + +AppUpdateStatus? _statusFromBackend(String status) { + final normalized = status.trim().toLowerCase(); + return switch (normalized) { + 'force' || 'required' || 'mandatory' => AppUpdateStatus.updateRequired, + 'soft' || 'optional' || 'recommended' => AppUpdateStatus.updateAvailable, + 'none' || + 'up_to_date' || + 'uptodate' || + 'no_update' => AppUpdateStatus.upToDate, + _ => null, + }; +} + +int _clampHours( + int value, { + required int fallback, + required int min, + required int max, +}) { + if (value <= 0) { + return fallback; + } + if (value < min) { + return min; + } + if (value > max) { + return max; + } + return value; +} + +String? _storeUrlFromRemoteConfig(FirebaseRemoteConfigService remoteConfig) { + final key = switch (_platformCode) { + 'android' => _keyStoreUrlAndroid, + 'ios' => _keyStoreUrlIos, + _ => '', + }; + + if (key.isNotEmpty) { + final fromConfig = _normalizeText( + remoteConfig.getString(key, fallback: ''), + ); + if (fromConfig != null) { + return fromConfig; + } + } + + return switch (_platformCode) { + 'android' => _normalizeText(_envAndroidStoreUrl), + 'ios' => _normalizeText(_envIosStoreUrl), + _ => null, + }; +} + +String get _platformCode => switch (defaultTargetPlatform) { + TargetPlatform.android => 'android', + TargetPlatform.iOS => 'ios', + TargetPlatform.macOS => 'macos', + TargetPlatform.windows => 'windows', + TargetPlatform.linux => 'linux', + TargetPlatform.fuchsia => 'fuchsia', +}; + +const _releaseChannel = String.fromEnvironment( + 'APP_RELEASE_CHANNEL', + defaultValue: 'production', +); + +const _envAndroidStoreUrl = String.fromEnvironment( + 'APP_UPDATE_STORE_URL_ANDROID', + defaultValue: '', +); +const _envIosStoreUrl = String.fromEnvironment( + 'APP_UPDATE_STORE_URL_IOS', + defaultValue: '', +); + +const _keyFeatureEnabled = 'app_update_feature_enabled'; +const _keyUseBackend = 'app_update_use_backend'; +const _keyCheckIntervalHours = 'app_update_check_interval_hours'; +const _keySnoozeHours = 'app_update_snooze_hours'; +const _keyForceMode = 'app_update_force_mode'; +const _keyTitle = 'app_update_title'; +const _keyMessage = 'app_update_message'; +const _keyStoreUrlAndroid = 'app_update_store_url_android'; +const _keyStoreUrlIos = 'app_update_store_url_ios'; + +String _latestVersionKeyForPlatform() { + return switch (_platformCode) { + 'android' => 'app_update_latest_android', + 'ios' => 'app_update_latest_ios', + _ => 'app_update_latest', + }; +} + +String _minSupportedVersionKeyForPlatform() { + return switch (_platformCode) { + 'android' => 'app_update_min_supported_android', + 'ios' => 'app_update_min_supported_ios', + _ => 'app_update_min_supported', + }; +} diff --git a/apps/mobile/lib/features/app_update/presentation/views/settings_app_update_screen.dart b/apps/mobile/lib/features/app_update/presentation/views/settings_app_update_screen.dart new file mode 100644 index 0000000..14c1918 --- /dev/null +++ b/apps/mobile/lib/features/app_update/presentation/views/settings_app_update_screen.dart @@ -0,0 +1,181 @@ +import 'package:collection_tracker/core/providers/providers.dart'; +import 'package:collection_tracker/l10n/l10n.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:ui/ui.dart'; + +import '../../domain/app_update_models.dart'; +import '../providers/app_update_providers.dart'; +import '../../../settings/presentation/widgets/settings_primitives.dart'; + +class SettingsAppUpdateScreen extends ConsumerWidget { + const SettingsAppUpdateScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(appUpdateControllerProvider); + final notifier = ref.read(appUpdateControllerProvider.notifier); + final runtimeEnabled = ref.watch(appUpdateFeatureFlagProvider); + final displayVersion = ref.watch(appDisplayVersionProvider); + final result = state.lastResult; + + return Scaffold( + appBar: AppBar(title: const Text('App Update')), + body: ListView( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.md, + AppSpacing.lg, + AppSpacing.xxl, + ), + children: [ + AppReveal( + child: AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + runtimeEnabled + ? 'Keep your app secure and up to date.' + : 'Update checks are disabled by runtime configuration.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + if (state.errorMessage != null) ...[ + const SizedBox(height: AppSpacing.sm), + Text( + state.errorMessage!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.error, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ), + ), + ), + const SizedBox(height: AppSpacing.lg), + AppReveal( + delay: AppMotion.stagger, + child: SettingsSection( + title: context.l10n.settingsSectionAbout, + children: [ + SettingsTile( + icon: Icons.info_outline_rounded, + title: context.l10n.settingsVersionTitle, + subtitle: displayVersion, + ), + SettingsTile( + icon: Icons.system_update_alt_rounded, + title: 'Update Status', + subtitle: _statusLabel(result), + ), + if (result?.latestVersion != null) + SettingsTile( + icon: Icons.new_releases_outlined, + title: 'Latest Version', + subtitle: result!.latestVersion, + ), + if (result?.minSupportedVersion != null) + SettingsTile( + icon: Icons.security_update_warning_rounded, + title: 'Minimum Supported', + subtitle: result!.minSupportedVersion, + ), + if (result?.storeUrl != null) + SettingsTile( + icon: Icons.storefront_outlined, + title: 'Store Link', + subtitle: result!.storeUrl, + ), + ], + ), + ), + const SizedBox(height: AppSpacing.lg), + AppReveal( + delay: AppMotion.stagger * 2, + child: AppButton( + label: state.isChecking + ? 'Checking...' + : context.l10n.actionRefresh, + onPressed: state.isChecking + ? null + : () async { + final checked = await notifier.checkNow(); + if (!context.mounted) { + return; + } + final statusMessage = _statusLabel(checked); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(statusMessage))); + }, + ), + ), + if (result != null && result.hasStoreUrl && result.hasUpdate) ...[ + const SizedBox(height: AppSpacing.sm), + AppReveal( + delay: AppMotion.stagger * 3, + child: AppButton( + label: result.isForceUpdate ? 'Update Required' : 'Update Now', + variant: result.isForceUpdate + ? AppButtonVariant.danger + : AppButtonVariant.primary, + onPressed: () async { + final opened = await notifier.openStore(result); + if (!context.mounted) { + return; + } + if (!opened) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Failed to open store link.'), + ), + ); + } + }, + ), + ), + ], + if (result != null && result.canSnooze && !result.isForceUpdate) ...[ + const SizedBox(height: AppSpacing.sm), + AppReveal( + delay: AppMotion.stagger * 4, + child: AppButton( + label: result.status == AppUpdateStatus.deferred + ? 'Clear Reminder' + : 'Remind Me Later', + variant: AppButtonVariant.secondary, + onPressed: () async { + if (result.status == AppUpdateStatus.deferred) { + await notifier.clearSnooze(); + } else { + await notifier.snoozeCurrent(hours: result.snoozeHours); + } + }, + ), + ), + ], + ], + ), + ); + } + + String _statusLabel(AppUpdateResult? result) { + if (result == null) { + return 'Tap refresh to check'; + } + + return switch (result.status) { + AppUpdateStatus.upToDate => 'App is up to date', + AppUpdateStatus.updateAvailable => 'Update available', + AppUpdateStatus.updateRequired => 'Update required', + AppUpdateStatus.deferred => 'Update deferred', + AppUpdateStatus.disabled => 'Disabled by runtime config', + AppUpdateStatus.notConfigured => 'No update policy configured', + AppUpdateStatus.error => 'Update check failed', + }; + } +} diff --git a/apps/mobile/lib/features/app_update/presentation/widgets/app_update_gate.dart b/apps/mobile/lib/features/app_update/presentation/widgets/app_update_gate.dart new file mode 100644 index 0000000..de33e1f --- /dev/null +++ b/apps/mobile/lib/features/app_update/presentation/widgets/app_update_gate.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:ui/ui.dart'; + +import '../../domain/app_update_models.dart'; +import '../providers/app_update_providers.dart'; + +class AppUpdateGate extends ConsumerStatefulWidget { + const AppUpdateGate({required this.child, super.key}); + + final Widget child; + + @override + ConsumerState createState() => _AppUpdateGateState(); +} + +class _AppUpdateGateState extends ConsumerState + with WidgetsBindingObserver { + bool _dialogInProgress = false; + String? _lastPromptedSignature; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + WidgetsBinding.instance.addPostFrameCallback((_) { + _checkAndMaybePrompt(trigger: 'startup'); + }); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _checkAndMaybePrompt(trigger: 'resume'); + } + } + + @override + Widget build(BuildContext context) { + return widget.child; + } + + Future _checkAndMaybePrompt({required String trigger}) async { + if (!mounted || _dialogInProgress) { + return; + } + + final result = await ref + .read(appUpdateControllerProvider.notifier) + .checkIfDue(trigger: trigger); + if (!mounted || result == null) { + return; + } + + if (result.status != AppUpdateStatus.updateAvailable && + result.status != AppUpdateStatus.updateRequired) { + return; + } + + if (_lastPromptedSignature == result.signature && + !result.isForceUpdate && + trigger == 'resume') { + return; + } + + _dialogInProgress = true; + _lastPromptedSignature = result.signature; + + try { + final action = await showAppDialog<_UpdateAction>( + context: context, + barrierDismissible: !result.isForceUpdate, + title: Text(_titleFor(result)), + content: Text(_messageFor(result)), + actions: [ + if (!result.isForceUpdate) + AppButton( + label: 'Later', + variant: AppButtonVariant.ghost, + onPressed: () => closeAppDialog(context, _UpdateAction.later), + ), + if (result.hasStoreUrl) + AppButton( + label: result.isForceUpdate ? 'Update Now' : 'Update', + onPressed: () => closeAppDialog(context, _UpdateAction.update), + ), + ], + ); + + if (!mounted) { + return; + } + + switch (action) { + case _UpdateAction.update: + await ref + .read(appUpdateControllerProvider.notifier) + .openStore(result); + case _UpdateAction.later: + if (!result.isForceUpdate) { + await ref + .read(appUpdateControllerProvider.notifier) + .snoozeCurrent(hours: result.snoozeHours); + } + case null: + if (!result.isForceUpdate) { + await ref + .read(appUpdateControllerProvider.notifier) + .snoozeCurrent(hours: result.snoozeHours); + } + } + } finally { + _dialogInProgress = false; + } + } + + String _titleFor(AppUpdateResult result) { + final custom = result.title?.trim(); + if (custom != null && custom.isNotEmpty) { + return custom; + } + return result.isForceUpdate ? 'Update Required' : 'Update Available'; + } + + String _messageFor(AppUpdateResult result) { + final custom = result.message?.trim(); + if (custom != null && custom.isNotEmpty) { + return custom; + } + + final latest = result.latestVersion?.trim(); + final minimum = result.minSupportedVersion?.trim(); + + if (result.isForceUpdate) { + if (minimum != null && minimum.isNotEmpty) { + return 'This version is no longer supported. Update to continue (minimum: $minimum).'; + } + return 'A newer app version is required to continue.'; + } + + if (latest != null && latest.isNotEmpty) { + return 'A newer version ($latest) is available with improvements and fixes.'; + } + return 'A newer app version is available.'; + } +} + +enum _UpdateAction { later, update } diff --git a/apps/mobile/lib/features/auth/presentation/views/auth_screen.dart b/apps/mobile/lib/features/auth/presentation/views/auth_screen.dart index 109b5ad..599c075 100644 --- a/apps/mobile/lib/features/auth/presentation/views/auth_screen.dart +++ b/apps/mobile/lib/features/auth/presentation/views/auth_screen.dart @@ -1,6 +1,8 @@ import 'package:auth_session/auth_session.dart'; import 'package:backend_api/backend_api.dart'; import 'package:collection_tracker/core/providers/providers.dart'; +import 'package:collection_tracker/core/router/routes.dart'; +import 'package:collection_tracker/l10n/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -51,15 +53,17 @@ class _AuthScreenState extends ConsumerState { @override Widget build(BuildContext context) { + final l10n = context.l10n; final sessionAsync = ref.watch(authSessionProvider); final session = sessionAsync.value; + final profileAsync = ref.watch(backendAuthProfileProvider); final service = ref.watch(backendAuthServiceProvider); final readiness = ref.watch(backendAuthReadinessProvider); final canAuthenticate = service != null; final isAuthenticated = session?.isAuthenticated ?? false; return Scaffold( - appBar: AppBar(title: const Text('Account')), + appBar: AppBar(title: Text(l10n.authTitleAccount)), body: SafeArea( child: ListView( padding: const EdgeInsets.fromLTRB( @@ -75,142 +79,183 @@ class _AuthScreenState extends ConsumerState { child: Center(child: CircularProgressIndicator()), ) else if (isAuthenticated) - AppReveal( - child: _AuthenticatedCard( - session: session!, - isSubmitting: _isSubmitting, - onSignOut: _handleSignOut, - onDone: () => context.pop(true), - ), + Column( + children: [ + AppReveal( + child: _AuthenticatedCard( + l10n: l10n, + session: session!, + profile: profileAsync.value, + isProfileLoading: profileAsync.isLoading, + isSubmitting: _isSubmitting, + onRequestAccountDeletion: _handleRequestAccountDeletion, + onSignOut: _handleSignOut, + onDone: () => _closeWithResult(true), + ), + ), + ], ) else if (!canAuthenticate) AppReveal( child: _AuthUnavailableCard( + l10n: l10n, message: readiness.message, - onClose: () => context.pop(false), + onClose: () => _closeWithResult(false), ), ) else - AppReveal( - child: AppCard( - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _isRegisterMode ? 'Create Account' : 'Sign In', - style: Theme.of(context).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.w700), - ), - const SizedBox(height: AppSpacing.sm), - Text( - 'Sign in to enable cloud sync features.', - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: AppSpacing.md), - Wrap( - spacing: AppSpacing.sm, + Column( + children: [ + AppReveal( + child: _AuthHeaderCard( + l10n: l10n, + isRegisterMode: _isRegisterMode, + ), + ), + const SizedBox(height: AppSpacing.md), + AppReveal( + delay: AppMotion.stagger, + child: AppCard( + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - ChoiceChip( - label: const Text('Sign in'), - selected: !_isRegisterMode, - onSelected: _isSubmitting - ? null - : (selected) { - if (!selected) return; - setState(() => _isRegisterMode = false); - }, + Text( + _isRegisterMode + ? l10n.authCreateAccountHeading + : l10n.authSignInHeading, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: AppSpacing.sm), + Text( + _isRegisterMode + ? l10n.authCreateAccountDescription + : l10n.authSignInDescription, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.md), + Wrap( + spacing: AppSpacing.sm, + children: [ + ChoiceChip( + label: Text(l10n.authSignInChoice), + selected: !_isRegisterMode, + onSelected: _isSubmitting + ? null + : (selected) { + if (!selected) return; + setState( + () => _isRegisterMode = false, + ); + }, + ), + ChoiceChip( + label: Text(l10n.authRegisterChoice), + selected: _isRegisterMode, + onSelected: _isSubmitting + ? null + : (selected) { + if (!selected) return; + setState( + () => _isRegisterMode = true, + ); + }, + ), + ], ), - ChoiceChip( - label: const Text('Register'), - selected: _isRegisterMode, - onSelected: _isSubmitting + const SizedBox(height: AppSpacing.md), + AppInput( + controller: _emailController, + labelText: l10n.authEmailLabel, + hintText: l10n.authEmailHint, + prefixIcon: const Icon(Icons.email_outlined), + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + validator: (value) { + final text = (value ?? '').trim(); + if (text.isEmpty) { + return l10n.authEmailRequiredError; + } + if (!text.contains('@')) { + return l10n.authEmailInvalidError; + } + return null; + }, + ), + const SizedBox(height: AppSpacing.sm), + AppInput( + controller: _passwordController, + labelText: l10n.authPasswordLabel, + hintText: _isRegisterMode + ? l10n.authPasswordHint + : null, + prefixIcon: const Icon(Icons.lock_outline), + keyboardType: TextInputType.visiblePassword, + obscureText: true, + autocorrect: false, + enableSuggestions: false, + smartDashesType: SmartDashesType.disabled, + smartQuotesType: SmartQuotesType.disabled, + textInputAction: _isRegisterMode + ? TextInputAction.next + : TextInputAction.done, + validator: (value) { + final text = value ?? ''; + if (text.isEmpty) { + return l10n.authPasswordRequiredError; + } + if (text.length < 8) { + return l10n.authPasswordLengthError; + } + if (!_passwordPolicyRegex.hasMatch(text)) { + return l10n.authPasswordPolicyError; + } + return null; + }, + ), + if (_isRegisterMode) ...[ + const SizedBox(height: AppSpacing.sm), + AppInput( + controller: _displayNameController, + labelText: l10n.authDisplayNameLabel, + hintText: l10n.authDisplayNameHint, + prefixIcon: const Icon( + Icons.person_outline_rounded, + ), + textInputAction: TextInputAction.done, + ), + ], + const SizedBox(height: AppSpacing.lg), + AppButton( + label: _isRegisterMode + ? l10n.authCreateAccountAction + : l10n.authSignInChoice, + isLoading: _isSubmitting, + onPressed: _isSubmitting ? null : _handleSubmit, + expand: true, + ), + const SizedBox(height: AppSpacing.sm), + AppButton( + label: l10n.authNotNowAction, + variant: AppButtonVariant.ghost, + onPressed: _isSubmitting ? null - : (selected) { - if (!selected) return; - setState(() => _isRegisterMode = true); - }, + : () => _closeWithResult(false), + expand: true, ), ], ), - const SizedBox(height: AppSpacing.md), - AppInput( - controller: _emailController, - labelText: 'Email', - keyboardType: TextInputType.emailAddress, - textInputAction: TextInputAction.next, - validator: (value) { - final text = (value ?? '').trim(); - if (text.isEmpty) { - return 'Email is required.'; - } - if (!text.contains('@')) { - return 'Enter a valid email.'; - } - return null; - }, - ), - const SizedBox(height: AppSpacing.sm), - AppInput( - controller: _passwordController, - labelText: 'Password', - keyboardType: TextInputType.visiblePassword, - obscureText: true, - autocorrect: false, - enableSuggestions: false, - smartDashesType: SmartDashesType.disabled, - smartQuotesType: SmartQuotesType.disabled, - textInputAction: _isRegisterMode - ? TextInputAction.next - : TextInputAction.done, - validator: (value) { - final text = value ?? ''; - if (text.isEmpty) { - return 'Password is required.'; - } - if (text.length < 8) { - return 'Password must be at least 8 characters.'; - } - if (!_passwordPolicyRegex.hasMatch(text)) { - return 'Password must include uppercase, lowercase, and number.'; - } - return null; - }, - ), - if (_isRegisterMode) ...[ - const SizedBox(height: AppSpacing.sm), - AppInput( - controller: _displayNameController, - labelText: 'Display Name (optional)', - textInputAction: TextInputAction.done, - ), - ], - const SizedBox(height: AppSpacing.lg), - AppButton( - label: _isRegisterMode ? 'Create account' : 'Sign in', - isLoading: _isSubmitting, - onPressed: _isSubmitting ? null : _handleSubmit, - expand: true, - ), - const SizedBox(height: AppSpacing.sm), - AppButton( - label: 'Not now', - variant: AppButtonVariant.ghost, - onPressed: _isSubmitting - ? null - : () => context.pop(false), - expand: true, - ), - ], + ), ), ), - ), + ], ), ], ), @@ -219,14 +264,13 @@ class _AuthScreenState extends ConsumerState { } Future _handleSubmit() async { + final l10n = context.l10n; final service = ref.read(backendAuthServiceProvider); if (service == null) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Authentication is currently unavailable.'), - ), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.authUnavailableMessage))); return; } @@ -254,16 +298,14 @@ class _AuthScreenState extends ConsumerState { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - _isRegisterMode - ? 'Account created and signed in.' - : 'Signed in successfully.', + _isRegisterMode ? l10n.authRegisterSuccess : l10n.authSignInSuccess, ), backgroundColor: Colors.green, ), ); if (widget.popOnSuccess && mounted) { - context.pop(true); + _closeWithResult(true); } } on BackendApiException catch (error) { if (!mounted) return; @@ -274,7 +316,7 @@ class _AuthScreenState extends ConsumerState { if (!mounted) return; ScaffoldMessenger.of( context, - ).showSnackBar(SnackBar(content: Text('Sign-in failed: $error'))); + ).showSnackBar(SnackBar(content: Text(l10n.authSignInFailed('$error')))); } finally { if (mounted) { setState(() => _isSubmitting = false); @@ -283,6 +325,7 @@ class _AuthScreenState extends ConsumerState { } Future _handleSignOut() async { + final l10n = context.l10n; setState(() => _isSubmitting = true); try { final service = ref.read(backendAuthServiceProvider); @@ -295,8 +338,8 @@ class _AuthScreenState extends ConsumerState { if (!mounted) return; ScaffoldMessenger.of( context, - ).showSnackBar(const SnackBar(content: Text('Signed out.'))); - context.pop(true); + ).showSnackBar(SnackBar(content: Text(l10n.authSignedOut))); + _closeWithResult(true); } finally { if (mounted) { setState(() => _isSubmitting = false); @@ -304,10 +347,184 @@ class _AuthScreenState extends ConsumerState { } } + Future _handleRequestAccountDeletion() async { + final l10n = context.l10n; + final acknowledged = await _showDeletionImpactDialog(); + if (acknowledged != true) { + return; + } + if (!mounted) { + return; + } + + final confirmed = await showAppDialog( + context: context, + title: Text(l10n.authFinalConfirmationTitle), + content: Text(l10n.authFinalConfirmationMessage), + actions: [ + AppButton( + label: l10n.authBackAction, + variant: AppButtonVariant.ghost, + onPressed: () => closeAppDialog(context, false), + ), + AppButton( + label: l10n.authSubmitRequestAction, + variant: AppButtonVariant.danger, + onPressed: () => closeAppDialog(context, true), + ), + ], + ); + + if (confirmed != true || !mounted) { + return; + } + + setState(() => _isSubmitting = true); + try { + final service = ref.read(backendAuthServiceProvider); + if (service == null) { + throw BackendApiException( + message: l10n.authUnavailableMessage, + code: 'AUTH_UNAVAILABLE', + ); + } + + await service.requestAccountDeletion( + reason: 'User requested deletion from mobile app', + ); + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.authDeletionRequestSubmitted), + backgroundColor: Colors.green, + ), + ); + _closeWithResult(true); + } on BackendApiException catch (error) { + if (!mounted) { + return; + } + final message = error.code == 'ACCOUNT_DELETION_ENDPOINT_NOT_FOUND' + ? l10n.authDeletionEndpointMissing + : _friendlyAuthError(error); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } finally { + if (mounted) { + setState(() => _isSubmitting = false); + } + } + } + + Future _showDeletionImpactDialog() { + final l10n = context.l10n; + final colorScheme = Theme.of(context).colorScheme; + final warningBackground = colorScheme.errorContainer.withValues( + alpha: colorScheme.brightness == Brightness.light ? 0.78 : 0.9, + ); + final warningBorder = colorScheme.error.withValues( + alpha: colorScheme.brightness == Brightness.light ? 0.35 : 0.48, + ); + final warningTitle = colorScheme.onErrorContainer; + final warningBody = colorScheme.onErrorContainer.withValues(alpha: 0.92); + + return showAppDialog( + context: context, + barrierDismissible: false, + title: Text(l10n.authDeletionImpactDialogTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.authDeletionImpactReviewPrompt, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.md), + AppCard( + padding: const EdgeInsets.all(AppSpacing.md), + color: warningBackground, + borderColor: warningBorder, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.warning_amber_rounded, + color: warningTitle, + size: 18, + ), + const SizedBox(width: AppSpacing.xs), + Text( + l10n.authIrreversibleRequestTitle, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: warningTitle, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + _DeletionImpactLine( + color: warningBody, + text: l10n.authImpactLineSessionRevoked, + ), + const SizedBox(height: AppSpacing.xs), + _DeletionImpactLine( + color: warningBody, + text: l10n.authImpactLineCloudDataDeleted, + ), + const SizedBox(height: AppSpacing.xs), + _DeletionImpactLine( + color: warningBody, + text: l10n.authImpactLineCannotRestore, + ), + ], + ), + ), + ], + ), + actions: [ + AppButton( + label: l10n.actionCancel, + variant: AppButtonVariant.ghost, + onPressed: () => closeAppDialog(context, false), + ), + AppButton( + label: l10n.authUnderstandAction, + variant: AppButtonVariant.danger, + onPressed: () => closeAppDialog(context, true), + ), + ], + ); + } + + void _closeWithResult(bool result) { + if (!mounted) { + return; + } + + if (Navigator.of(context).canPop()) { + context.pop(result); + return; + } + + context.go(Routes.settings); + } + String _friendlyAuthError(BackendApiException error) { + final l10n = context.l10n; final message = error.message.trim(); if (message.toLowerCase().contains('password must contain uppercase')) { - return '$message Use English keyboard letters and digits (A-Z, a-z, 0-9).'; + return '$message ${l10n.authPasswordPolicySuffix}'; } return message; } @@ -315,55 +532,274 @@ class _AuthScreenState extends ConsumerState { class _AuthenticatedCard extends StatelessWidget { const _AuthenticatedCard({ + required this.l10n, required this.session, + required this.profile, + required this.isProfileLoading, required this.isSubmitting, + required this.onRequestAccountDeletion, required this.onSignOut, required this.onDone, }); + final AppLocalizations l10n; final AuthSession session; + final BackendAuthUser? profile; + final bool isProfileLoading; final bool isSubmitting; + final Future Function() onRequestAccountDeletion; final Future Function() onSignOut; final VoidCallback onDone; @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final warningBackground = colorScheme.errorContainer.withValues( + alpha: colorScheme.brightness == Brightness.light ? 0.78 : 0.9, + ); + final warningBorder = colorScheme.error.withValues( + alpha: colorScheme.brightness == Brightness.light ? 0.35 : 0.48, + ); + final warningTitle = colorScheme.onErrorContainer; + final warningBody = colorScheme.onErrorContainer.withValues(alpha: 0.92); + final titleText = profile?.displayName?.trim(); + final subtitleText = profile?.email.trim(); + + return Column( + children: [ + AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar( + radius: 24, + backgroundColor: colorScheme.primaryContainer, + foregroundColor: colorScheme.onPrimaryContainer, + child: const Icon(Icons.person_rounded), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + (titleText == null || titleText.isEmpty) + ? l10n.authAccountConnected + : titleText, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 2), + Text( + (subtitleText == null || subtitleText.isEmpty) + ? l10n.authSignedInReadySubtitle + : subtitleText, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: 6, + ), + decoration: BoxDecoration( + color: colorScheme.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadii.pill), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.verified_rounded, + size: 14, + color: colorScheme.primary, + ), + const SizedBox(width: 4), + Text( + l10n.authActiveStatus, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ], + ), + if (isProfileLoading) ...[ + const SizedBox(height: AppSpacing.sm), + LinearProgressIndicator( + minHeight: 2, + borderRadius: BorderRadius.circular(AppRadii.pill), + ), + ], + ], + ), + ), + const SizedBox(height: AppSpacing.md), + AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.authSessionDetailsTitle, + style: Theme.of( + context, + ).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: AppSpacing.sm), + _MetaRow( + icon: Icons.badge_outlined, + label: l10n.authUserIdLabel, + value: session.userId ?? l10n.authUnknownValue, + ), + const SizedBox(height: AppSpacing.xs), + _MetaRow( + icon: Icons.smartphone_outlined, + label: l10n.authDeviceIdLabel, + value: session.deviceId ?? l10n.authUnknownValue, + ), + ], + ), + ), + const SizedBox(height: AppSpacing.md), + AppCard( + color: warningBackground, + borderColor: warningBorder, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.warning_amber_rounded, + color: warningTitle, + size: 18, + ), + const SizedBox(width: AppSpacing.xs), + Text( + l10n.authDeletionNoticeTitle, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: warningTitle, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + Text( + l10n.authDeletionNoticeSubtitle, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: warningBody), + ), + const SizedBox(height: AppSpacing.xs), + _DeletionImpactLine( + color: warningBody, + text: l10n.authDeletionNoticeLineProfileSessions, + ), + const SizedBox(height: AppSpacing.xs), + _DeletionImpactLine( + color: warningBody, + text: l10n.authDeletionNoticeLineSyncedData, + ), + ], + ), + ), + const SizedBox(height: AppSpacing.md), + AppCard( + child: Column( + children: [ + AppButton( + label: l10n.authRequestDeletionAction, + variant: AppButtonVariant.danger, + isLoading: isSubmitting, + onPressed: isSubmitting + ? null + : () => onRequestAccountDeletion(), + expand: true, + ), + const SizedBox(height: AppSpacing.sm), + AppButton( + label: l10n.authSignOutAction, + variant: AppButtonVariant.secondary, + isLoading: isSubmitting, + onPressed: isSubmitting ? null : () => onSignOut(), + expand: true, + ), + const SizedBox(height: AppSpacing.sm), + AppButton( + label: l10n.authDoneAction, + variant: AppButtonVariant.ghost, + onPressed: isSubmitting ? null : onDone, + expand: true, + ), + ], + ), + ), + ], + ); + } +} + +class _AuthHeaderCard extends StatelessWidget { + const _AuthHeaderCard({required this.l10n, required this.isRegisterMode}); + + final AppLocalizations l10n; + final bool isRegisterMode; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; return AppCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Signed in', - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: colorScheme.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadii.md), + ), + child: Icon( + isRegisterMode + ? Icons.person_add_alt_1_rounded + : Icons.lock_open_rounded, + color: colorScheme.primary, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + isRegisterMode + ? l10n.authHeaderCreateTitle + : l10n.authHeaderWelcomeTitle, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ], ), const SizedBox(height: AppSpacing.sm), Text( - 'You can now use cloud sync features.', + isRegisterMode + ? l10n.authHeaderCreateSubtitle + : l10n.authHeaderSignInSubtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: colorScheme.onSurfaceVariant, ), ), - const SizedBox(height: AppSpacing.md), - _MetaRow(label: 'User ID', value: session.userId ?? 'Unknown'), - const SizedBox(height: AppSpacing.xs), - _MetaRow(label: 'Device ID', value: session.deviceId ?? 'Unknown'), - const SizedBox(height: AppSpacing.lg), - AppButton( - label: 'Sign out', - variant: AppButtonVariant.danger, - isLoading: isSubmitting, - onPressed: isSubmitting ? null : () => onSignOut(), - expand: true, - ), - const SizedBox(height: AppSpacing.sm), - AppButton( - label: 'Done', - variant: AppButtonVariant.ghost, - onPressed: isSubmitting ? null : onDone, - expand: true, - ), ], ), ); @@ -371,22 +807,37 @@ class _AuthenticatedCard extends StatelessWidget { } class _AuthUnavailableCard extends StatelessWidget { - const _AuthUnavailableCard({required this.message, required this.onClose}); + const _AuthUnavailableCard({ + required this.l10n, + required this.message, + required this.onClose, + }); + final AppLocalizations l10n; final String message; final VoidCallback onClose; @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; return AppCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Authentication unavailable', - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + Row( + children: [ + Icon( + Icons.lock_person_outlined, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: AppSpacing.sm), + Text( + l10n.authUnavailableTitle, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ), + ], ), const SizedBox(height: AppSpacing.sm), Text( @@ -397,7 +848,7 @@ class _AuthUnavailableCard extends StatelessWidget { ), const SizedBox(height: AppSpacing.lg), AppButton( - label: 'Back', + label: l10n.authBackAction, variant: AppButtonVariant.ghost, onPressed: onClose, expand: true, @@ -409,15 +860,24 @@ class _AuthUnavailableCard extends StatelessWidget { } class _MetaRow extends StatelessWidget { - const _MetaRow({required this.label, required this.value}); + const _MetaRow({required this.label, required this.value, this.icon}); final String label; final String value; + final IconData? icon; @override Widget build(BuildContext context) { return Row( children: [ + if (icon != null) ...[ + Icon( + icon, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: AppSpacing.xs), + ], SizedBox( width: 88, child: Text( @@ -441,3 +901,36 @@ class _MetaRow extends StatelessWidget { ); } } + +class _DeletionImpactLine extends StatelessWidget { + const _DeletionImpactLine({required this.text, this.color}); + + final String text; + final Color? color; + + @override + Widget build(BuildContext context) { + final resolvedColor = color ?? Theme.of(context).colorScheme.onSurface; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '\u2022', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: resolvedColor, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(width: AppSpacing.xs), + Expanded( + child: Text( + text, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: resolvedColor), + ), + ), + ], + ); + } +} diff --git a/apps/mobile/lib/features/collections/presentation/widgets/collection_grid_tile.dart b/apps/mobile/lib/features/collections/presentation/widgets/collection_grid_tile.dart index 630d942..259bcd8 100644 --- a/apps/mobile/lib/features/collections/presentation/widgets/collection_grid_tile.dart +++ b/apps/mobile/lib/features/collections/presentation/widgets/collection_grid_tile.dart @@ -36,6 +36,8 @@ class CollectionGridTile extends StatelessWidget { final hasDescription = collection.description != null && collection.description!.trim().isNotEmpty; + final showDescription = + hasDescription && (!compact || constraints.maxHeight >= 232); final iconSize = compact ? 36.0 : 40.0; return ClipRRect( @@ -125,15 +127,16 @@ class CollectionGridTile extends StatelessWidget { icon: Icons.category_outlined, label: collectionTypeLabel(context, collection.type), ), - if (hasDescription) ...[ - if (!compact) const Spacer(), + if (showDescription) ...[ const SizedBox(height: AppSpacing.xs), - Text( - collection.description!, - maxLines: compact ? 1 : 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + Flexible( + child: Text( + collection.description!, + maxLines: compact ? 1 : 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), ), ), ], diff --git a/apps/mobile/lib/features/items/presentation/views/add_item_screen.dart b/apps/mobile/lib/features/items/presentation/views/add_item_screen.dart index fe795cc..3221da3 100644 --- a/apps/mobile/lib/features/items/presentation/views/add_item_screen.dart +++ b/apps/mobile/lib/features/items/presentation/views/add_item_screen.dart @@ -1,12 +1,13 @@ -import 'dart:developer'; - import 'package:collection_tracker/l10n/l10n.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; +import 'package:domain/domain.dart'; import 'package:storage/storage.dart'; import 'package:ui/ui.dart'; +import 'package:collection_tracker/core/providers/metadata_preferences_provider.dart'; import 'package:collection_tracker/core/providers/metadata_providers.dart'; import 'package:collection_tracker/features/collections/presentation/view_models/collections_view_model.dart'; import 'package:metadata_api/metadata_api.dart'; @@ -56,8 +57,16 @@ class _AddItemScreenState extends ConsumerState { @override Widget build(BuildContext context) { final l10n = context.l10n; - // Watch collection details so they are available for search/scan actions - ref.watch(collectionDetailProvider(widget.collectionId)); + final collectionAsync = ref.watch( + collectionDetailProvider(widget.collectionId), + ); + final collection = collectionAsync.asData?.value; + final metadataPreferences = ref.watch(metadataPreferencesProvider); + final metadataService = ref.read(metadataLookupServiceProvider); + final metadataSearchSupported = + collection != null && + metadataPreferences.isEnabled && + metadataService.supportsSearch(collection.type); return Scaffold( appBar: AppBar(title: Text(l10n.addItemTitle)), @@ -111,9 +120,14 @@ class _AddItemScreenState extends ConsumerState { labelText: l10n.itemFormTitleLabel, hintText: l10n.addItemTitleHint, prefixIcon: const Icon(Icons.title), - suffixIcon: IconButton( - icon: const Icon(Icons.search), - onPressed: () => _showMetadataSearch(context), + suffixIcon: Tooltip( + message: metadataSearchSupported + ? l10n.metadataSearchSuggestionTitle + : l10n.metadataSearchDisabledHint, + child: IconButton( + icon: const Icon(Icons.search), + onPressed: () => _showMetadataSearch(context), + ), ), textCapitalization: TextCapitalization.words, validator: (value) { @@ -138,7 +152,9 @@ class _AddItemScreenState extends ConsumerState { if (barcode != null && mounted) { _barcodeController.text = barcode; - _fetchMetadata(barcode); + if (metadataPreferences.canAutoFetchFromBarcode) { + _fetchMetadata(barcode, showNoMatchFeedback: false); + } } }, ), @@ -258,12 +274,26 @@ class _AddItemScreenState extends ConsumerState { } Future _showMetadataSearch(BuildContext context) async { - log('show metadata search'); final collectionAsync = ref.read( collectionDetailProvider(widget.collectionId), ); final collection = collectionAsync.asData?.value; if (collection == null) return; + final metadataPreferences = ref.read(metadataPreferencesProvider); + if (!metadataPreferences.isEnabled) { + _showMetadataMessage(context.l10n.settingsMetadataFeatureDisabledMessage); + return; + } + + final metadataService = ref.read(metadataLookupServiceProvider); + if (!metadataService.supportsSearch(collection.type)) { + _showMetadataMessage( + context.l10n.metadataSearchUnavailableForType( + _collectionTypeLabel(collection.type), + ), + ); + return; + } final result = await showSearch( context: context, @@ -271,7 +301,7 @@ class _AddItemScreenState extends ConsumerState { ref: ref, collectionType: collection.type, searchFieldLabelText: context.l10n.metadataSearchFieldLabel( - collection.type.name, + _collectionTypeLabel(collection.type), ), ), query: _titleController.text, @@ -279,16 +309,32 @@ class _AddItemScreenState extends ConsumerState { if (result != null && mounted) { setState(() { - _titleController.text = result.title; - if (_descriptionController.text.isEmpty) { - _descriptionController.text = result.description ?? ''; - } - _coverImageUrl = result.thumbnailUrl; + _applyMetadata( + result, + fillOnlyEmptyFields: metadataPreferences.fillOnlyEmptyFields, + ); }); } } - Future _fetchMetadata(String barcode) async { + Future _fetchMetadata( + String barcode, { + bool showNoMatchFeedback = true, + }) async { + if (_isFetchingMetadata) { + return; + } + + final metadataPreferences = ref.read(metadataPreferencesProvider); + if (!metadataPreferences.isEnabled) { + if (showNoMatchFeedback) { + _showMetadataMessage( + context.l10n.settingsMetadataFeatureDisabledMessage, + ); + } + return; + } + final collectionAsync = ref.read( collectionDetailProvider(widget.collectionId), ); @@ -296,45 +342,57 @@ class _AddItemScreenState extends ConsumerState { final collection = collectionAsync.value; if (collection == null) return; + final metadataService = ref.read(metadataLookupServiceProvider); + if (!metadataService.supportsBarcodeLookup(primaryType: collection.type)) { + if (showNoMatchFeedback) { + _showMetadataMessage( + context.l10n.metadataSearchUnavailableForType( + _collectionTypeLabel(collection.type), + ), + ); + } + return; + } + setState(() { _isFetchingMetadata = true; }); try { - final matcher = await ref.read(smartMetadataMatcherProvider.future); - final result = await matcher.findBestMatch( + final result = await metadataService.findBestBarcodeMatch( barcode: barcode, primaryType: collection.type, ); - result.fold( - (exception) => null, // Ignore errors for now - (match) { - if (match.metadata != null && mounted) { - final metadata = match.metadata!; - setState(() { - if (_titleController.text.isEmpty) { - _titleController.text = metadata.title; - } - if (_descriptionController.text.isEmpty) { - _descriptionController.text = metadata.description ?? ''; - } - _coverImageUrl = metadata.thumbnailUrl; - }); - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.addItemMatchedMetadata(match.source), - ), - duration: const Duration(seconds: 2), + if (result.metadata != null && mounted) { + final metadata = result.metadata!; + setState(() { + _applyMetadata( + metadata, + fillOnlyEmptyFields: metadataPreferences.fillOnlyEmptyFields, + ); + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.addItemMatchedMetadata( + _metadataSourceLabel(result.source), ), - ); - } - }, - ); + ), + duration: const Duration(seconds: 2), + ), + ); + } else if (showNoMatchFeedback && mounted) { + _showMetadataMessage(context.l10n.metadataNoMatchForBarcode); + } } catch (e) { - debugPrint('Metadata fetch error: $e'); + if (kDebugMode) { + debugPrint('Metadata fetch error: $e'); + } + if (showNoMatchFeedback && mounted) { + _showMetadataMessage(context.l10n.metadataNoMatchForBarcode); + } } finally { if (mounted) { setState(() { @@ -435,4 +493,70 @@ class _AddItemScreenState extends ConsumerState { final locale = Localizations.localeOf(context).toLanguageTag(); return NumberFormat.simpleCurrency(locale: locale).currencySymbol; } + + void _applyMetadata( + MetadataBase metadata, { + required bool fillOnlyEmptyFields, + }) { + final title = metadata.title.trim(); + final description = metadata.description?.trim(); + final thumbnail = metadata.thumbnailUrl?.trim(); + + if (fillOnlyEmptyFields) { + if (_titleController.text.trim().isEmpty && title.isNotEmpty) { + _titleController.text = title; + } + if (_descriptionController.text.trim().isEmpty && + description != null && + description.isNotEmpty) { + _descriptionController.text = description; + } + final hasImage = + _imagePath != null || (_coverImageUrl?.trim().isNotEmpty ?? false); + if (!hasImage && thumbnail != null && thumbnail.isNotEmpty) { + _coverImageUrl = thumbnail; + } + return; + } + + if (title.isNotEmpty) { + _titleController.text = title; + } + if (description != null && description.isNotEmpty) { + _descriptionController.text = description; + } + if (_imagePath == null && thumbnail != null && thumbnail.isNotEmpty) { + _coverImageUrl = thumbnail; + } + } + + String _collectionTypeLabel(CollectionType type) { + final l10n = context.l10n; + return switch (type) { + CollectionType.book => l10n.collectionTypeBooks, + CollectionType.game => l10n.collectionTypeGames, + CollectionType.movie => l10n.collectionTypeMovies, + CollectionType.comic => l10n.collectionTypeComics, + CollectionType.music => l10n.collectionTypeMusic, + CollectionType.custom => l10n.collectionTypeCustom, + }; + } + + String _metadataSourceLabel(String source) { + return switch (source.toLowerCase()) { + 'book' => context.l10n.collectionTypeBooks, + 'game' => context.l10n.collectionTypeGames, + 'movie' => context.l10n.collectionTypeMovies, + _ => source, + }; + } + + void _showMetadataMessage(String message) { + if (!mounted) { + return; + } + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } } diff --git a/apps/mobile/lib/features/items/presentation/views/edit_item_screen.dart b/apps/mobile/lib/features/items/presentation/views/edit_item_screen.dart index 2748023..9209635 100644 --- a/apps/mobile/lib/features/items/presentation/views/edit_item_screen.dart +++ b/apps/mobile/lib/features/items/presentation/views/edit_item_screen.dart @@ -1,12 +1,17 @@ import 'package:domain/domain.dart'; +import 'package:collection_tracker/core/providers/metadata_preferences_provider.dart'; +import 'package:collection_tracker/core/providers/metadata_providers.dart'; +import 'package:collection_tracker/features/collections/presentation/view_models/collections_view_model.dart'; import 'package:collection_tracker/l10n/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; +import 'package:metadata_api/metadata_api.dart'; import 'package:ui/ui.dart'; import '../view_models/items_view_model.dart'; +import 'metadata_search_delegate.dart'; import '../widgets/item_tags_editor.dart'; class EditItemScreen extends ConsumerStatefulWidget { @@ -31,6 +36,7 @@ class _EditItemScreenState extends ConsumerState { final _quantityController = TextEditingController(); bool _isSaving = false; + bool _isFetchingMetadata = false; bool _isInitialized = false; Item? _item; ItemCondition? _selectedCondition; @@ -97,6 +103,17 @@ class _EditItemScreenState extends ConsumerState { _isInitialized = true; } + final collectionAsync = ref.watch( + collectionDetailProvider(item.collectionId), + ); + final collection = collectionAsync.asData?.value; + final metadataPreferences = ref.watch(metadataPreferencesProvider); + final metadataService = ref.read(metadataLookupServiceProvider); + final metadataSearchSupported = + collection != null && + metadataPreferences.isEnabled && + metadataService.supportsSearch(collection.type); + return Scaffold( appBar: AppBar(title: Text(l10n.editItemTitle)), body: Form( @@ -111,6 +128,16 @@ class _EditItemScreenState extends ConsumerState { controller: _titleController, labelText: l10n.itemFormTitleLabel, prefixIcon: const Icon(Icons.title), + suffixIcon: Tooltip( + message: metadataSearchSupported + ? l10n.metadataSearchSuggestionTitle + : l10n.metadataSearchDisabledHint, + child: IconButton( + icon: const Icon(Icons.search), + onPressed: () => + _showMetadataSearch(context, collection?.type), + ), + ), textCapitalization: TextCapitalization.words, validator: (value) { if (value == null || value.trim().isEmpty) { @@ -128,18 +155,57 @@ class _EditItemScreenState extends ConsumerState { icon: const Icon(Icons.camera_alt), onPressed: () async { final barcode = await context.push( - '/scanner', + '/scanner?collectionId=${item.collectionId}', ); if (barcode != null && mounted) { setState(() { _barcodeController.text = barcode; }); + if (metadataPreferences.canAutoFetchFromBarcode) { + _fetchMetadata( + barcode, + collectionType: collection?.type, + showNoMatchFeedback: false, + ); + } } }, ), - keyboardType: TextInputType.number, + keyboardType: TextInputType.text, + onFieldSubmitted: (value) { + if (value.trim().isEmpty) { + return; + } + _fetchMetadata( + value.trim(), + collectionType: collection?.type, + ); + }, ), + if (_isFetchingMetadata) + Padding( + padding: EdgeInsets.only(top: AppSpacing.sm), + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ), + SizedBox(width: 8), + Text( + l10n.addItemFetchingMetadata, + style: TextStyle(fontSize: 12), + ), + ], + ), + ), + ), const SizedBox(height: AppSpacing.md), AppInput( controller: _descriptionController, @@ -348,6 +414,131 @@ class _EditItemScreenState extends ConsumerState { } } + Future _showMetadataSearch( + BuildContext context, + CollectionType? collectionType, + ) async { + if (collectionType == null) { + return; + } + + final metadataPreferences = ref.read(metadataPreferencesProvider); + if (!metadataPreferences.isEnabled) { + _showMetadataMessage(context.l10n.settingsMetadataFeatureDisabledMessage); + return; + } + + final metadataService = ref.read(metadataLookupServiceProvider); + if (!metadataService.supportsSearch(collectionType)) { + _showMetadataMessage( + context.l10n.metadataSearchUnavailableForType( + _collectionTypeLabel(collectionType), + ), + ); + return; + } + + final result = await showSearch( + context: context, + delegate: MetadataSearchDelegate( + ref: ref, + collectionType: collectionType, + searchFieldLabelText: context.l10n.metadataSearchFieldLabel( + _collectionTypeLabel(collectionType), + ), + ), + query: _titleController.text, + ); + + if (result == null || !mounted) { + return; + } + + setState(() { + _applyMetadata( + result, + fillOnlyEmptyFields: metadataPreferences.fillOnlyEmptyFields, + ); + }); + } + + Future _fetchMetadata( + String barcode, { + required CollectionType? collectionType, + bool showNoMatchFeedback = true, + }) async { + if (_isFetchingMetadata || collectionType == null) { + return; + } + + final metadataPreferences = ref.read(metadataPreferencesProvider); + if (!metadataPreferences.isEnabled) { + if (showNoMatchFeedback) { + _showMetadataMessage( + context.l10n.settingsMetadataFeatureDisabledMessage, + ); + } + return; + } + + final metadataService = ref.read(metadataLookupServiceProvider); + if (!metadataService.supportsBarcodeLookup(primaryType: collectionType)) { + if (showNoMatchFeedback) { + _showMetadataMessage( + context.l10n.metadataSearchUnavailableForType( + _collectionTypeLabel(collectionType), + ), + ); + } + return; + } + + setState(() { + _isFetchingMetadata = true; + }); + + try { + final result = await metadataService.findBestBarcodeMatch( + barcode: barcode, + primaryType: collectionType, + ); + + if (!mounted) { + return; + } + + if (result.metadata != null) { + setState(() { + _applyMetadata( + result.metadata!, + fillOnlyEmptyFields: metadataPreferences.fillOnlyEmptyFields, + ); + }); + + _showMetadataMessage( + context.l10n.addItemMatchedMetadata( + _metadataSourceLabel(result.source), + ), + ); + } else if (showNoMatchFeedback) { + _showMetadataMessage(context.l10n.metadataNoMatchForBarcode); + } + } catch (_) { + if (!mounted) { + return; + } + if (showNoMatchFeedback) { + _showMetadataMessage(context.l10n.metadataNoMatchForBarcode); + } + } finally { + if (mounted) { + setState(() { + _isFetchingMetadata = false; + }); + } + } + } + String? _validatePriceInput(String? value) { if (value == null || value.trim().isEmpty) { return null; @@ -402,4 +593,61 @@ class _EditItemScreenState extends ConsumerState { ItemCondition.poor => l10n.itemConditionPoor, }; } + + void _applyMetadata( + MetadataBase metadata, { + required bool fillOnlyEmptyFields, + }) { + final title = metadata.title.trim(); + final description = metadata.description?.trim(); + + if (fillOnlyEmptyFields) { + if (_titleController.text.trim().isEmpty && title.isNotEmpty) { + _titleController.text = title; + } + if (_descriptionController.text.trim().isEmpty && + description != null && + description.isNotEmpty) { + _descriptionController.text = description; + } + return; + } + + if (title.isNotEmpty) { + _titleController.text = title; + } + if (description != null && description.isNotEmpty) { + _descriptionController.text = description; + } + } + + String _collectionTypeLabel(CollectionType type) { + final l10n = context.l10n; + return switch (type) { + CollectionType.book => l10n.collectionTypeBooks, + CollectionType.game => l10n.collectionTypeGames, + CollectionType.movie => l10n.collectionTypeMovies, + CollectionType.comic => l10n.collectionTypeComics, + CollectionType.music => l10n.collectionTypeMusic, + CollectionType.custom => l10n.collectionTypeCustom, + }; + } + + String _metadataSourceLabel(String source) { + return switch (source.toLowerCase()) { + 'book' => context.l10n.collectionTypeBooks, + 'game' => context.l10n.collectionTypeGames, + 'movie' => context.l10n.collectionTypeMovies, + _ => source, + }; + } + + void _showMetadataMessage(String message) { + if (!mounted) { + return; + } + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } } diff --git a/apps/mobile/lib/features/items/presentation/views/metadata_search_delegate.dart b/apps/mobile/lib/features/items/presentation/views/metadata_search_delegate.dart index 81c0f54..f5be060 100644 --- a/apps/mobile/lib/features/items/presentation/views/metadata_search_delegate.dart +++ b/apps/mobile/lib/features/items/presentation/views/metadata_search_delegate.dart @@ -18,6 +18,10 @@ class MetadataSearchDelegate extends SearchDelegate { required this.searchFieldLabelText, }) : super(searchFieldLabel: searchFieldLabelText); + String _lastSearchQuery = ''; + int _lastSearchLimit = 0; + Future>? _lastSearchFuture; + @override List? buildActions(BuildContext context) { return [ @@ -52,7 +56,7 @@ class MetadataSearchDelegate extends SearchDelegate { } return FutureBuilder( - future: _performSearch(), + future: _performSearch(query: query, limit: 12), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return LoadingView(message: l10n.metadataSearchLoading); @@ -116,20 +120,94 @@ class MetadataSearchDelegate extends SearchDelegate { @override Widget buildSuggestions(BuildContext context) { - return EmptyState( - icon: Icons.search, - title: context.l10n.metadataSearchSuggestionTitle, - message: context.l10n.metadataSearchSuggestionMessage, + if (query.trim().length < 2) { + return EmptyState( + icon: Icons.search, + title: context.l10n.metadataSearchSuggestionTitle, + message: context.l10n.metadataSearchSuggestionMessage, + ); + } + + return FutureBuilder>( + future: _performSearch(query: query, limit: 6), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return LoadingView(message: context.l10n.metadataSearchLoading); + } + if (snapshot.hasError) { + return ErrorView( + message: context.l10n.metadataSearchError('${snapshot.error}'), + ); + } + + final results = snapshot.data ?? const []; + if (results.isEmpty) { + return EmptyState( + icon: Icons.search_off, + title: context.l10n.metadataSearchNoResultsTitle, + message: context.l10n.metadataSearchNoResultsMessage, + ); + } + + return ListView.builder( + padding: const EdgeInsets.all(AppSpacing.md), + itemCount: results.length, + itemBuilder: (context, index) { + final item = results[index]; + return ListTile( + leading: item.thumbnailUrl != null + ? CachedNetworkImage( + imageUrl: item.thumbnailUrl!, + width: 42, + fit: BoxFit.cover, + placeholder: (context, url) => const Icon(Icons.image), + errorWidget: (context, url, error) => + const Icon(Icons.image_not_supported), + ) + : const Icon(Icons.image), + title: Text( + item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: () { + query = item.title; + showResults(context); + }, + ); + }, + ); + }, ); } - Future> _performSearch() async { - final service = await ref.read(unifiedMetadataServiceProvider.future); - final result = await service.search( + Future> _performSearch({ + required String query, + int limit = 10, + }) { + final normalized = query.trim(); + if (_lastSearchFuture != null && + _lastSearchQuery == normalized && + _lastSearchLimit == limit) { + return _lastSearchFuture!; + } + + _lastSearchQuery = normalized; + _lastSearchLimit = limit; + _lastSearchFuture = _search(normalized, limit); + return _lastSearchFuture!; + } + + Future> _search(String query, int limit) async { + if (query.isEmpty) { + return const []; + } + + final service = ref.read(metadataLookupServiceProvider); + return service.search( query: query, collectionType: collectionType, + limit: limit, ); - - return result.fold((exception) => throw exception, (items) => items); } } diff --git a/apps/mobile/lib/features/items/presentation/widgets/item_filter_sheet.dart b/apps/mobile/lib/features/items/presentation/widgets/item_filter_sheet.dart index 087faf7..c5e0f30 100644 --- a/apps/mobile/lib/features/items/presentation/widgets/item_filter_sheet.dart +++ b/apps/mobile/lib/features/items/presentation/widgets/item_filter_sheet.dart @@ -161,7 +161,7 @@ class ItemFilterSheet extends ConsumerWidget { onPressed: () => Navigator.pop(context), ), ), - const SizedBox(height: 8), + const SizedBox(height: 64), ], ), ), diff --git a/apps/mobile/lib/features/loans/presentation/view_models/loan_tracking_view_model.dart b/apps/mobile/lib/features/loans/presentation/view_models/loan_tracking_view_model.dart new file mode 100644 index 0000000..088dccd --- /dev/null +++ b/apps/mobile/lib/features/loans/presentation/view_models/loan_tracking_view_model.dart @@ -0,0 +1,118 @@ +import 'package:app_analytics/app_analytics.dart'; +import 'package:collection_tracker/core/providers/providers.dart'; +import 'package:domain/domain.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'loan_tracking_view_model.g.dart'; + +class LoanCandidateItem { + const LoanCandidateItem({required this.item, required this.collectionName}); + + final Item item; + final String collectionName; + + String get displayLabel => '${item.title} • $collectionName'; +} + +@riverpod +Stream> activeLoans(Ref ref) { + final repository = ref.watch(loanRepositoryProvider); + return repository.watchActiveLoans(); +} + +@riverpod +Stream> loanHistory(Ref ref) { + final repository = ref.watch(loanRepositoryProvider); + return repository.watchLoanHistory(); +} + +@riverpod +Stream activeLoanForItem(Ref ref, String itemId) { + final repository = ref.watch(loanRepositoryProvider); + return repository.watchActiveLoanForItem(itemId); +} + +@riverpod +Future> loanCandidateItems(Ref ref) async { + final collectionRepository = ref.read(collectionRepositoryProvider); + final itemRepository = ref.read(itemRepositoryProvider); + + final collectionsResult = await collectionRepository.getCollections(); + final collections = collectionsResult.fold( + (exception) => throw exception, + (r) => r, + ); + + final candidates = []; + for (final collection in collections) { + final itemsResult = await itemRepository.getItems( + collectionId: collection.id, + ); + final items = itemsResult.fold((exception) => throw exception, (r) => r); + for (final item in items) { + candidates.add( + LoanCandidateItem(item: item, collectionName: collection.name), + ); + } + } + + candidates.sort( + (a, b) => a.item.title.toLowerCase().compareTo(b.item.title.toLowerCase()), + ); + + return candidates; +} + +@riverpod +Future createLoan( + Ref ref, { + required String itemId, + required String borrowerName, + String? borrowerContact, + String? notes, + DateTime? dueAt, +}) async { + final repository = ref.read(loanRepositoryProvider); + final result = await repository.createLoan( + itemId: itemId, + borrowerName: borrowerName, + borrowerContact: borrowerContact, + notes: notes, + dueAt: dueAt, + ); + + result.fold((exception) => throw exception, (loan) async { + await AnalyticsService.instance.track( + AnalyticsEvent.custom( + name: 'loan_created', + properties: { + 'loan_id': loan.id, + 'item_id': loan.itemId, + 'has_due_date': loan.dueAt != null, + }, + ), + ); + }); +} + +@riverpod +Future markLoanReturned(Ref ref, {required String loanId}) async { + final repository = ref.read(loanRepositoryProvider); + final result = await repository.markLoanReturned(loanId: loanId); + + result.fold((exception) => throw exception, (loan) async { + await AnalyticsService.instance.track( + AnalyticsEvent.custom( + name: 'loan_returned', + properties: {'loan_id': loan.id, 'item_id': loan.itemId}, + ), + ); + }); +} + +@riverpod +Future deleteLoan(Ref ref, {required String loanId}) async { + final repository = ref.read(loanRepositoryProvider); + final result = await repository.deleteLoan(loanId); + result.fold((exception) => throw exception, (_) => null); +} diff --git a/apps/mobile/lib/features/loans/presentation/views/loan_tracking_screen.dart b/apps/mobile/lib/features/loans/presentation/views/loan_tracking_screen.dart new file mode 100644 index 0000000..32f1c7a --- /dev/null +++ b/apps/mobile/lib/features/loans/presentation/views/loan_tracking_screen.dart @@ -0,0 +1,882 @@ +import 'package:collection_tracker/features/loans/presentation/view_models/loan_tracking_view_model.dart'; +import 'package:collection_tracker/l10n/l10n.dart'; +import 'package:domain/domain.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:ui/ui.dart'; + +class LoanTrackingScreen extends ConsumerStatefulWidget { + const LoanTrackingScreen({super.key}); + + @override + ConsumerState createState() => _LoanTrackingScreenState(); +} + +class _LoanTrackingScreenState extends ConsumerState { + bool _showHistory = false; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final activeAsync = ref.watch(activeLoansProvider); + final historyAsync = ref.watch(loanHistoryProvider); + final loansAsync = _showHistory ? historyAsync : activeAsync; + + return Scaffold( + appBar: AppBar(title: Text(l10n.loanTrackingTitle)), + floatingActionButton: FloatingActionButton.extended( + heroTag: 'loan_tracking_fab', + onPressed: () => _showCreateLoanSheet(context), + icon: const Icon(Icons.handshake_outlined), + label: Text(l10n.loanTrackingNewLoan), + ), + body: ListView( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.md, + AppSpacing.lg, + AppSpacing.xxl * 2, + ), + children: [ + AppReveal(child: _LoanSummaryCard(activeAsync: activeAsync)), + const SizedBox(height: AppSpacing.md), + AppReveal( + delay: AppMotion.stagger, + child: Wrap( + spacing: AppSpacing.sm, + children: [ + ChoiceChip( + selected: !_showHistory, + label: Text(l10n.loanTrackingFilterActive), + onSelected: (_) => setState(() => _showHistory = false), + ), + ChoiceChip( + selected: _showHistory, + label: Text(l10n.loanTrackingFilterHistory), + onSelected: (_) => setState(() => _showHistory = true), + ), + ], + ), + ), + const SizedBox(height: AppSpacing.md), + AppReveal( + delay: AppMotion.stagger * 2, + child: AppAnimatedSwitcher( + child: loansAsync.when( + data: (loans) { + if (loans.isEmpty) { + return EmptyState( + key: ValueKey( + _showHistory ? 'history-empty' : 'active-empty', + ), + icon: _showHistory + ? Icons.history_toggle_off_rounded + : Icons.inventory_2_outlined, + title: _showHistory + ? l10n.loanTrackingEmptyHistoryTitle + : l10n.loanTrackingEmptyActiveTitle, + message: _showHistory + ? l10n.loanTrackingEmptyHistoryMessage + : l10n.loanTrackingEmptyActiveMessage, + ); + } + + return Column( + key: ValueKey( + _showHistory ? 'history-list' : 'active-list', + ), + children: [ + for (var i = 0; i < loans.length; i++) ...[ + _LoanCard( + loan: loans[i], + showHistoryMeta: _showHistory, + onReturn: loans[i].isActive + ? () => _markReturned(context, loans[i]) + : null, + onDelete: () => _deleteLoan(context, loans[i]), + ), + if (i < loans.length - 1) + const SizedBox(height: AppSpacing.sm), + ], + ], + ); + }, + loading: () => Padding( + padding: EdgeInsets.only(top: AppSpacing.xxl), + child: LoadingView(message: l10n.loanTrackingLoadingLoans), + ), + error: (error, _) => ErrorView( + message: l10n.loanTrackingLoadFailed('$error'), + onRetry: () { + ref.invalidate(activeLoansProvider); + ref.invalidate(loanHistoryProvider); + }, + ), + ), + ), + ), + ], + ), + ); + } + + Future _showCreateLoanSheet(BuildContext context) async { + await showAppSheet( + context: context, + builder: (_) => const _CreateLoanSheet(), + ); + } + + Future _markReturned(BuildContext context, LoanRecord loan) async { + final l10n = context.l10n; + final confirmed = await showAppDialog( + context: context, + title: Text(l10n.loanTrackingMarkReturnedConfirmTitle), + content: Text( + l10n.loanTrackingMarkReturnedConfirmMessage(loan.itemTitle), + ), + actions: [ + AppButton( + label: l10n.actionCancel, + variant: AppButtonVariant.ghost, + onPressed: () => closeAppDialog(context, false), + ), + AppButton( + label: l10n.loanTrackingMarkReturnedAction, + onPressed: () => closeAppDialog(context, true), + ), + ], + ); + + if (confirmed != true || !context.mounted) { + return; + } + + try { + await ref.read(markLoanReturnedProvider(loanId: loan.id).future); + if (!context.mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.loanTrackingMarkedReturnedSuccess)), + ); + } catch (error) { + if (!context.mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.loanTrackingMarkReturnedFailed('$error')), + backgroundColor: Colors.red, + ), + ); + } + } + + Future _deleteLoan(BuildContext context, LoanRecord loan) async { + final l10n = context.l10n; + final confirmed = await showAppDialog( + context: context, + title: Text(l10n.loanTrackingDeleteConfirmTitle), + content: Text(l10n.loanTrackingDeleteConfirmMessage(loan.itemTitle)), + actions: [ + AppButton( + label: l10n.actionCancel, + variant: AppButtonVariant.ghost, + onPressed: () => closeAppDialog(context, false), + ), + AppButton( + label: context.l10n.actionDelete, + variant: AppButtonVariant.danger, + onPressed: () => closeAppDialog(context, true), + ), + ], + ); + + if (confirmed != true || !context.mounted) { + return; + } + + try { + await ref.read(deleteLoanProvider(loanId: loan.id).future); + if (!context.mounted) { + return; + } + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.loanTrackingDeleteSuccess))); + } catch (error) { + if (!context.mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.loanTrackingDeleteFailed('$error')), + backgroundColor: Colors.red, + ), + ); + } + } +} + +class _LoanSummaryCard extends StatelessWidget { + const _LoanSummaryCard({required this.activeAsync}); + + final AsyncValue> activeAsync; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final theme = Theme.of(context); + + return AppCard( + child: activeAsync.when( + data: (activeLoans) { + final overdueCount = activeLoans + .where((loan) => loan.isOverdue) + .length; + + return Row( + children: [ + Expanded( + child: _StatPill( + icon: Icons.inventory_2_outlined, + label: l10n.loanTrackingSummaryActiveLabel, + value: '${activeLoans.length}', + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: _StatPill( + icon: Icons.warning_amber_rounded, + label: l10n.loanTrackingSummaryOverdueLabel, + value: '$overdueCount', + tint: overdueCount > 0 + ? theme.colorScheme.errorContainer + : theme.colorScheme.surfaceContainerHighest, + ), + ), + ], + ); + }, + loading: () => const SizedBox( + height: 74, + child: Center(child: LoadingView(indicatorSize: 30)), + ), + error: (_, _) => Text(l10n.loanTrackingSummaryLoadFailed), + ), + ); + } +} + +class _StatPill extends StatelessWidget { + const _StatPill({ + required this.icon, + required this.label, + required this.value, + this.tint, + }); + + final IconData icon; + final String label; + final String value; + final Color? tint; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: tint ?? colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(AppRadii.md), + ), + child: Row( + children: [ + Icon(icon, size: 18, color: colors.onSurfaceVariant), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + value, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800), + ), + Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _LoanCard extends StatelessWidget { + const _LoanCard({ + required this.loan, + required this.showHistoryMeta, + required this.onDelete, + this.onReturn, + }); + + final LoanRecord loan; + final bool showHistoryMeta; + final VoidCallback onDelete; + final VoidCallback? onReturn; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final theme = Theme.of(context); + + return AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + loan.itemTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + _LoanStatusBadge(loan: loan), + ], + ), + const SizedBox(height: AppSpacing.xs), + Text( + loan.collectionName, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.md), + _LoanMetaRow( + icon: Icons.person_outline_rounded, + label: l10n.loanTrackingFieldBorrower, + value: loan.borrowerName, + ), + if (loan.borrowerContact != null) ...[ + const SizedBox(height: AppSpacing.xs), + _LoanMetaRow( + icon: Icons.call_outlined, + label: l10n.loanTrackingFieldContact, + value: loan.borrowerContact!, + ), + ], + const SizedBox(height: AppSpacing.xs), + _LoanMetaRow( + icon: Icons.calendar_today_outlined, + label: l10n.loanTrackingFieldLoaned, + value: _formatDate(context, loan.loanedAt), + ), + if (loan.dueAt != null) ...[ + const SizedBox(height: AppSpacing.xs), + _LoanMetaRow( + icon: Icons.event_available_outlined, + label: l10n.loanTrackingFieldDue, + value: _formatDate(context, loan.dueAt!), + valueColor: loan.isOverdue + ? theme.colorScheme.error + : theme.colorScheme.onSurface, + ), + ], + if (showHistoryMeta && loan.returnedAt != null) ...[ + const SizedBox(height: AppSpacing.xs), + _LoanMetaRow( + icon: Icons.assignment_turned_in_outlined, + label: l10n.loanTrackingFieldReturned, + value: _formatDate(context, loan.returnedAt!), + ), + ], + if (loan.notes != null && loan.notes!.trim().isNotEmpty) ...[ + const SizedBox(height: AppSpacing.sm), + Text( + loan.notes!, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + const SizedBox(height: AppSpacing.md), + Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, + children: [ + if (onReturn != null) + AppButton( + label: l10n.loanTrackingMarkReturnedAction, + variant: AppButtonVariant.secondary, + onPressed: onReturn, + ), + AppButton( + label: context.l10n.actionDelete, + variant: AppButtonVariant.ghost, + onPressed: onDelete, + ), + ], + ), + ], + ), + ); + } + + String _formatDate(BuildContext context, DateTime date) { + final locale = Localizations.localeOf(context).toLanguageTag(); + return DateFormat.yMMMd(locale).format(date); + } +} + +class _LoanMetaRow extends StatelessWidget { + const _LoanMetaRow({ + required this.icon, + required this.label, + required this.value, + this.valueColor, + }); + + final IconData icon; + final String label; + final String value; + final Color? valueColor; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + return Row( + children: [ + Icon(icon, size: 15, color: colors.onSurfaceVariant), + const SizedBox(width: AppSpacing.xs), + SizedBox( + width: 84, + child: Text( + label, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: colors.onSurfaceVariant), + ), + ), + Expanded( + child: Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: valueColor, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ); + } +} + +class _LoanStatusBadge extends StatelessWidget { + const _LoanStatusBadge({required this.loan}); + + final LoanRecord loan; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + late final Color background; + late final Color foreground; + late final String label; + + if (loan.isReturned) { + background = colorScheme.primary.withValues(alpha: 0.14); + foreground = colorScheme.primary; + label = l10n.loanTrackingStatusReturned; + } else if (loan.isOverdue) { + background = colorScheme.error.withValues(alpha: 0.14); + foreground = colorScheme.error; + label = l10n.loanTrackingStatusOverdue; + } else { + background = colorScheme.tertiary.withValues(alpha: 0.14); + foreground = colorScheme.tertiary; + label = l10n.loanTrackingStatusActive; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(AppRadii.pill), + ), + child: Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: foreground, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +class _CreateLoanSheet extends ConsumerStatefulWidget { + const _CreateLoanSheet(); + + @override + ConsumerState<_CreateLoanSheet> createState() => _CreateLoanSheetState(); +} + +class _CreateLoanSheetState extends ConsumerState<_CreateLoanSheet> { + final TextEditingController _borrowerController = TextEditingController(); + final TextEditingController _contactController = TextEditingController(); + final TextEditingController _notesController = TextEditingController(); + + String? _selectedItemId; + DateTime? _dueDate; + bool _submitting = false; + + @override + void dispose() { + _borrowerController.dispose(); + _contactController.dispose(); + _notesController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final candidatesAsync = ref.watch(loanCandidateItemsProvider); + final activeLoans = + ref.watch(activeLoansProvider).asData?.value ?? const []; + + return AnimatedPadding( + duration: AppMotion.fast, + curve: AppMotion.emphasized, + padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: candidatesAsync.when( + data: (allCandidates) { + final activeItemIds = activeLoans + .map((loan) => loan.itemId) + .toSet(); + final candidates = allCandidates + .where( + (candidate) => !activeItemIds.contains(candidate.item.id), + ) + .toList(growable: false); + + final selectedStillValid = + _selectedItemId != null && + candidates.any( + (candidate) => candidate.item.id == _selectedItemId, + ); + if (!selectedStillValid) { + _selectedItemId = candidates.isEmpty + ? null + : candidates.first.item.id; + } + + return SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.loanTrackingCreateTitle, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: AppSpacing.sm), + Text( + l10n.loanTrackingCreateDescription, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.md), + if (candidates.isEmpty) + EmptyState( + icon: Icons.inventory_2_outlined, + title: l10n.loanTrackingCreateNoItemsTitle, + message: l10n.loanTrackingCreateNoItemsMessage, + ) + else + DropdownButtonFormField( + key: ValueKey(_selectedItemId), + initialValue: _selectedItemId, + isExpanded: true, + decoration: InputDecoration( + labelText: l10n.loanTrackingCreateItemLabel, + ), + items: candidates + .map( + (candidate) => DropdownMenuItem( + value: candidate.item.id, + child: Text( + candidate.displayLabel, + overflow: TextOverflow.ellipsis, + ), + ), + ) + .toList(growable: false), + onChanged: _submitting + ? null + : (value) => + setState(() => _selectedItemId = value), + ), + const SizedBox(height: AppSpacing.md), + AppInput( + controller: _borrowerController, + labelText: l10n.loanTrackingCreateBorrowerLabel, + hintText: l10n.loanTrackingCreateBorrowerHint, + enabled: !_submitting && candidates.isNotEmpty, + ), + const SizedBox(height: AppSpacing.md), + AppInput( + controller: _contactController, + labelText: l10n.loanTrackingCreateContactLabel, + hintText: l10n.loanTrackingCreateContactHint, + enabled: !_submitting && candidates.isNotEmpty, + ), + const SizedBox(height: AppSpacing.md), + _DueDateField( + dueDate: _dueDate, + enabled: !_submitting && candidates.isNotEmpty, + onPick: () => _pickDueDate(context), + onClear: _dueDate == null + ? null + : () => setState(() => _dueDate = null), + ), + const SizedBox(height: AppSpacing.md), + AppInput( + controller: _notesController, + labelText: l10n.loanTrackingCreateNotesLabel, + hintText: l10n.loanTrackingCreateNotesHint, + maxLines: 3, + enabled: !_submitting && candidates.isNotEmpty, + ), + const SizedBox(height: AppSpacing.lg), + AppButton( + label: _submitting + ? l10n.loanTrackingCreateSubmitting + : l10n.loanTrackingCreateAction, + onPressed: (_submitting || candidates.isEmpty) + ? null + : () => _submit(context), + ), + const SizedBox(height: AppSpacing.sm), + AppButton( + label: context.l10n.actionCancel, + variant: AppButtonVariant.ghost, + onPressed: _submitting + ? null + : () => Navigator.of(context).pop(), + ), + ], + ), + ); + }, + loading: () => SizedBox( + height: 180, + child: LoadingView(message: l10n.loanTrackingLoadingItems), + ), + error: (error, _) => ErrorView( + message: l10n.loanTrackingLoadItemsFailed('$error'), + onRetry: () => ref.invalidate(loanCandidateItemsProvider), + ), + ), + ), + ), + ); + } + + Future _pickDueDate(BuildContext context) async { + final now = DateTime.now(); + final picked = await showDatePicker( + context: context, + initialDate: _dueDate ?? now.add(const Duration(days: 7)), + firstDate: now, + lastDate: now.add(const Duration(days: 3650)), + ); + if (picked != null) { + setState(() { + _dueDate = DateTime(picked.year, picked.month, picked.day, 12); + }); + } + } + + Future _submit(BuildContext context) async { + final l10n = context.l10n; + final itemId = _selectedItemId; + if (itemId == null || itemId.isEmpty) { + return; + } + + final borrowerName = _borrowerController.text.trim(); + if (borrowerName.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.loanTrackingBorrowerRequired)), + ); + return; + } + + setState(() => _submitting = true); + + try { + await ref + .read( + createLoanProvider( + itemId: itemId, + borrowerName: borrowerName, + borrowerContact: _contactController.text, + notes: _notesController.text, + dueAt: _dueDate, + ).future, + ) + .then((_) { + ref.invalidate(activeLoansProvider); + ref.invalidate(loanHistoryProvider); + ref.invalidate(loanCandidateItemsProvider); + }); + + if (!context.mounted) { + return; + } + + Navigator.of(context).pop(); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.loanTrackingCreateSuccess))); + } catch (error) { + if (!context.mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.loanTrackingCreateFailed('$error')), + backgroundColor: Colors.red, + ), + ); + } finally { + if (mounted) { + setState(() => _submitting = false); + } + } + } +} + +class _DueDateField extends StatelessWidget { + const _DueDateField({ + required this.dueDate, + required this.enabled, + required this.onPick, + this.onClear, + }); + + final DateTime? dueDate; + final bool enabled; + final VoidCallback onPick; + final VoidCallback? onClear; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final locale = Localizations.localeOf(context).toLanguageTag(); + final label = dueDate == null + ? l10n.loanTrackingNoDueDate + : DateFormat.yMMMd(locale).format(dueDate!); + + final controls = [ + AppButton( + label: l10n.loanTrackingPickDateAction, + variant: AppButtonVariant.secondary, + onPressed: enabled ? onPick : null, + ), + if (dueDate != null) + AppButton( + label: l10n.loanTrackingClearDateAction, + variant: AppButtonVariant.ghost, + onPressed: enabled ? onClear : null, + ), + ]; + + return LayoutBuilder( + builder: (context, constraints) { + final isCompact = constraints.maxWidth < 420; + final dateField = Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.md, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppRadii.md), + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.loanTrackingDueDateLabel, + style: Theme.of(context).textTheme.labelMedium, + ), + const SizedBox(height: AppSpacing.xs), + Text(label, style: Theme.of(context).textTheme.bodyMedium), + ], + ), + ); + + if (isCompact) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + dateField, + const SizedBox(height: AppSpacing.sm), + Wrap( + spacing: AppSpacing.xs, + runSpacing: AppSpacing.xs, + children: controls, + ), + ], + ); + } + + return Row( + children: [ + Expanded(child: dateField), + const SizedBox(width: AppSpacing.sm), + for (final button in controls) + Padding( + padding: const EdgeInsets.only(left: AppSpacing.xs), + child: button, + ), + ], + ); + }, + ); + } +} diff --git a/apps/mobile/lib/features/settings/presentation/view_models/export_import_view_model.dart b/apps/mobile/lib/features/settings/presentation/view_models/export_import_view_model.dart index 81f5390..1c6af0b 100644 --- a/apps/mobile/lib/features/settings/presentation/view_models/export_import_view_model.dart +++ b/apps/mobile/lib/features/settings/presentation/view_models/export_import_view_model.dart @@ -1,13 +1,42 @@ +import 'dart:convert'; + import 'package:app_firebase/app_firebase.dart'; import 'package:collection_tracker/core/observability/operational_telemetry.dart'; import 'package:collection_tracker/core/providers/providers.dart'; import 'package:database/database.dart'; -import 'package:domain/domain.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:storage/storage.dart'; part 'export_import_view_model.g.dart'; +class JsonImportPreview { + const JsonImportPreview({ + required this.fileName, + required this.version, + required this.schema, + required this.collectionCount, + required this.itemCount, + required this.tagCount, + required this.itemTagCount, + required this.priceHistoryCount, + required this.loanCount, + required this.warnings, + required this.payload, + }); + + final String fileName; + final String? version; + final String? schema; + final int collectionCount; + final int itemCount; + final int tagCount; + final int itemTagCount; + final int priceHistoryCount; + final int loanCount; + final List warnings; + final Map payload; +} + @riverpod class ExportImportViewModel extends _$ExportImportViewModel { @override @@ -19,6 +48,8 @@ class ExportImportViewModel extends _$ExportImportViewModel { state = const AsyncValue.loading(); final performanceService = FirebasePerformanceService.instance; final stopwatch = Stopwatch()..start(); + final db = ref.read(appDatabaseProvider); + final exportService = ExportImportService(); var collectionCount = 0; var itemCount = 0; @@ -26,65 +57,106 @@ class ExportImportViewModel extends _$ExportImportViewModel { () => performanceService.traceAsync( 'settings_export_all_data_json', () async { - final collectionRepo = ref.read(collectionRepositoryProvider); - final itemRepo = ref.read(itemRepositoryProvider); - final exportService = ExportImportService(); - - final collectionsResult = await collectionRepo.getCollections(); - final collections = collectionsResult.fold( - (exception) => throw exception, - (data) => data, - ); - collectionCount = collections.length; + final collections = await db.select(db.collections).get(); + final items = await db.select(db.items).get(); + final tags = await db.select(db.tags).get(); + final itemTags = await db.select(db.itemTags).get(); + final priceHistory = await db.select(db.itemPriceHistory).get(); + final loans = await db.select(db.itemLoans).get(); - final allItems = []; - for (final collection in collections) { - final itemsResult = await itemRepo.getItems( - collectionId: collection.id, - ); - itemsResult.fold( - (exception) => null, - (items) => allItems.addAll(items), - ); - } - itemCount = allItems.length; + collectionCount = collections.length; + itemCount = items.length; - final exportData = { - 'version': '1.0.0', + final exportData = { + 'version': '2.0.0', + 'schema': 'collection_tracker_backup', 'exportDate': DateTime.now().toIso8601String(), 'collections': collections .map( - (c) => { - 'id': c.id, - 'name': c.name, - 'type': c.type.name, - 'description': c.description, - 'itemCount': c.itemCount, - 'createdAt': c.createdAt.toIso8601String(), - 'updatedAt': c.updatedAt.toIso8601String(), + (collection) => { + 'id': collection.id, + 'name': collection.name, + 'type': collection.type, + 'description': collection.description, + 'coverImagePath': collection.coverImagePath, + 'itemCount': collection.itemCount, + 'createdAt': collection.createdAt.toIso8601String(), + 'updatedAt': collection.updatedAt.toIso8601String(), + }, + ) + .toList(growable: false), + 'items': items + .map( + (item) => { + 'id': item.id, + 'collectionId': item.collectionId, + 'title': item.title, + 'barcode': item.barcode, + 'coverImageUrl': item.coverImageUrl, + 'coverImagePath': item.coverImagePath, + 'description': item.description, + 'notes': item.notes, + 'metadata': item.metadata, + 'condition': item.condition, + 'purchasePrice': item.purchasePrice, + 'purchaseDate': item.purchaseDate?.toIso8601String(), + 'currentValue': item.currentValue, + 'location': item.location, + 'isFavorite': item.isFavorite, + 'isWishlist': item.isWishlist, + 'sortOrder': item.sortOrder, + 'quantity': item.quantity, + 'createdAt': item.createdAt.toIso8601String(), + 'updatedAt': item.updatedAt.toIso8601String(), + }, + ) + .toList(growable: false), + 'tags': tags + .map( + (tag) => { + 'id': tag.id, + 'name': tag.name, + 'color': tag.color, + 'createdAt': tag.createdAt.toIso8601String(), + 'updatedAt': tag.updatedAt.toIso8601String(), + }, + ) + .toList(growable: false), + 'itemTags': itemTags + .map( + (relation) => { + 'itemId': relation.itemId, + 'tagId': relation.tagId, }, ) - .toList(), - 'items': allItems + .toList(growable: false), + 'priceHistory': priceHistory .map( - (i) => { - 'id': i.id, - 'collectionId': i.collectionId, - 'title': i.title, - 'barcode': i.barcode, - 'description': i.description, - 'condition': i.condition?.name, - 'quantity': i.quantity, - 'location': i.location, - 'notes': i.notes, - 'isFavorite': i.isFavorite, - 'purchasePrice': i.purchasePrice, - 'purchaseDate': i.purchaseDate?.toIso8601String(), - 'createdAt': i.createdAt.toIso8601String(), - 'updatedAt': i.updatedAt.toIso8601String(), + (history) => { + 'id': history.id, + 'itemId': history.itemId, + 'value': history.value, + 'recordedAt': history.recordedAt.toIso8601String(), + 'source': history.source, }, ) - .toList(), + .toList(growable: false), + 'loans': loans + .map( + (loan) => { + 'id': loan.id, + 'itemId': loan.itemId, + 'borrowerName': loan.borrowerName, + 'borrowerContact': loan.borrowerContact, + 'notes': loan.notes, + 'loanedAt': loan.loanedAt.toIso8601String(), + 'dueAt': loan.dueAt?.toIso8601String(), + 'returnedAt': loan.returnedAt?.toIso8601String(), + 'createdAt': loan.createdAt.toIso8601String(), + 'updatedAt': loan.updatedAt.toIso8601String(), + }, + ) + .toList(growable: false), }; return exportService.exportToJson(exportData); @@ -96,16 +168,20 @@ class ExportImportViewModel extends _$ExportImportViewModel { stopwatch.stop(); if (result.hasError) { + final error = result.error; + if (error is UserCancelledStorageOperationException) { + throw error; + } await OperationalTelemetry.trackDataTransfer( operation: 'export_json', success: false, durationMs: stopwatch.elapsedMilliseconds, collectionCount: collectionCount, itemCount: itemCount, - error: result.error, + error: error, stackTrace: result.stackTrace, ); - throw result.error!; + throw error!; } await OperationalTelemetry.trackDataTransfer( @@ -123,49 +199,68 @@ class ExportImportViewModel extends _$ExportImportViewModel { state = const AsyncValue.loading(); final performanceService = FirebasePerformanceService.instance; final stopwatch = Stopwatch()..start(); + final db = ref.read(appDatabaseProvider); + final exportService = ExportImportService(); var collectionCount = 0; var itemCount = 0; final result = await AsyncValue.guard( () => performanceService.traceAsync('settings_export_items_csv', () async { - final collectionRepo = ref.read(collectionRepositoryProvider); - final itemRepo = ref.read(itemRepositoryProvider); - final exportService = ExportImportService(); - - final collectionsResult = await collectionRepo.getCollections(); - final collections = collectionsResult.fold( - (exception) => throw exception, - (data) => data, - ); + final collections = await db.select(db.collections).get(); + final items = await db.select(db.items).get(); + final tags = await db.select(db.tags).get(); + final itemTags = await db.select(db.itemTags).get(); + collectionCount = collections.length; + itemCount = items.length; - final allItems = >[]; - for (final collection in collections) { - final itemsResult = await itemRepo.getItems( - collectionId: collection.id, - ); - itemsResult.fold((exception) => null, (items) { - for (final item in items) { - allItems.add({ - 'Collection': collection.name, + final collectionById = { + for (final collection in collections) collection.id: collection, + }; + final tagNameById = { + for (final tag in tags) tag.id: tag.name, + }; + final tagsByItemId = >{}; + for (final relation in itemTags) { + final tagName = tagNameById[relation.tagId]; + if (tagName == null) { + continue; + } + tagsByItemId + .putIfAbsent(relation.itemId, () => []) + .add(tagName); + } + + final rows = items + .map((item) { + final collectionName = + collectionById[item.collectionId]?.name ?? + item.collectionId; + final itemTags = (tagsByItemId[item.id] ?? const []) + .join(', '); + + return { + 'Collection': collectionName, 'Title': item.title, + 'Quantity': item.quantity.toString(), + 'Current Value': item.currentValue?.toString() ?? '', + 'Purchase Price': item.purchasePrice?.toString() ?? '', 'Barcode': item.barcode ?? '', + 'Tags': itemTags, 'Description': item.description ?? '', - 'Condition': item.condition?.name ?? '', - 'Quantity': item.quantity.toString(), + 'Condition': item.condition ?? '', 'Location': item.location ?? '', 'Notes': item.notes ?? '', 'Favorite': item.isFavorite ? 'Yes' : 'No', - 'Purchase Price': item.purchasePrice?.toString() ?? '', - 'Created': item.createdAt.toString(), - }); - } - }); - } - itemCount = allItems.length; + 'Wishlist': item.isWishlist ? 'Yes' : 'No', + 'Created': item.createdAt.toIso8601String(), + 'Updated': item.updatedAt.toIso8601String(), + }; + }) + .toList(growable: false); - return exportService.exportToCsv(allItems); + return exportService.exportToCsv(rows); }), ); @@ -173,16 +268,20 @@ class ExportImportViewModel extends _$ExportImportViewModel { stopwatch.stop(); if (result.hasError) { + final error = result.error; + if (error is UserCancelledStorageOperationException) { + throw error; + } await OperationalTelemetry.trackDataTransfer( operation: 'export_csv', success: false, durationMs: stopwatch.elapsedMilliseconds, collectionCount: collectionCount, itemCount: itemCount, - error: result.error, + error: error, stackTrace: result.stackTrace, ); - throw result.error!; + throw error!; } await OperationalTelemetry.trackDataTransfer( @@ -196,89 +295,112 @@ class ExportImportViewModel extends _$ExportImportViewModel { return result.value!; } + Future prepareJsonImportPreview() async { + final exportService = ExportImportService(); + final picked = await exportService.pickJsonImportFile(); + final payload = picked.data; + + final warnings = []; + final version = _nullableString(payload['version']); + final schema = _nullableString(payload['schema']); + + if (schema != null && schema != 'collection_tracker_backup') { + warnings.add('Backup schema "$schema" is not recognized.'); + } + + final collectionCount = _countListSection(payload, 'collections', warnings); + final itemCount = _countListSection(payload, 'items', warnings); + final tagCount = _countListSection(payload, 'tags', warnings); + final itemTagCount = _countListSection(payload, 'itemTags', warnings); + final priceHistoryCount = _countListSection( + payload, + 'priceHistory', + warnings, + ); + final loanCount = _countListSection(payload, 'loans', warnings); + + if (collectionCount == 0 && itemCount == 0) { + warnings.add('No collections or items found in this backup.'); + } + + return JsonImportPreview( + fileName: picked.fileName, + version: version, + schema: schema, + collectionCount: collectionCount, + itemCount: itemCount, + tagCount: tagCount, + itemTagCount: itemTagCount, + priceHistoryCount: priceHistoryCount, + loanCount: loanCount, + warnings: warnings, + payload: payload, + ); + } + Future importFromJson() async { + final exportService = ExportImportService(); + final picked = await exportService.pickJsonImportFile(); + await importFromJsonPayload(picked.data); + } + + Future importFromJsonPayload(Map data) async { state = const AsyncValue.loading(); final performanceService = FirebasePerformanceService.instance; final stopwatch = Stopwatch()..start(); + final db = ref.read(appDatabaseProvider); var collectionCount = 0; var itemCount = 0; final result = await AsyncValue.guard( - () => performanceService.traceAsync( - 'settings_import_data_json', - () async { - final collectionDao = ref.read(collectionDaoProvider); - final itemDao = ref.read(itemDaoProvider); - final exportService = ExportImportService(); + () => + performanceService.traceAsync('settings_import_data_json', () async { + final collections = _readMapList(data, 'collections'); + final items = _readMapList(data, 'items'); + final tags = _readMapList(data, 'tags'); + final itemTags = _readMapList(data, 'itemTags'); + final priceHistory = _readMapList(data, 'priceHistory'); + final loans = _readMapList(data, 'loans'); - final data = await exportService.importFromJson(); + if (collections.isEmpty && items.isEmpty) { + throw const FormatException( + 'Invalid backup format: collections/items are missing.', + ); + } - final collections = data['collections'] as List; - collectionCount = collections.length; - for (final collectionData in collections) { - final companion = CollectionsCompanion( - id: Value(collectionData['id'] as String), - name: Value(collectionData['name'] as String), - type: Value(collectionData['type'] as String), - description: Value(collectionData['description'] as String?), - itemCount: Value(collectionData['itemCount'] as int), - createdAt: Value( - DateTime.parse(collectionData['createdAt'] as String), - ), - updatedAt: Value( - DateTime.parse(collectionData['updatedAt'] as String), - ), - ); - await collectionDao.insertCollection(companion); - } + collectionCount = collections.length; + itemCount = items.length; - final items = data['items'] as List; - itemCount = items.length; - for (final itemData in items) { - final companion = ItemsCompanion( - id: Value(itemData['id'] as String), - collectionId: Value(itemData['collectionId'] as String), - title: Value(itemData['title'] as String), - barcode: Value(itemData['barcode'] as String?), - description: Value(itemData['description'] as String?), - condition: Value(itemData['condition'] as String?), - quantity: Value(itemData['quantity'] as int), - location: Value(itemData['location'] as String?), - notes: Value(itemData['notes'] as String?), - isFavorite: Value(itemData['isFavorite'] as bool), - purchasePrice: Value( - itemData['purchasePrice'] != null - ? (itemData['purchasePrice'] as num).toDouble() - : null, - ), - purchaseDate: Value( - itemData['purchaseDate'] != null - ? DateTime.parse(itemData['purchaseDate'] as String) - : null, - ), - createdAt: Value(DateTime.parse(itemData['createdAt'] as String)), - updatedAt: Value(DateTime.parse(itemData['updatedAt'] as String)), - ); - await itemDao.insertItem(companion); - } - }, - ), + await db.transaction(() async { + await _importCollections(db, collections); + await _importItems(db, items); + final tagIdRemap = await _importTags(db, tags); + await _importItemTags(db, itemTags, tagIdRemap); + await _importPriceHistory(db, priceHistory); + await _importLoans(db, loans); + await _recalculateCollectionItemCounts(db); + }); + }), ); _setStateSafely(result); stopwatch.stop(); if (result.hasError) { + final error = result.error; + if (error is UserCancelledStorageOperationException) { + throw error; + } await OperationalTelemetry.trackDataTransfer( operation: 'import_json', success: false, durationMs: stopwatch.elapsedMilliseconds, collectionCount: collectionCount, itemCount: itemCount, - error: result.error, + error: error, stackTrace: result.stackTrace, ); - throw result.error!; + throw error!; } await OperationalTelemetry.trackDataTransfer( @@ -290,11 +412,386 @@ class ExportImportViewModel extends _$ExportImportViewModel { ); } - void _setStateSafely(AsyncValue value) { + Future _importCollections( + AppDatabase db, + List> collections, + ) async { + for (final raw in collections) { + final id = _requiredString(raw, 'id'); + final name = _requiredString(raw, 'name'); + final type = _nullableString(raw['type']) ?? 'custom'; + final now = DateTime.now(); + + await db + .into(db.collections) + .insert( + CollectionsCompanion( + id: Value(id), + name: Value(name), + type: Value(type), + description: Value(_nullableString(raw['description'])), + coverImagePath: Value(_nullableString(raw['coverImagePath'])), + itemCount: Value(_nullableInt(raw['itemCount']) ?? 0), + createdAt: Value(_dateTimeOr(raw['createdAt'], now)), + updatedAt: Value(_dateTimeOr(raw['updatedAt'], now)), + ), + mode: InsertMode.insertOrReplace, + ); + } + } + + Future _importItems( + AppDatabase db, + List> items, + ) async { + final collections = await db.select(db.collections).get(); + final validCollectionIds = collections.map((e) => e.id).toSet(); + + for (final raw in items) { + final collectionId = _requiredString(raw, 'collectionId'); + if (!validCollectionIds.contains(collectionId)) { + continue; + } + + final id = _requiredString(raw, 'id'); + final title = _requiredString(raw, 'title'); + final now = DateTime.now(); + + await db + .into(db.items) + .insert( + ItemsCompanion( + id: Value(id), + collectionId: Value(collectionId), + title: Value(title), + barcode: Value(_nullableString(raw['barcode'])), + coverImageUrl: Value(_nullableString(raw['coverImageUrl'])), + coverImagePath: Value(_nullableString(raw['coverImagePath'])), + description: Value(_nullableString(raw['description'])), + notes: Value(_nullableString(raw['notes'])), + metadata: Value(_stringOrJson(raw['metadata'])), + condition: Value(_nullableString(raw['condition'])), + purchasePrice: Value(_nullableDouble(raw['purchasePrice'])), + purchaseDate: Value(_nullableDateTime(raw['purchaseDate'])), + currentValue: Value(_nullableDouble(raw['currentValue'])), + location: Value(_nullableString(raw['location'])), + isFavorite: Value(_boolOr(raw['isFavorite'], false)), + isWishlist: Value(_boolOr(raw['isWishlist'], false)), + sortOrder: Value(_nullableInt(raw['sortOrder']) ?? 0), + quantity: Value(_nullableInt(raw['quantity']) ?? 1), + createdAt: Value(_dateTimeOr(raw['createdAt'], now)), + updatedAt: Value(_dateTimeOr(raw['updatedAt'], now)), + ), + mode: InsertMode.insertOrReplace, + ); + } + } + + Future> _importTags( + AppDatabase db, + List> tags, + ) async { + final existingTags = await db.select(db.tags).get(); + final knownTagIds = {for (final tag in existingTags) tag.id}; + final knownTagNameToId = { + for (final tag in existingTags) tag.name.trim().toLowerCase(): tag.id, + }; + final importedToResolvedTagId = {}; + + for (final raw in tags) { + final importedId = _requiredString(raw, 'id'); + final name = _requiredString(raw, 'name'); + final normalizedName = name.trim().toLowerCase(); + final now = DateTime.now(); + + final targetId = knownTagIds.contains(importedId) + ? importedId + : (knownTagNameToId[normalizedName] ?? importedId); + + importedToResolvedTagId[importedId] = targetId; + + await db + .into(db.tags) + .insert( + TagsCompanion( + id: Value(targetId), + name: Value(name), + color: Value(_nullableString(raw['color'])), + createdAt: Value(_dateTimeOr(raw['createdAt'], now)), + updatedAt: Value(_dateTimeOr(raw['updatedAt'], now)), + ), + mode: InsertMode.insertOrReplace, + ); + + knownTagIds.add(targetId); + knownTagNameToId[normalizedName] = targetId; + } + + return importedToResolvedTagId; + } + + Future _importItemTags( + AppDatabase db, + List> itemTags, + Map tagIdRemap, + ) async { + final itemIds = (await db.select(db.items).get()).map((e) => e.id).toSet(); + final tagIds = (await db.select(db.tags).get()).map((e) => e.id).toSet(); + + for (final raw in itemTags) { + final itemId = _requiredString(raw, 'itemId'); + final importedTagId = _requiredString(raw, 'tagId'); + final resolvedTagId = tagIdRemap[importedTagId] ?? importedTagId; + + if (!itemIds.contains(itemId) || !tagIds.contains(resolvedTagId)) { + continue; + } + + await db + .into(db.itemTags) + .insert( + ItemTagsCompanion.insert(itemId: itemId, tagId: resolvedTagId), + mode: InsertMode.insertOrIgnore, + ); + } + } + + Future _importPriceHistory( + AppDatabase db, + List> priceHistory, + ) async { + final itemIds = (await db.select(db.items).get()).map((e) => e.id).toSet(); + + for (final raw in priceHistory) { + final id = _requiredString(raw, 'id'); + final itemId = _requiredString(raw, 'itemId'); + if (!itemIds.contains(itemId)) { + continue; + } + + final value = _nullableDouble(raw['value']); + if (value == null) { + continue; + } + + final now = DateTime.now(); + await db + .into(db.itemPriceHistory) + .insert( + ItemPriceHistoryCompanion( + id: Value(id), + itemId: Value(itemId), + value: Value(value), + recordedAt: Value(_dateTimeOr(raw['recordedAt'], now)), + source: Value(_nullableString(raw['source']) ?? 'manual'), + ), + mode: InsertMode.insertOrReplace, + ); + } + } + + Future _importLoans( + AppDatabase db, + List> loans, + ) async { + final itemIds = (await db.select(db.items).get()).map((e) => e.id).toSet(); + + for (final raw in loans) { + final id = _requiredString(raw, 'id'); + final itemId = _requiredString(raw, 'itemId'); + if (!itemIds.contains(itemId)) { + continue; + } + + final borrowerName = _requiredString(raw, 'borrowerName'); + final now = DateTime.now(); + + await db + .into(db.itemLoans) + .insert( + ItemLoansCompanion( + id: Value(id), + itemId: Value(itemId), + borrowerName: Value(borrowerName), + borrowerContact: Value(_nullableString(raw['borrowerContact'])), + notes: Value(_nullableString(raw['notes'])), + loanedAt: Value(_dateTimeOr(raw['loanedAt'], now)), + dueAt: Value(_nullableDateTime(raw['dueAt'])), + returnedAt: Value(_nullableDateTime(raw['returnedAt'])), + createdAt: Value(_dateTimeOr(raw['createdAt'], now)), + updatedAt: Value(_dateTimeOr(raw['updatedAt'], now)), + ), + mode: InsertMode.insertOrReplace, + ); + } + } + + Future _recalculateCollectionItemCounts(AppDatabase db) async { + final allCollections = await db.select(db.collections).get(); + final now = DateTime.now(); + + for (final collection in allCollections) { + final row = await db + .customSelect( + ''' + SELECT COUNT(*) AS count + FROM items + WHERE collection_id = ? + ''', + variables: [Variable(collection.id)], + readsFrom: {db.items}, + ) + .getSingle(); + final count = row.read('count'); + + await (db.update( + db.collections, + )..where((tbl) => tbl.id.equals(collection.id))).write( + CollectionsCompanion(itemCount: Value(count), updatedAt: Value(now)), + ); + } + } + + int _countListSection( + Map source, + String key, + List warnings, + ) { + final value = source[key]; + if (value == null) { + return 0; + } + if (value is! List) { + warnings.add('Section "$key" has invalid format and will be skipped.'); + return 0; + } + return value.length; + } + + List> _readMapList( + Map source, + String key, + ) { + final value = source[key]; + if (value == null) { + return const >[]; + } + if (value is! List) { + throw FormatException('Invalid backup format: "$key" must be a list.'); + } + + return value + .map>((entry) { + if (entry is Map) { + return entry; + } + if (entry is Map) { + return entry.map( + (mapKey, mapValue) => MapEntry(mapKey.toString(), mapValue), + ); + } + throw FormatException( + 'Invalid backup format: "$key" contains a non-object element.', + ); + }) + .toList(growable: false); + } + + String _requiredString(Map source, String key) { + final value = _nullableString(source[key]); + if (value == null) { + throw FormatException('Invalid backup data: "$key" is required.'); + } + return value; + } + + String? _nullableString(dynamic value) { + if (value == null) { + return null; + } + final normalized = value.toString().trim(); + return normalized.isEmpty ? null : normalized; + } + + String? _stringOrJson(dynamic value) { + if (value == null) { + return null; + } + if (value is String) { + final normalized = value.trim(); + return normalized.isEmpty ? null : normalized; + } + return jsonEncode(value); + } + + int? _nullableInt(dynamic value) { + if (value == null) { + return null; + } + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + return int.tryParse(value.toString()); + } + + double? _nullableDouble(dynamic value) { + if (value == null) { + return null; + } + if (value is double) { + return value; + } + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value.toString()); + } + + bool _boolOr(dynamic value, bool fallback) { + if (value is bool) { + return value; + } + if (value is num) { + return value != 0; + } + if (value is String) { + final normalized = value.trim().toLowerCase(); + if (normalized == 'true' || normalized == 'yes' || normalized == '1') { + return true; + } + if (normalized == 'false' || normalized == 'no' || normalized == '0') { + return false; + } + } + return fallback; + } + + DateTime _dateTimeOr(dynamic value, DateTime fallback) { + return _nullableDateTime(value) ?? fallback; + } + + DateTime? _nullableDateTime(dynamic value) { + if (value == null) { + return null; + } + if (value is DateTime) { + return value; + } + final text = value.toString().trim(); + if (text.isEmpty) { + return null; + } + return DateTime.tryParse(text); + } + + void _setStateSafely(AsyncValue value) { try { - state = value; + state = value.whenData((_) {}); } catch (_) { - // Ignore if provider was auto-disposed while async work was running. + // Ignore if provider was disposed while async work was running. } } } diff --git a/apps/mobile/lib/features/settings/presentation/views/settings_data_transfer_screen.dart b/apps/mobile/lib/features/settings/presentation/views/settings_data_transfer_screen.dart new file mode 100644 index 0000000..536367e --- /dev/null +++ b/apps/mobile/lib/features/settings/presentation/views/settings_data_transfer_screen.dart @@ -0,0 +1,368 @@ +import 'package:collection_tracker/l10n/l10n.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:storage/storage.dart'; +import 'package:ui/ui.dart'; + +import '../view_models/export_import_view_model.dart'; +import '../widgets/settings_primitives.dart'; + +class SettingsDataTransferScreen extends ConsumerStatefulWidget { + const SettingsDataTransferScreen({super.key}); + + @override + ConsumerState createState() => + _SettingsDataTransferScreenState(); +} + +class _SettingsDataTransferScreenState + extends ConsumerState { + bool _isBusy = false; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Scaffold( + appBar: AppBar( + title: Text( + '${l10n.settingsExportJsonTitle} / ${l10n.settingsImportJsonTitle}', + ), + ), + body: ListView( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.md, + AppSpacing.lg, + AppSpacing.xxl, + ), + children: [ + AppReveal( + child: AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.settingsSectionData, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: AppSpacing.sm), + Text( + l10n.settingsExportJsonSubtitle, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.xs), + Text( + l10n.settingsImportJsonSubtitle, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + const SizedBox(height: AppSpacing.lg), + AppReveal( + delay: AppMotion.stagger, + child: SettingsSection( + title: l10n.settingsExportJsonTitle, + children: [ + SettingsTile( + icon: Icons.file_download_outlined, + title: l10n.settingsExportJsonTitle, + subtitle: l10n.settingsExportJsonSubtitle, + enabled: !_isBusy, + onTap: _isBusy ? null : _handleExportJson, + ), + SettingsTile( + icon: Icons.table_chart_outlined, + title: l10n.settingsExportCsvTitle, + subtitle: l10n.settingsExportCsvSubtitle, + enabled: !_isBusy, + onTap: _isBusy ? null : _handleExportCsv, + ), + ], + ), + ), + const SizedBox(height: AppSpacing.lg), + AppReveal( + delay: AppMotion.stagger * 2, + child: SettingsSection( + title: l10n.settingsImportJsonTitle, + children: [ + SettingsTile( + icon: Icons.file_upload_outlined, + title: l10n.settingsImportJsonTitle, + subtitle: l10n.settingsImportJsonSubtitle, + enabled: !_isBusy, + onTap: _isBusy ? null : _handleImportJson, + ), + ], + ), + ), + ], + ), + ); + } + + Future _handleExportJson() async { + final l10n = context.l10n; + final messenger = ScaffoldMessenger.of(context); + if (_isBusy) { + return; + } + + setState(() => _isBusy = true); + try { + messenger.showSnackBar( + SnackBar(content: Text(l10n.settingsExportingData)), + ); + await ref + .read(exportImportViewModelProvider.notifier) + .exportAllDataToJson(); + if (!mounted) { + return; + } + messenger.showSnackBar( + SnackBar( + content: Text(l10n.settingsDataExportSuccess), + backgroundColor: Colors.green, + ), + ); + } on UserCancelledStorageOperationException { + // User canceled picker/save dialog. Keep silent. + } catch (error) { + if (!mounted) { + return; + } + messenger.showSnackBar( + SnackBar( + content: Text(l10n.settingsExportFailed('$error')), + backgroundColor: Colors.red, + ), + ); + } finally { + if (mounted) { + setState(() => _isBusy = false); + } + } + } + + Future _handleExportCsv() async { + final l10n = context.l10n; + final messenger = ScaffoldMessenger.of(context); + if (_isBusy) { + return; + } + + setState(() => _isBusy = true); + try { + messenger.showSnackBar( + SnackBar(content: Text(l10n.settingsExportingData)), + ); + await ref.read(exportImportViewModelProvider.notifier).exportItemsToCsv(); + if (!mounted) { + return; + } + messenger.showSnackBar( + SnackBar( + content: Text(l10n.settingsDataExportSuccess), + backgroundColor: Colors.green, + ), + ); + } on UserCancelledStorageOperationException { + // User canceled picker/save dialog. Keep silent. + } catch (error) { + if (!mounted) { + return; + } + messenger.showSnackBar( + SnackBar( + content: Text(l10n.settingsExportFailed('$error')), + backgroundColor: Colors.red, + ), + ); + } finally { + if (mounted) { + setState(() => _isBusy = false); + } + } + } + + Future _handleImportJson() async { + final l10n = context.l10n; + final messenger = ScaffoldMessenger.of(context); + if (_isBusy) { + return; + } + + setState(() => _isBusy = true); + try { + final preview = await ref + .read(exportImportViewModelProvider.notifier) + .prepareJsonImportPreview(); + if (!mounted) { + return; + } + + final confirmed = await _showImportPreviewDialog(preview); + if (confirmed != true || !mounted) { + return; + } + + messenger.showSnackBar( + SnackBar(content: Text(l10n.settingsImportingData)), + ); + await ref + .read(exportImportViewModelProvider.notifier) + .importFromJsonPayload(preview.payload); + if (!mounted) { + return; + } + messenger.showSnackBar( + SnackBar( + content: Text(l10n.settingsDataImportSuccess), + backgroundColor: Colors.green, + ), + ); + } on UserCancelledStorageOperationException { + // User canceled picker/save dialog. Keep silent. + } catch (error) { + if (!mounted) { + return; + } + messenger.showSnackBar( + SnackBar( + content: Text(l10n.settingsImportFailed('$error')), + backgroundColor: Colors.red, + ), + ); + } finally { + if (mounted) { + setState(() => _isBusy = false); + } + } + } + + Future _showImportPreviewDialog(JsonImportPreview preview) { + final l10n = context.l10n; + final colorScheme = Theme.of(context).colorScheme; + final warningText = preview.warnings.join('\n'); + + return showAppDialog( + context: context, + title: Text(l10n.settingsImportDataTitle), + content: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 420), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.settingsImportDataMessage, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.md), + _PreviewRow(label: 'File', value: preview.fileName), + if (preview.version != null) + _PreviewRow(label: 'Version', value: preview.version!), + if (preview.schema != null) + _PreviewRow(label: 'Schema', value: preview.schema!), + const SizedBox(height: AppSpacing.sm), + _PreviewRow( + label: 'Collections', + value: preview.collectionCount.toString(), + ), + _PreviewRow(label: 'Items', value: preview.itemCount.toString()), + _PreviewRow(label: 'Tags', value: preview.tagCount.toString()), + _PreviewRow( + label: 'Item-Tag Links', + value: preview.itemTagCount.toString(), + ), + _PreviewRow( + label: 'Price History', + value: preview.priceHistoryCount.toString(), + ), + _PreviewRow(label: 'Loans', value: preview.loanCount.toString()), + if (warningText.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.md), + Container( + width: double.infinity, + padding: const EdgeInsets.all(AppSpacing.sm), + decoration: BoxDecoration( + color: colorScheme.errorContainer.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(AppRadii.sm), + border: Border.all( + color: colorScheme.error.withValues(alpha: 0.35), + ), + ), + child: Text( + warningText, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onErrorContainer, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ], + ), + ), + ), + actions: [ + AppButton( + label: l10n.actionCancel, + variant: AppButtonVariant.ghost, + onPressed: () => closeAppDialog(context, false), + ), + AppButton( + label: l10n.actionImport, + onPressed: () => closeAppDialog(context, true), + ), + ], + ); + } +} + +class _PreviewRow extends StatelessWidget { + const _PreviewRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.xs), + child: Row( + children: [ + Expanded( + child: Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w700), + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/lib/features/settings/presentation/views/settings_devtools_screen.dart b/apps/mobile/lib/features/settings/presentation/views/settings_devtools_screen.dart index c8a3759..162f242 100644 --- a/apps/mobile/lib/features/settings/presentation/views/settings_devtools_screen.dart +++ b/apps/mobile/lib/features/settings/presentation/views/settings_devtools_screen.dart @@ -718,6 +718,18 @@ class SettingsDevToolsScreen extends ConsumerWidget { ), ), ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.auto_awesome_outlined), + title: const Text('Metadata fetching'), + subtitle: const Text('app_metadata_feature_enabled'), + trailing: Text( + _enabledDisabledLabel( + sheetContext, + runtimeConfig.metadataFeatureEnabled, + ), + ), + ), ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.hub_outlined), diff --git a/apps/mobile/lib/features/settings/presentation/views/settings_metadata_screen.dart b/apps/mobile/lib/features/settings/presentation/views/settings_metadata_screen.dart new file mode 100644 index 0000000..e549cda --- /dev/null +++ b/apps/mobile/lib/features/settings/presentation/views/settings_metadata_screen.dart @@ -0,0 +1,184 @@ +import 'package:collection_tracker/core/providers/metadata_preferences_provider.dart'; +import 'package:collection_tracker/core/providers/metadata_providers.dart'; +import 'package:collection_tracker/l10n/l10n.dart'; +import 'package:domain/domain.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:ui/ui.dart'; + +import '../widgets/settings_primitives.dart'; + +class SettingsMetadataScreen extends ConsumerWidget { + const SettingsMetadataScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final preferences = ref.watch(metadataPreferencesProvider); + final notifier = ref.read(metadataPreferencesProvider.notifier); + final metadataService = ref.read(metadataLookupServiceProvider); + + final canConfigure = preferences.runtimeFeatureEnabled; + final canTuneAutofill = preferences.isEnabled; + + return Scaffold( + appBar: AppBar(title: Text(l10n.settingsMetadataTitle)), + body: ListView( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.md, + AppSpacing.lg, + AppSpacing.xxl, + ), + children: [ + AppReveal( + child: AppCard( + child: Text( + _statusMessage(context, preferences), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ), + const SizedBox(height: AppSpacing.lg), + AppReveal( + delay: AppMotion.stagger, + child: SettingsSection( + title: l10n.settingsSectionGeneral, + children: [ + SwitchListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + ), + title: Text(l10n.settingsMetadataEnableToggleTitle), + subtitle: Text(l10n.settingsMetadataEnableToggleSubtitle), + value: preferences.preferenceEnabled, + onChanged: canConfigure + ? notifier.setPreferenceEnabled + : null, + ), + SwitchListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + ), + title: Text(l10n.settingsMetadataAutoFetchToggleTitle), + subtitle: Text(l10n.settingsMetadataAutoFetchToggleSubtitle), + value: preferences.autoFetchBarcodeEnabled, + onChanged: canTuneAutofill + ? notifier.setAutoFetchBarcodeEnabled + : null, + ), + SwitchListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + ), + title: Text(l10n.settingsMetadataFillEmptyOnlyToggleTitle), + subtitle: Text( + l10n.settingsMetadataFillEmptyOnlyToggleSubtitle, + ), + value: preferences.fillOnlyEmptyFields, + onChanged: canTuneAutofill + ? notifier.setFillOnlyEmptyFields + : null, + ), + ], + ), + ), + const SizedBox(height: AppSpacing.lg), + AppReveal( + delay: AppMotion.stagger * 2, + child: SettingsSection( + title: l10n.settingsMetadataSourcesSectionTitle, + children: [ + _MetadataSourceTile( + icon: Icons.menu_book_rounded, + title: l10n.collectionTypeBooks, + source: 'Google Books', + statusText: l10n.settingsMetadataSourceAvailable, + enabled: true, + ), + _MetadataSourceTile( + icon: Icons.sports_esports_rounded, + title: l10n.collectionTypeGames, + source: 'IGDB', + statusText: + metadataService.supportsSearch(CollectionType.game) + ? l10n.settingsMetadataSourceAvailable + : l10n.settingsMetadataSourceNotConfigured, + enabled: metadataService.supportsSearch(CollectionType.game), + ), + _MetadataSourceTile( + icon: Icons.movie_creation_rounded, + title: l10n.collectionTypeMovies, + source: 'TMDB', + statusText: + metadataService.supportsSearch(CollectionType.movie) + ? l10n.settingsMetadataSourceAvailable + : l10n.settingsMetadataSourceNotConfigured, + enabled: metadataService.supportsSearch(CollectionType.movie), + ), + _MetadataSourceTile( + icon: Icons.category_rounded, + title: l10n.collectionTypeCustom, + source: l10n.settingsMetadataManualCollectionsLabel, + statusText: l10n.settingsMetadataSourceManualOnly, + enabled: false, + ), + ], + ), + ), + ], + ), + ); + } + + String _statusMessage( + BuildContext context, + MetadataPreferencesState preferences, + ) { + final l10n = context.l10n; + if (!preferences.runtimeFeatureEnabled) { + return l10n.settingsMetadataSummaryFeatureDisabled; + } + if (!preferences.preferenceEnabled) { + return l10n.settingsMetadataSummaryDisabled; + } + return l10n.settingsMetadataSummaryEnabled; + } +} + +class _MetadataSourceTile extends StatelessWidget { + const _MetadataSourceTile({ + required this.icon, + required this.title, + required this.source, + required this.statusText, + required this.enabled, + }); + + final IconData icon; + final String title; + final String source; + final String statusText; + final bool enabled; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final statusColor = enabled ? colors.primary : colors.onSurfaceVariant; + + return ListTile( + leading: Icon(icon, color: colors.primary), + title: Text(title), + subtitle: Text(source), + trailing: Text( + statusText, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: statusColor, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} diff --git a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart index 59bc1cf..902abb4 100644 --- a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart +++ b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart @@ -5,15 +5,14 @@ import 'package:collection_tracker/core/analytics/analytics_preferences.dart'; import 'package:collection_tracker/core/observability/operational_telemetry.dart'; import 'package:collection_tracker/core/providers/providers.dart'; import 'package:collection_tracker/core/router/routes.dart'; +import 'package:collection_tracker/features/app_update/presentation/providers/app_update_providers.dart'; import 'package:collection_tracker/l10n/l10n.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:storage/storage.dart'; import 'package:ui/ui.dart'; -import '../view_models/export_import_view_model.dart'; import '../widgets/settings_primitives.dart'; class SettingsScreen extends ConsumerWidget { @@ -26,10 +25,13 @@ class SettingsScreen extends ConsumerWidget { final currentLanguage = ref.watch(localeSettingsProvider); final analyticsPreferences = ref.watch(analyticsPreferencesProvider); final pushPreferences = ref.watch(pushNotificationPreferencesProvider); + final metadataPreferences = ref.watch(metadataPreferencesProvider); final syncReadiness = ref.watch(syncReadinessProvider); final accountReadiness = ref.watch(backendAuthReadinessProvider); + final appUpdateSummary = ref.watch(appUpdateSummaryProvider); final pendingSyncCount = ref.watch(syncOutboxCountProvider).value ?? 0; final authSession = ref.watch(authSessionProvider).value; + final appVersionLabel = ref.watch(appDisplayVersionProvider); final themeSummary = '${_themeModeLabel(context, themeSettings.mode)} - ${themeSettings.variant.label}'; @@ -42,6 +44,7 @@ class SettingsScreen extends ConsumerWidget { pendingSyncCount: pendingSyncCount, ); final pushSummary = _pushNotificationSummary(pushPreferences); + final metadataSummary = _metadataSummary(context, metadataPreferences); final cloudSyncFeatureEnabled = syncReadiness.status != SyncReadinessStatus.disabledByFeatureFlag; @@ -88,21 +91,31 @@ class SettingsScreen extends ConsumerWidget { subtitle: analyticsSummary, onTap: () => _showAnalyticsSettings(context, ref), ), - SettingsTile( - icon: Icons.person_outline_rounded, - title: 'Account', - subtitle: accountSummary, - enabled: accountFeatureEnabled, - onTap: accountFeatureEnabled - ? () => context.push(Routes.auth) - : null, - ), + if (accountFeatureEnabled) + SettingsTile( + icon: Icons.person_outline_rounded, + title: 'Account', + subtitle: accountSummary, + onTap: () => context.push(Routes.auth), + ), SettingsTile( icon: Icons.notifications_outlined, title: 'Push Notifications', subtitle: pushSummary, onTap: () => context.push(Routes.settingsNotifications), ), + SettingsTile( + icon: Icons.auto_awesome_outlined, + title: l10n.settingsMetadataTitle, + subtitle: metadataSummary, + onTap: () => context.push(Routes.settingsMetadata), + ), + SettingsTile( + icon: Icons.system_update_alt_rounded, + title: 'App Update', + subtitle: appUpdateSummary, + onTap: () => context.push(Routes.settingsAppUpdate), + ), ], ), ), @@ -113,22 +126,12 @@ class SettingsScreen extends ConsumerWidget { title: l10n.settingsSectionData, children: [ SettingsTile( - icon: Icons.file_download, - title: l10n.settingsExportJsonTitle, - subtitle: l10n.settingsExportJsonSubtitle, - onTap: () => _handleExportJson(context, ref), - ), - SettingsTile( - icon: Icons.table_chart, - title: l10n.settingsExportCsvTitle, - subtitle: l10n.settingsExportCsvSubtitle, - onTap: () => _handleExportCsv(context, ref), - ), - SettingsTile( - icon: Icons.file_upload, - title: l10n.settingsImportJsonTitle, - subtitle: l10n.settingsImportJsonSubtitle, - onTap: () => _handleImportJson(context, ref), + icon: Icons.folder_shared_outlined, + title: + '${l10n.settingsExportJsonTitle} / ${l10n.settingsImportJsonTitle}', + subtitle: + '${l10n.settingsExportJsonSubtitle}. ${l10n.settingsImportJsonSubtitle}.', + onTap: () => context.push(Routes.settingsDataTransfer), ), SettingsTile( icon: Icons.cloud_upload, @@ -145,6 +148,12 @@ class SettingsScreen extends ConsumerWidget { subtitle: l10n.settingsManageTagsSubtitle, onTap: () => context.push(Routes.settingsTags), ), + SettingsTile( + icon: Icons.handshake_outlined, + title: l10n.settingsLoanTrackingTitle, + subtitle: l10n.settingsLoanTrackingSubtitle, + onTap: () => context.push(Routes.settingsLoans), + ), ], ), ), @@ -157,7 +166,7 @@ class SettingsScreen extends ConsumerWidget { SettingsTile( icon: Icons.info, title: l10n.settingsVersionTitle, - subtitle: '1.0.0', + subtitle: appVersionLabel, ), ], ), @@ -184,133 +193,6 @@ class SettingsScreen extends ConsumerWidget { ); } - Future _handleExportJson(BuildContext context, WidgetRef ref) async { - final l10n = context.l10n; - try { - final messenger = ScaffoldMessenger.of(context); - messenger.showSnackBar( - SnackBar(content: Text(l10n.settingsExportingData)), - ); - - final filePath = await ref - .read(exportImportViewModelProvider.notifier) - .exportAllDataToJson(); - - final exportService = ExportImportService(); - await exportService.shareFile(filePath, 'collection_tracker_export.json'); - - if (!context.mounted) { - return; - } - messenger.showSnackBar( - SnackBar( - content: Text(l10n.settingsDataExportSuccess), - backgroundColor: Colors.green, - ), - ); - } catch (error) { - if (!context.mounted) { - return; - } - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(l10n.settingsExportFailed('$error')), - backgroundColor: Colors.red, - ), - ); - } - } - - Future _handleExportCsv(BuildContext context, WidgetRef ref) async { - final l10n = context.l10n; - try { - final messenger = ScaffoldMessenger.of(context); - messenger.showSnackBar( - SnackBar(content: Text(l10n.settingsExportingData)), - ); - - final filePath = await ref - .read(exportImportViewModelProvider.notifier) - .exportItemsToCsv(); - - final exportService = ExportImportService(); - await exportService.shareFile(filePath, 'collection_tracker_export.csv'); - - if (!context.mounted) { - return; - } - messenger.showSnackBar( - SnackBar( - content: Text(l10n.settingsDataExportSuccess), - backgroundColor: Colors.green, - ), - ); - } catch (error) { - if (!context.mounted) { - return; - } - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(l10n.settingsExportFailed('$error')), - backgroundColor: Colors.red, - ), - ); - } - } - - Future _handleImportJson(BuildContext context, WidgetRef ref) async { - final l10n = context.l10n; - final confirmed = await showAppDialog( - context: context, - title: Text(l10n.settingsImportDataTitle), - content: Text(l10n.settingsImportDataMessage), - actions: [ - AppButton( - label: l10n.actionCancel, - variant: AppButtonVariant.ghost, - onPressed: () => closeAppDialog(context, false), - ), - AppButton( - label: l10n.actionImport, - onPressed: () => closeAppDialog(context, true), - ), - ], - ); - - if (confirmed != true || !context.mounted) { - return; - } - - try { - final messenger = ScaffoldMessenger.of(context); - messenger.showSnackBar( - SnackBar(content: Text(l10n.settingsImportingData)), - ); - - await ref.read(exportImportViewModelProvider.notifier).importFromJson(); - - if (!context.mounted) { - return; - } - messenger.showSnackBar( - SnackBar( - content: Text(l10n.settingsDataImportSuccess), - backgroundColor: Colors.green, - ), - ); - } catch (error) { - if (!context.mounted) { - return; - } - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(l10n.settingsImportFailed('$error')), - backgroundColor: Colors.red, - ), - ); - } - } - Future _showCloudSyncStatusSheet( BuildContext context, WidgetRef ref, @@ -468,13 +350,16 @@ class SettingsScreen extends ConsumerWidget { syncedCollections: result.syncedCollections, syncedItems: result.syncedItems, syncedTags: result.syncedTags, + syncedLoans: result.syncedLoans, conflictCount: result.conflictCount, appliedServerCollections: result.appliedServerCollections, appliedServerItems: result.appliedServerItems, appliedServerTags: result.appliedServerTags, + appliedServerLoans: result.appliedServerLoans, skippedServerCollections: result.skippedServerCollections, skippedServerItems: result.skippedServerItems, skippedServerTags: result.skippedServerTags, + skippedServerLoans: result.skippedServerLoans, message: result.message, error: result.error, stackTrace: result.stackTrace, @@ -487,7 +372,9 @@ class SettingsScreen extends ConsumerWidget { ? 'Prepared ${bootstrapResult.totalOperations} local change(s). ' : ''; final recoveryHint = bootstrapResult.skipped && !result.executed - ? ' If existing local data is missing on cloud, open Developer Tools and use "Rebuild local sync queue".' + ? kDebugMode + ? ' If existing local data is missing on cloud, open Developer Tools and use "Rebuild local sync queue".' + : '' : ''; ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -880,4 +767,21 @@ class SettingsScreen extends ConsumerWidget { return 'Enabled ($enabledTopics topics)'; } + + String _metadataSummary( + BuildContext context, + MetadataPreferencesState preferences, + ) { + final l10n = context.l10n; + if (!preferences.runtimeFeatureEnabled) { + return l10n.settingsMetadataSummaryFeatureDisabled; + } + if (!preferences.preferenceEnabled) { + return l10n.settingsMetadataSummaryDisabled; + } + if (!preferences.autoFetchBarcodeEnabled) { + return l10n.settingsMetadataSummaryManual; + } + return l10n.settingsMetadataSummaryEnabled; + } } diff --git a/apps/mobile/lib/features/statistics/presentation/views/statistics_screen.dart b/apps/mobile/lib/features/statistics/presentation/views/statistics_screen.dart index 9bc8d59..74776d4 100644 --- a/apps/mobile/lib/features/statistics/presentation/views/statistics_screen.dart +++ b/apps/mobile/lib/features/statistics/presentation/views/statistics_screen.dart @@ -83,7 +83,7 @@ class StatisticsScreen extends ConsumerWidget { final isCompact = width < 900 || textScale > 1.05; final crossAxisCount = isCompact ? 2 : 4; final tileHeight = switch (crossAxisCount) { - 2 => width < 460 || textScale > 1.0 ? 184.0 : 160.0, + 2 => width < 460 || textScale > 1.0 ? 210.0 : 186.0, _ => 136.0, }; final cards = [ diff --git a/apps/mobile/lib/l10n/arb/app_en.arb b/apps/mobile/lib/l10n/arb/app_en.arb index 96b0378..e8309c7 100644 --- a/apps/mobile/lib/l10n/arb/app_en.arb +++ b/apps/mobile/lib/l10n/arb/app_en.arb @@ -1,6 +1,6 @@ { "@@locale": "en", - "appTitle": "Collection Tracker", + "appTitle": "Collectra", "@appTitle": {}, "navHome": "Home", "@navHome": {}, @@ -68,6 +68,10 @@ "@settingsManageTagsTitle": {}, "settingsManageTagsSubtitle": "Rename, merge, and delete tags", "@settingsManageTagsSubtitle": {}, + "settingsLoanTrackingTitle": "Loan Tracking", + "@settingsLoanTrackingTitle": {}, + "settingsLoanTrackingSubtitle": "Track borrowed items and return dates", + "@settingsLoanTrackingSubtitle": {}, "settingsVersionTitle": "Version", "@settingsVersionTitle": {}, "settingsPrivacyPolicyTitle": "Privacy Policy", @@ -108,7 +112,7 @@ "@settingsAnalyticsConsentAccepted": {}, "settingsAnalyticsConsentDeclined": "Analytics consent declined.", "@settingsAnalyticsConsentDeclined": {}, - "analyticsConsentDialogTitle": "Help Improve Collection Tracker", + "analyticsConsentDialogTitle": "Help Improve Collectra", "@analyticsConsentDialogTitle": {}, "analyticsConsentDialogMessage": "Can we collect anonymous usage analytics to improve app quality and features? You can change this anytime in Settings.", "@analyticsConsentDialogMessage": {}, @@ -140,6 +144,40 @@ "@settingsFirebaseRuntimeConfigTitle": {}, "settingsFirebaseRuntimeConfigSubtitle": "Inspect and refresh runtime feature flags", "@settingsFirebaseRuntimeConfigSubtitle": {}, + "settingsMetadataTitle": "Metadata & Autofill", + "@settingsMetadataTitle": {}, + "settingsMetadataSummaryEnabled": "Enabled with automatic barcode lookup", + "@settingsMetadataSummaryEnabled": {}, + "settingsMetadataSummaryManual": "Enabled with manual lookup", + "@settingsMetadataSummaryManual": {}, + "settingsMetadataSummaryDisabled": "Disabled", + "@settingsMetadataSummaryDisabled": {}, + "settingsMetadataSummaryFeatureDisabled": "Disabled by runtime feature flag", + "@settingsMetadataSummaryFeatureDisabled": {}, + "settingsMetadataEnableToggleTitle": "Enable metadata assistance", + "@settingsMetadataEnableToggleTitle": {}, + "settingsMetadataEnableToggleSubtitle": "Allow metadata search and barcode-based autofill in item forms.", + "@settingsMetadataEnableToggleSubtitle": {}, + "settingsMetadataAutoFetchToggleTitle": "Auto-fetch from barcode scan", + "@settingsMetadataAutoFetchToggleTitle": {}, + "settingsMetadataAutoFetchToggleSubtitle": "After scanning a barcode, fetch metadata automatically.", + "@settingsMetadataAutoFetchToggleSubtitle": {}, + "settingsMetadataFillEmptyOnlyToggleTitle": "Fill empty fields only", + "@settingsMetadataFillEmptyOnlyToggleTitle": {}, + "settingsMetadataFillEmptyOnlyToggleSubtitle": "Do not overwrite existing title or description when metadata is found.", + "@settingsMetadataFillEmptyOnlyToggleSubtitle": {}, + "settingsMetadataSourcesSectionTitle": "Sources", + "@settingsMetadataSourcesSectionTitle": {}, + "settingsMetadataSourceAvailable": "Available", + "@settingsMetadataSourceAvailable": {}, + "settingsMetadataSourceNotConfigured": "Not configured", + "@settingsMetadataSourceNotConfigured": {}, + "settingsMetadataSourceManualOnly": "Manual only", + "@settingsMetadataSourceManualOnly": {}, + "settingsMetadataManualCollectionsLabel": "Comics, Music, and Custom", + "@settingsMetadataManualCollectionsLabel": {}, + "settingsMetadataFeatureDisabledMessage": "Metadata assistance is disabled by runtime configuration.", + "@settingsMetadataFeatureDisabledMessage": {}, "settingsFirebaseRuntimeConfigSheetTitle": "Firebase Runtime Config", "@settingsFirebaseRuntimeConfigSheetTitle": {}, "settingsFirebaseRuntimeConfigDescription": "Values are fetched from Firebase Remote Config and applied at runtime.", @@ -204,7 +242,7 @@ }, "settingsImportDataTitle": "Import Data", "@settingsImportDataTitle": {}, - "settingsImportDataMessage": "This will import collections and items from a JSON file. Existing data will not be deleted.\\n\\nContinue?", + "settingsImportDataMessage": "This will import collections and items from a JSON file. Existing data will not be deleted.\n\nContinue?", "@settingsImportDataMessage": {}, "settingsImportingData": "Importing data...", "@settingsImportingData": {}, @@ -609,6 +647,18 @@ "@metadataSearchSuggestionTitle": {}, "metadataSearchSuggestionMessage": "Start typing to look up metadata.", "@metadataSearchSuggestionMessage": {}, + "metadataSearchDisabledHint": "Metadata search is unavailable for this collection type or currently disabled.", + "@metadataSearchDisabledHint": {}, + "metadataNoMatchForBarcode": "No metadata match found for this barcode.", + "@metadataNoMatchForBarcode": {}, + "metadataSearchUnavailableForType": "Metadata search is unavailable for {collectionType}.", + "@metadataSearchUnavailableForType": { + "placeholders": { + "collectionType": { + "type": "String" + } + } + }, "tagItemsTitle": "Tag: {tag}", "@tagItemsTitle": { "placeholders": { @@ -749,7 +799,7 @@ }, "tagManagementDeleteSelectedTitle": "Delete Selected Tags", "@tagManagementDeleteSelectedTitle": {}, - "tagManagementDeleteSelectedMessage": "Delete {count} selected tags from all items?\\n\\nThis cannot be undone.", + "tagManagementDeleteSelectedMessage": "Delete {count} selected tags from all items?\n\nThis cannot be undone.", "@tagManagementDeleteSelectedMessage": { "placeholders": { "count": { @@ -780,7 +830,7 @@ }, "tagManagementDeleteTitle": "Delete Tag", "@tagManagementDeleteTitle": {}, - "tagManagementDeleteMessage": "Delete \"{tagName}\" from all items?\\n\\nThis cannot be undone.", + "tagManagementDeleteMessage": "Delete \"{tagName}\" from all items?\n\nThis cannot be undone.", "@tagManagementDeleteMessage": { "placeholders": { "tagName": { @@ -976,5 +1026,273 @@ "collectionTypeMusic": "Music", "@collectionTypeMusic": {}, "collectionTypeCustom": "Custom", - "@collectionTypeCustom": {} + "@collectionTypeCustom": {}, + "loanTrackingTitle": "Loan Tracking", + "@loanTrackingTitle": {}, + "loanTrackingNewLoan": "New Loan", + "@loanTrackingNewLoan": {}, + "loanTrackingFilterActive": "Active", + "@loanTrackingFilterActive": {}, + "loanTrackingFilterHistory": "History", + "@loanTrackingFilterHistory": {}, + "loanTrackingEmptyHistoryTitle": "No returned loans yet", + "@loanTrackingEmptyHistoryTitle": {}, + "loanTrackingEmptyHistoryMessage": "Returned items will appear here.", + "@loanTrackingEmptyHistoryMessage": {}, + "loanTrackingEmptyActiveTitle": "No active loans", + "@loanTrackingEmptyActiveTitle": {}, + "loanTrackingEmptyActiveMessage": "Create a loan to start tracking borrowed items.", + "@loanTrackingEmptyActiveMessage": {}, + "loanTrackingLoadingLoans": "Loading loans...", + "@loanTrackingLoadingLoans": {}, + "loanTrackingLoadFailed": "Failed to load loans: {error}", + "@loanTrackingLoadFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loanTrackingMarkReturnedConfirmTitle": "Mark as returned?", + "@loanTrackingMarkReturnedConfirmTitle": {}, + "loanTrackingMarkReturnedConfirmMessage": "Confirm return for \"{itemTitle}\".", + "@loanTrackingMarkReturnedConfirmMessage": { + "placeholders": { + "itemTitle": { + "type": "String" + } + } + }, + "loanTrackingMarkReturnedAction": "Mark Returned", + "@loanTrackingMarkReturnedAction": {}, + "loanTrackingMarkedReturnedSuccess": "Loan marked as returned.", + "@loanTrackingMarkedReturnedSuccess": {}, + "loanTrackingMarkReturnedFailed": "Failed to mark return: {error}", + "@loanTrackingMarkReturnedFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loanTrackingDeleteConfirmTitle": "Delete loan record?", + "@loanTrackingDeleteConfirmTitle": {}, + "loanTrackingDeleteConfirmMessage": "Delete loan record for \"{itemTitle}\".", + "@loanTrackingDeleteConfirmMessage": { + "placeholders": { + "itemTitle": { + "type": "String" + } + } + }, + "loanTrackingDeleteSuccess": "Loan deleted.", + "@loanTrackingDeleteSuccess": {}, + "loanTrackingDeleteFailed": "Failed to delete loan: {error}", + "@loanTrackingDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loanTrackingSummaryActiveLabel": "Active Loans", + "@loanTrackingSummaryActiveLabel": {}, + "loanTrackingSummaryOverdueLabel": "Overdue", + "@loanTrackingSummaryOverdueLabel": {}, + "loanTrackingSummaryLoadFailed": "Unable to load loan summary.", + "@loanTrackingSummaryLoadFailed": {}, + "loanTrackingFieldBorrower": "Borrower", + "@loanTrackingFieldBorrower": {}, + "loanTrackingFieldContact": "Contact", + "@loanTrackingFieldContact": {}, + "loanTrackingFieldLoaned": "Loaned", + "@loanTrackingFieldLoaned": {}, + "loanTrackingFieldDue": "Due", + "@loanTrackingFieldDue": {}, + "loanTrackingFieldReturned": "Returned", + "@loanTrackingFieldReturned": {}, + "loanTrackingStatusReturned": "Returned", + "@loanTrackingStatusReturned": {}, + "loanTrackingStatusOverdue": "Overdue", + "@loanTrackingStatusOverdue": {}, + "loanTrackingStatusActive": "Active", + "@loanTrackingStatusActive": {}, + "loanTrackingCreateTitle": "Create Loan", + "@loanTrackingCreateTitle": {}, + "loanTrackingCreateDescription": "Track who borrowed an item and when it should be returned.", + "@loanTrackingCreateDescription": {}, + "loanTrackingCreateNoItemsTitle": "No available items", + "@loanTrackingCreateNoItemsTitle": {}, + "loanTrackingCreateNoItemsMessage": "All items are currently loaned or there are no items yet.", + "@loanTrackingCreateNoItemsMessage": {}, + "loanTrackingCreateItemLabel": "Item", + "@loanTrackingCreateItemLabel": {}, + "loanTrackingCreateBorrowerLabel": "Borrower name", + "@loanTrackingCreateBorrowerLabel": {}, + "loanTrackingCreateBorrowerHint": "e.g. John Doe", + "@loanTrackingCreateBorrowerHint": {}, + "loanTrackingCreateContactLabel": "Contact (optional)", + "@loanTrackingCreateContactLabel": {}, + "loanTrackingCreateContactHint": "Phone, email, or @username", + "@loanTrackingCreateContactHint": {}, + "loanTrackingCreateNotesLabel": "Notes (optional)", + "@loanTrackingCreateNotesLabel": {}, + "loanTrackingCreateNotesHint": "Extra details for this loan", + "@loanTrackingCreateNotesHint": {}, + "loanTrackingCreateSubmitting": "Creating...", + "@loanTrackingCreateSubmitting": {}, + "loanTrackingCreateAction": "Create Loan", + "@loanTrackingCreateAction": {}, + "loanTrackingLoadingItems": "Loading items...", + "@loanTrackingLoadingItems": {}, + "loanTrackingLoadItemsFailed": "Failed to load items: {error}", + "@loanTrackingLoadItemsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loanTrackingBorrowerRequired": "Borrower name is required.", + "@loanTrackingBorrowerRequired": {}, + "loanTrackingCreateSuccess": "Loan created successfully.", + "@loanTrackingCreateSuccess": {}, + "loanTrackingCreateFailed": "Failed to create loan: {error}", + "@loanTrackingCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loanTrackingNoDueDate": "No due date", + "@loanTrackingNoDueDate": {}, + "loanTrackingPickDateAction": "Pick", + "@loanTrackingPickDateAction": {}, + "loanTrackingClearDateAction": "Clear", + "@loanTrackingClearDateAction": {}, + "loanTrackingDueDateLabel": "Due date", + "@loanTrackingDueDateLabel": {}, + "authTitleAccount": "Account", + "@authTitleAccount": {}, + "authCreateAccountHeading": "Create Account", + "@authCreateAccountHeading": {}, + "authSignInHeading": "Sign In", + "@authSignInHeading": {}, + "authCreateAccountDescription": "Create an account to sync your collections across devices.", + "@authCreateAccountDescription": {}, + "authSignInDescription": "Sign in to enable cloud sync and account features.", + "@authSignInDescription": {}, + "authSignInChoice": "Sign in", + "@authSignInChoice": {}, + "authRegisterChoice": "Register", + "@authRegisterChoice": {}, + "authEmailLabel": "Email", + "@authEmailLabel": {}, + "authEmailHint": "you@example.com", + "@authEmailHint": {}, + "authEmailRequiredError": "Email is required.", + "@authEmailRequiredError": {}, + "authEmailInvalidError": "Enter a valid email.", + "@authEmailInvalidError": {}, + "authPasswordLabel": "Password", + "@authPasswordLabel": {}, + "authPasswordHint": "Min 8 chars, A-Z, a-z, 0-9", + "@authPasswordHint": {}, + "authPasswordRequiredError": "Password is required.", + "@authPasswordRequiredError": {}, + "authPasswordLengthError": "Password must be at least 8 characters.", + "@authPasswordLengthError": {}, + "authPasswordPolicyError": "Password must include uppercase, lowercase, and number.", + "@authPasswordPolicyError": {}, + "authDisplayNameLabel": "Display Name (optional)", + "@authDisplayNameLabel": {}, + "authDisplayNameHint": "How should we call you?", + "@authDisplayNameHint": {}, + "authCreateAccountAction": "Create account", + "@authCreateAccountAction": {}, + "authNotNowAction": "Not now", + "@authNotNowAction": {}, + "authUnavailableMessage": "Authentication is currently unavailable.", + "@authUnavailableMessage": {}, + "authRegisterSuccess": "Account created and signed in.", + "@authRegisterSuccess": {}, + "authSignInSuccess": "Signed in successfully.", + "@authSignInSuccess": {}, + "authSignInFailed": "Sign-in failed: {error}", + "@authSignInFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "authSignedOut": "Signed out.", + "@authSignedOut": {}, + "authFinalConfirmationTitle": "Final confirmation", + "@authFinalConfirmationTitle": {}, + "authFinalConfirmationMessage": "Submit account deletion request now? You will be signed out immediately from this device.", + "@authFinalConfirmationMessage": {}, + "authBackAction": "Back", + "@authBackAction": {}, + "authSubmitRequestAction": "Submit Request", + "@authSubmitRequestAction": {}, + "authDeletionRequestSubmitted": "Account deletion request submitted. You have been signed out.", + "@authDeletionRequestSubmitted": {}, + "authDeletionEndpointMissing": "Deletion request endpoint is not configured on backend yet.", + "@authDeletionEndpointMissing": {}, + "authDeletionImpactDialogTitle": "Before requesting account deletion", + "@authDeletionImpactDialogTitle": {}, + "authDeletionImpactReviewPrompt": "Please review the impact carefully.", + "@authDeletionImpactReviewPrompt": {}, + "authIrreversibleRequestTitle": "Irreversible request", + "@authIrreversibleRequestTitle": {}, + "authImpactLineSessionRevoked": "Your account session is revoked immediately on request.", + "@authImpactLineSessionRevoked": {}, + "authImpactLineCloudDataDeleted": "Synced cloud data linked to this account may be permanently deleted during processing.", + "@authImpactLineCloudDataDeleted": {}, + "authImpactLineCannotRestore": "Deleted account data cannot be restored once processed.", + "@authImpactLineCannotRestore": {}, + "authUnderstandAction": "I understand", + "@authUnderstandAction": {}, + "authPasswordPolicySuffix": "Use English keyboard letters and digits (A-Z, a-z, 0-9).", + "@authPasswordPolicySuffix": {}, + "authAccountConnected": "Account connected", + "@authAccountConnected": {}, + "authSignedInReadySubtitle": "Signed in and ready for cloud sync", + "@authSignedInReadySubtitle": {}, + "authActiveStatus": "Active", + "@authActiveStatus": {}, + "authSessionDetailsTitle": "Session details", + "@authSessionDetailsTitle": {}, + "authUserIdLabel": "User ID", + "@authUserIdLabel": {}, + "authDeviceIdLabel": "Device ID", + "@authDeviceIdLabel": {}, + "authUnknownValue": "Unknown", + "@authUnknownValue": {}, + "authDeletionNoticeTitle": "Account deletion notice", + "@authDeletionNoticeTitle": {}, + "authDeletionNoticeSubtitle": "Deletion requests are irreversible once processed.", + "@authDeletionNoticeSubtitle": {}, + "authDeletionNoticeLineProfileSessions": "Account profile and active sessions will be removed from cloud access.", + "@authDeletionNoticeLineProfileSessions": {}, + "authDeletionNoticeLineSyncedData": "Synced collections, items, tags, and loans may be permanently deleted.", + "@authDeletionNoticeLineSyncedData": {}, + "authRequestDeletionAction": "Request account deletion", + "@authRequestDeletionAction": {}, + "authSignOutAction": "Sign out", + "@authSignOutAction": {}, + "authDoneAction": "Done", + "@authDoneAction": {}, + "authHeaderCreateTitle": "Create your account", + "@authHeaderCreateTitle": {}, + "authHeaderWelcomeTitle": "Welcome back", + "@authHeaderWelcomeTitle": {}, + "authHeaderCreateSubtitle": "Accounts are optional, but required for cloud sync and multi-device access.", + "@authHeaderCreateSubtitle": {}, + "authHeaderSignInSubtitle": "Sign in to access cloud sync and account-based features.", + "@authHeaderSignInSubtitle": {}, + "authUnavailableTitle": "Authentication unavailable", + "@authUnavailableTitle": {} } diff --git a/apps/mobile/lib/l10n/arb/app_es.arb b/apps/mobile/lib/l10n/arb/app_es.arb index b08f0c6..268ea1a 100644 --- a/apps/mobile/lib/l10n/arb/app_es.arb +++ b/apps/mobile/lib/l10n/arb/app_es.arb @@ -1,6 +1,6 @@ { "@@locale": "es", - "appTitle": "Collection Tracker", + "appTitle": "Collectra", "@appTitle": {}, "navHome": "Inicio", "@navHome": {}, @@ -68,6 +68,10 @@ "@settingsManageTagsTitle": {}, "settingsManageTagsSubtitle": "Renombrar, combinar y eliminar etiquetas", "@settingsManageTagsSubtitle": {}, + "settingsLoanTrackingTitle": "Seguimiento de préstamos", + "@settingsLoanTrackingTitle": {}, + "settingsLoanTrackingSubtitle": "Controla artículos prestados y fechas de devolución", + "@settingsLoanTrackingSubtitle": {}, "settingsVersionTitle": "Versión", "@settingsVersionTitle": {}, "settingsPrivacyPolicyTitle": "Política de privacidad", @@ -108,7 +112,7 @@ "@settingsAnalyticsConsentAccepted": {}, "settingsAnalyticsConsentDeclined": "Consentimiento de analíticas rechazado.", "@settingsAnalyticsConsentDeclined": {}, - "analyticsConsentDialogTitle": "Ayúdanos a mejorar Collection Tracker", + "analyticsConsentDialogTitle": "Ayúdanos a mejorar Collectra", "@analyticsConsentDialogTitle": {}, "analyticsConsentDialogMessage": "¿Podemos recopilar analíticas de uso anónimas para mejorar la calidad y las funciones de la app? Puedes cambiarlo en Configuración en cualquier momento.", "@analyticsConsentDialogMessage": {}, @@ -140,6 +144,40 @@ "@settingsFirebaseRuntimeConfigTitle": {}, "settingsFirebaseRuntimeConfigSubtitle": "Inspecciona y actualiza las banderas de ejecución", "@settingsFirebaseRuntimeConfigSubtitle": {}, + "settingsMetadataTitle": "Metadatos y Autorrelleno", + "@settingsMetadataTitle": {}, + "settingsMetadataSummaryEnabled": "Activado con búsqueda automática por código de barras", + "@settingsMetadataSummaryEnabled": {}, + "settingsMetadataSummaryManual": "Activado con búsqueda manual", + "@settingsMetadataSummaryManual": {}, + "settingsMetadataSummaryDisabled": "Desactivado", + "@settingsMetadataSummaryDisabled": {}, + "settingsMetadataSummaryFeatureDisabled": "Desactivado por bandera de ejecución", + "@settingsMetadataSummaryFeatureDisabled": {}, + "settingsMetadataEnableToggleTitle": "Activar asistencia de metadatos", + "@settingsMetadataEnableToggleTitle": {}, + "settingsMetadataEnableToggleSubtitle": "Permite la búsqueda de metadatos y el autorrelleno por código de barras en formularios de artículos.", + "@settingsMetadataEnableToggleSubtitle": {}, + "settingsMetadataAutoFetchToggleTitle": "Buscar automáticamente al escanear código", + "@settingsMetadataAutoFetchToggleTitle": {}, + "settingsMetadataAutoFetchToggleSubtitle": "Después de escanear un código de barras, obtiene metadatos automáticamente.", + "@settingsMetadataAutoFetchToggleSubtitle": {}, + "settingsMetadataFillEmptyOnlyToggleTitle": "Rellenar solo campos vacíos", + "@settingsMetadataFillEmptyOnlyToggleTitle": {}, + "settingsMetadataFillEmptyOnlyToggleSubtitle": "No sobrescribe el título ni la descripción existentes cuando se encuentran metadatos.", + "@settingsMetadataFillEmptyOnlyToggleSubtitle": {}, + "settingsMetadataSourcesSectionTitle": "Fuentes", + "@settingsMetadataSourcesSectionTitle": {}, + "settingsMetadataSourceAvailable": "Disponible", + "@settingsMetadataSourceAvailable": {}, + "settingsMetadataSourceNotConfigured": "Sin configurar", + "@settingsMetadataSourceNotConfigured": {}, + "settingsMetadataSourceManualOnly": "Solo manual", + "@settingsMetadataSourceManualOnly": {}, + "settingsMetadataManualCollectionsLabel": "Cómics, Música y Personalizado", + "@settingsMetadataManualCollectionsLabel": {}, + "settingsMetadataFeatureDisabledMessage": "La asistencia de metadatos está desactivada por configuración de ejecución.", + "@settingsMetadataFeatureDisabledMessage": {}, "settingsFirebaseRuntimeConfigSheetTitle": "Configuración de ejecución de Firebase", "@settingsFirebaseRuntimeConfigSheetTitle": {}, "settingsFirebaseRuntimeConfigDescription": "Los valores se obtienen de Firebase Remote Config y se aplican en tiempo de ejecución.", @@ -204,7 +242,7 @@ }, "settingsImportDataTitle": "Importar datos", "@settingsImportDataTitle": {}, - "settingsImportDataMessage": "Esto importará colecciones y artículos desde un archivo JSON. Los datos existentes no se eliminarán.\\n\\n¿Continuar?", + "settingsImportDataMessage": "Esto importará colecciones y artículos desde un archivo JSON. Los datos existentes no se eliminarán.\n\n¿Continuar?", "@settingsImportDataMessage": {}, "settingsImportingData": "Importando datos...", "@settingsImportingData": {}, @@ -609,6 +647,18 @@ "@metadataSearchSuggestionTitle": {}, "metadataSearchSuggestionMessage": "Empieza a escribir para buscar metadatos.", "@metadataSearchSuggestionMessage": {}, + "metadataSearchDisabledHint": "La búsqueda de metadatos no está disponible para este tipo de colección o está desactivada.", + "@metadataSearchDisabledHint": {}, + "metadataNoMatchForBarcode": "No se encontraron metadatos para este código de barras.", + "@metadataNoMatchForBarcode": {}, + "metadataSearchUnavailableForType": "La búsqueda de metadatos no está disponible para {collectionType}.", + "@metadataSearchUnavailableForType": { + "placeholders": { + "collectionType": { + "type": "String" + } + } + }, "tagItemsTitle": "Etiqueta: {tag}", "@tagItemsTitle": { "placeholders": { @@ -749,7 +799,7 @@ }, "tagManagementDeleteSelectedTitle": "Eliminar etiquetas seleccionadas", "@tagManagementDeleteSelectedTitle": {}, - "tagManagementDeleteSelectedMessage": "¿Eliminar {count} etiquetas seleccionadas de todos los artículos?\\n\\nEsto no se puede deshacer.", + "tagManagementDeleteSelectedMessage": "¿Eliminar {count} etiquetas seleccionadas de todos los artículos?\n\nEsto no se puede deshacer.", "@tagManagementDeleteSelectedMessage": { "placeholders": { "count": { @@ -780,7 +830,7 @@ }, "tagManagementDeleteTitle": "Eliminar etiqueta", "@tagManagementDeleteTitle": {}, - "tagManagementDeleteMessage": "¿Eliminar \"{tagName}\" de todos los artículos?\\n\\nEsto no se puede deshacer.", + "tagManagementDeleteMessage": "¿Eliminar \"{tagName}\" de todos los artículos?\n\nEsto no se puede deshacer.", "@tagManagementDeleteMessage": { "placeholders": { "tagName": { @@ -976,5 +1026,273 @@ "collectionTypeMusic": "Música", "@collectionTypeMusic": {}, "collectionTypeCustom": "Personalizada", - "@collectionTypeCustom": {} + "@collectionTypeCustom": {}, + "loanTrackingTitle": "Seguimiento de préstamos", + "@loanTrackingTitle": {}, + "loanTrackingNewLoan": "Nuevo préstamo", + "@loanTrackingNewLoan": {}, + "loanTrackingFilterActive": "Activos", + "@loanTrackingFilterActive": {}, + "loanTrackingFilterHistory": "Historial", + "@loanTrackingFilterHistory": {}, + "loanTrackingEmptyHistoryTitle": "Aún no hay préstamos devueltos", + "@loanTrackingEmptyHistoryTitle": {}, + "loanTrackingEmptyHistoryMessage": "Los artículos devueltos aparecerán aquí.", + "@loanTrackingEmptyHistoryMessage": {}, + "loanTrackingEmptyActiveTitle": "No hay préstamos activos", + "@loanTrackingEmptyActiveTitle": {}, + "loanTrackingEmptyActiveMessage": "Crea un préstamo para empezar a seguir artículos prestados.", + "@loanTrackingEmptyActiveMessage": {}, + "loanTrackingLoadingLoans": "Cargando préstamos...", + "@loanTrackingLoadingLoans": {}, + "loanTrackingLoadFailed": "No se pudieron cargar los préstamos: {error}", + "@loanTrackingLoadFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loanTrackingMarkReturnedConfirmTitle": "¿Marcar como devuelto?", + "@loanTrackingMarkReturnedConfirmTitle": {}, + "loanTrackingMarkReturnedConfirmMessage": "Confirmar devolución de \"{itemTitle}\".", + "@loanTrackingMarkReturnedConfirmMessage": { + "placeholders": { + "itemTitle": { + "type": "String" + } + } + }, + "loanTrackingMarkReturnedAction": "Marcar devuelto", + "@loanTrackingMarkReturnedAction": {}, + "loanTrackingMarkedReturnedSuccess": "El préstamo se marcó como devuelto.", + "@loanTrackingMarkedReturnedSuccess": {}, + "loanTrackingMarkReturnedFailed": "No se pudo marcar la devolución: {error}", + "@loanTrackingMarkReturnedFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loanTrackingDeleteConfirmTitle": "¿Eliminar registro del préstamo?", + "@loanTrackingDeleteConfirmTitle": {}, + "loanTrackingDeleteConfirmMessage": "Eliminar registro del préstamo de \"{itemTitle}\".", + "@loanTrackingDeleteConfirmMessage": { + "placeholders": { + "itemTitle": { + "type": "String" + } + } + }, + "loanTrackingDeleteSuccess": "Préstamo eliminado.", + "@loanTrackingDeleteSuccess": {}, + "loanTrackingDeleteFailed": "No se pudo eliminar el préstamo: {error}", + "@loanTrackingDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loanTrackingSummaryActiveLabel": "Préstamos activos", + "@loanTrackingSummaryActiveLabel": {}, + "loanTrackingSummaryOverdueLabel": "Vencidos", + "@loanTrackingSummaryOverdueLabel": {}, + "loanTrackingSummaryLoadFailed": "No se pudo cargar el resumen de préstamos.", + "@loanTrackingSummaryLoadFailed": {}, + "loanTrackingFieldBorrower": "Prestatario", + "@loanTrackingFieldBorrower": {}, + "loanTrackingFieldContact": "Contacto", + "@loanTrackingFieldContact": {}, + "loanTrackingFieldLoaned": "Prestado", + "@loanTrackingFieldLoaned": {}, + "loanTrackingFieldDue": "Vence", + "@loanTrackingFieldDue": {}, + "loanTrackingFieldReturned": "Devuelto", + "@loanTrackingFieldReturned": {}, + "loanTrackingStatusReturned": "Devuelto", + "@loanTrackingStatusReturned": {}, + "loanTrackingStatusOverdue": "Vencido", + "@loanTrackingStatusOverdue": {}, + "loanTrackingStatusActive": "Activo", + "@loanTrackingStatusActive": {}, + "loanTrackingCreateTitle": "Crear préstamo", + "@loanTrackingCreateTitle": {}, + "loanTrackingCreateDescription": "Registra quién tomó un artículo prestado y cuándo debe devolverlo.", + "@loanTrackingCreateDescription": {}, + "loanTrackingCreateNoItemsTitle": "No hay artículos disponibles", + "@loanTrackingCreateNoItemsTitle": {}, + "loanTrackingCreateNoItemsMessage": "Todos los artículos están prestados actualmente o aún no hay artículos.", + "@loanTrackingCreateNoItemsMessage": {}, + "loanTrackingCreateItemLabel": "Artículo", + "@loanTrackingCreateItemLabel": {}, + "loanTrackingCreateBorrowerLabel": "Nombre del prestatario", + "@loanTrackingCreateBorrowerLabel": {}, + "loanTrackingCreateBorrowerHint": "p. ej. Juan Pérez", + "@loanTrackingCreateBorrowerHint": {}, + "loanTrackingCreateContactLabel": "Contacto (opcional)", + "@loanTrackingCreateContactLabel": {}, + "loanTrackingCreateContactHint": "Teléfono, correo o @usuario", + "@loanTrackingCreateContactHint": {}, + "loanTrackingCreateNotesLabel": "Notas (opcional)", + "@loanTrackingCreateNotesLabel": {}, + "loanTrackingCreateNotesHint": "Detalles adicionales para este préstamo", + "@loanTrackingCreateNotesHint": {}, + "loanTrackingCreateSubmitting": "Creando...", + "@loanTrackingCreateSubmitting": {}, + "loanTrackingCreateAction": "Crear préstamo", + "@loanTrackingCreateAction": {}, + "loanTrackingLoadingItems": "Cargando artículos...", + "@loanTrackingLoadingItems": {}, + "loanTrackingLoadItemsFailed": "No se pudieron cargar los artículos: {error}", + "@loanTrackingLoadItemsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loanTrackingBorrowerRequired": "El nombre del prestatario es obligatorio.", + "@loanTrackingBorrowerRequired": {}, + "loanTrackingCreateSuccess": "Préstamo creado correctamente.", + "@loanTrackingCreateSuccess": {}, + "loanTrackingCreateFailed": "No se pudo crear el préstamo: {error}", + "@loanTrackingCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loanTrackingNoDueDate": "Sin fecha de vencimiento", + "@loanTrackingNoDueDate": {}, + "loanTrackingPickDateAction": "Elegir", + "@loanTrackingPickDateAction": {}, + "loanTrackingClearDateAction": "Limpiar", + "@loanTrackingClearDateAction": {}, + "loanTrackingDueDateLabel": "Fecha de vencimiento", + "@loanTrackingDueDateLabel": {}, + "authTitleAccount": "Cuenta", + "@authTitleAccount": {}, + "authCreateAccountHeading": "Crear cuenta", + "@authCreateAccountHeading": {}, + "authSignInHeading": "Iniciar sesión", + "@authSignInHeading": {}, + "authCreateAccountDescription": "Crea una cuenta para sincronizar tus colecciones entre dispositivos.", + "@authCreateAccountDescription": {}, + "authSignInDescription": "Inicia sesión para habilitar la sincronización en la nube y funciones de cuenta.", + "@authSignInDescription": {}, + "authSignInChoice": "Iniciar sesión", + "@authSignInChoice": {}, + "authRegisterChoice": "Registrarse", + "@authRegisterChoice": {}, + "authEmailLabel": "Correo electrónico", + "@authEmailLabel": {}, + "authEmailHint": "tu@ejemplo.com", + "@authEmailHint": {}, + "authEmailRequiredError": "El correo electrónico es obligatorio.", + "@authEmailRequiredError": {}, + "authEmailInvalidError": "Introduce un correo electrónico válido.", + "@authEmailInvalidError": {}, + "authPasswordLabel": "Contraseña", + "@authPasswordLabel": {}, + "authPasswordHint": "Mín. 8 caracteres, A-Z, a-z, 0-9", + "@authPasswordHint": {}, + "authPasswordRequiredError": "La contraseña es obligatoria.", + "@authPasswordRequiredError": {}, + "authPasswordLengthError": "La contraseña debe tener al menos 8 caracteres.", + "@authPasswordLengthError": {}, + "authPasswordPolicyError": "La contraseña debe incluir mayúsculas, minúsculas y números.", + "@authPasswordPolicyError": {}, + "authDisplayNameLabel": "Nombre para mostrar (opcional)", + "@authDisplayNameLabel": {}, + "authDisplayNameHint": "¿Cómo debemos llamarte?", + "@authDisplayNameHint": {}, + "authCreateAccountAction": "Crear cuenta", + "@authCreateAccountAction": {}, + "authNotNowAction": "Ahora no", + "@authNotNowAction": {}, + "authUnavailableMessage": "La autenticación no está disponible en este momento.", + "@authUnavailableMessage": {}, + "authRegisterSuccess": "Cuenta creada e inicio de sesión completado.", + "@authRegisterSuccess": {}, + "authSignInSuccess": "Inicio de sesión exitoso.", + "@authSignInSuccess": {}, + "authSignInFailed": "Error al iniciar sesión: {error}", + "@authSignInFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "authSignedOut": "Sesión cerrada.", + "@authSignedOut": {}, + "authFinalConfirmationTitle": "Confirmación final", + "@authFinalConfirmationTitle": {}, + "authFinalConfirmationMessage": "¿Enviar solicitud de eliminación de cuenta ahora? Se cerrará la sesión de inmediato en este dispositivo.", + "@authFinalConfirmationMessage": {}, + "authBackAction": "Atrás", + "@authBackAction": {}, + "authSubmitRequestAction": "Enviar solicitud", + "@authSubmitRequestAction": {}, + "authDeletionRequestSubmitted": "Solicitud de eliminación enviada. Se cerró tu sesión.", + "@authDeletionRequestSubmitted": {}, + "authDeletionEndpointMissing": "El endpoint de solicitud de eliminación aún no está configurado en el backend.", + "@authDeletionEndpointMissing": {}, + "authDeletionImpactDialogTitle": "Antes de solicitar la eliminación de la cuenta", + "@authDeletionImpactDialogTitle": {}, + "authDeletionImpactReviewPrompt": "Revisa cuidadosamente el impacto.", + "@authDeletionImpactReviewPrompt": {}, + "authIrreversibleRequestTitle": "Solicitud irreversible", + "@authIrreversibleRequestTitle": {}, + "authImpactLineSessionRevoked": "La sesión de tu cuenta se revoca inmediatamente al solicitarla.", + "@authImpactLineSessionRevoked": {}, + "authImpactLineCloudDataDeleted": "Los datos sincronizados en la nube vinculados a esta cuenta pueden eliminarse permanentemente durante el proceso.", + "@authImpactLineCloudDataDeleted": {}, + "authImpactLineCannotRestore": "Los datos eliminados de la cuenta no se pueden restaurar una vez procesados.", + "@authImpactLineCannotRestore": {}, + "authUnderstandAction": "Entiendo", + "@authUnderstandAction": {}, + "authPasswordPolicySuffix": "Usa letras y dígitos del teclado en inglés (A-Z, a-z, 0-9).", + "@authPasswordPolicySuffix": {}, + "authAccountConnected": "Cuenta conectada", + "@authAccountConnected": {}, + "authSignedInReadySubtitle": "Sesión iniciada y lista para sincronización en la nube", + "@authSignedInReadySubtitle": {}, + "authActiveStatus": "Activa", + "@authActiveStatus": {}, + "authSessionDetailsTitle": "Detalles de la sesión", + "@authSessionDetailsTitle": {}, + "authUserIdLabel": "ID de usuario", + "@authUserIdLabel": {}, + "authDeviceIdLabel": "ID del dispositivo", + "@authDeviceIdLabel": {}, + "authUnknownValue": "Desconocido", + "@authUnknownValue": {}, + "authDeletionNoticeTitle": "Aviso de eliminación de cuenta", + "@authDeletionNoticeTitle": {}, + "authDeletionNoticeSubtitle": "Las solicitudes de eliminación son irreversibles una vez procesadas.", + "@authDeletionNoticeSubtitle": {}, + "authDeletionNoticeLineProfileSessions": "El perfil de la cuenta y las sesiones activas se eliminarán del acceso en la nube.", + "@authDeletionNoticeLineProfileSessions": {}, + "authDeletionNoticeLineSyncedData": "Las colecciones, artículos, etiquetas y préstamos sincronizados pueden eliminarse permanentemente.", + "@authDeletionNoticeLineSyncedData": {}, + "authRequestDeletionAction": "Solicitar eliminación de cuenta", + "@authRequestDeletionAction": {}, + "authSignOutAction": "Cerrar sesión", + "@authSignOutAction": {}, + "authDoneAction": "Listo", + "@authDoneAction": {}, + "authHeaderCreateTitle": "Crea tu cuenta", + "@authHeaderCreateTitle": {}, + "authHeaderWelcomeTitle": "Bienvenido de nuevo", + "@authHeaderWelcomeTitle": {}, + "authHeaderCreateSubtitle": "Las cuentas son opcionales, pero necesarias para sincronización en la nube y acceso multidispositivo.", + "@authHeaderCreateSubtitle": {}, + "authHeaderSignInSubtitle": "Inicia sesión para acceder a sincronización en la nube y funciones de cuenta.", + "@authHeaderSignInSubtitle": {}, + "authUnavailableTitle": "Autenticación no disponible", + "@authUnavailableTitle": {} } diff --git a/apps/mobile/lib/l10n/arb/app_id.arb b/apps/mobile/lib/l10n/arb/app_id.arb index f69c021..ca78c9f 100644 --- a/apps/mobile/lib/l10n/arb/app_id.arb +++ b/apps/mobile/lib/l10n/arb/app_id.arb @@ -1,6 +1,6 @@ { "@@locale": "id", - "appTitle": "Collection Tracker", + "appTitle": "Collectra", "@appTitle": {}, "navHome": "Beranda", "@navHome": {}, @@ -68,6 +68,10 @@ "@settingsManageTagsTitle": {}, "settingsManageTagsSubtitle": "Ubah nama, gabungkan, dan hapus tag", "@settingsManageTagsSubtitle": {}, + "settingsLoanTrackingTitle": "Pelacakan Pinjaman", + "@settingsLoanTrackingTitle": {}, + "settingsLoanTrackingSubtitle": "Lacak item yang dipinjam dan tanggal pengembalian", + "@settingsLoanTrackingSubtitle": {}, "settingsVersionTitle": "Versi", "@settingsVersionTitle": {}, "settingsPrivacyPolicyTitle": "Kebijakan Privasi", @@ -108,7 +112,7 @@ "@settingsAnalyticsConsentAccepted": {}, "settingsAnalyticsConsentDeclined": "Persetujuan analitik ditolak.", "@settingsAnalyticsConsentDeclined": {}, - "analyticsConsentDialogTitle": "Bantu Tingkatkan Collection Tracker", + "analyticsConsentDialogTitle": "Bantu Tingkatkan Collectra", "@analyticsConsentDialogTitle": {}, "analyticsConsentDialogMessage": "Bolehkah kami mengumpulkan analitik penggunaan anonim untuk meningkatkan kualitas dan fitur aplikasi? Anda dapat mengubahnya kapan saja di Pengaturan.", "@analyticsConsentDialogMessage": {}, @@ -140,6 +144,40 @@ "@settingsFirebaseRuntimeConfigTitle": {}, "settingsFirebaseRuntimeConfigSubtitle": "Periksa dan segarkan flag fitur runtime", "@settingsFirebaseRuntimeConfigSubtitle": {}, + "settingsMetadataTitle": "Metadata & Isi Otomatis", + "@settingsMetadataTitle": {}, + "settingsMetadataSummaryEnabled": "Aktif dengan pencarian barcode otomatis", + "@settingsMetadataSummaryEnabled": {}, + "settingsMetadataSummaryManual": "Aktif dengan pencarian manual", + "@settingsMetadataSummaryManual": {}, + "settingsMetadataSummaryDisabled": "Nonaktif", + "@settingsMetadataSummaryDisabled": {}, + "settingsMetadataSummaryFeatureDisabled": "Dinonaktifkan oleh flag fitur runtime", + "@settingsMetadataSummaryFeatureDisabled": {}, + "settingsMetadataEnableToggleTitle": "Aktifkan bantuan metadata", + "@settingsMetadataEnableToggleTitle": {}, + "settingsMetadataEnableToggleSubtitle": "Izinkan pencarian metadata dan isi otomatis berbasis barcode pada formulir item.", + "@settingsMetadataEnableToggleSubtitle": {}, + "settingsMetadataAutoFetchToggleTitle": "Ambil otomatis saat barcode dipindai", + "@settingsMetadataAutoFetchToggleTitle": {}, + "settingsMetadataAutoFetchToggleSubtitle": "Setelah barcode dipindai, metadata diambil secara otomatis.", + "@settingsMetadataAutoFetchToggleSubtitle": {}, + "settingsMetadataFillEmptyOnlyToggleTitle": "Isi hanya kolom kosong", + "@settingsMetadataFillEmptyOnlyToggleTitle": {}, + "settingsMetadataFillEmptyOnlyToggleSubtitle": "Jangan menimpa judul atau deskripsi yang sudah ada saat metadata ditemukan.", + "@settingsMetadataFillEmptyOnlyToggleSubtitle": {}, + "settingsMetadataSourcesSectionTitle": "Sumber", + "@settingsMetadataSourcesSectionTitle": {}, + "settingsMetadataSourceAvailable": "Tersedia", + "@settingsMetadataSourceAvailable": {}, + "settingsMetadataSourceNotConfigured": "Belum dikonfigurasi", + "@settingsMetadataSourceNotConfigured": {}, + "settingsMetadataSourceManualOnly": "Manual saja", + "@settingsMetadataSourceManualOnly": {}, + "settingsMetadataManualCollectionsLabel": "Komik, Musik, dan Kustom", + "@settingsMetadataManualCollectionsLabel": {}, + "settingsMetadataFeatureDisabledMessage": "Bantuan metadata dinonaktifkan oleh konfigurasi runtime.", + "@settingsMetadataFeatureDisabledMessage": {}, "settingsFirebaseRuntimeConfigSheetTitle": "Konfigurasi Runtime Firebase", "@settingsFirebaseRuntimeConfigSheetTitle": {}, "settingsFirebaseRuntimeConfigDescription": "Nilai diambil dari Firebase Remote Config dan diterapkan saat runtime.", @@ -204,7 +242,7 @@ }, "settingsImportDataTitle": "Impor Data", "@settingsImportDataTitle": {}, - "settingsImportDataMessage": "Ini akan mengimpor koleksi dan item dari file JSON. Data yang sudah ada tidak akan dihapus.\\n\\nLanjutkan?", + "settingsImportDataMessage": "Ini akan mengimpor koleksi dan item dari file JSON. Data yang sudah ada tidak akan dihapus.\n\nLanjutkan?", "@settingsImportDataMessage": {}, "settingsImportingData": "Mengimpor data...", "@settingsImportingData": {}, @@ -609,6 +647,18 @@ "@metadataSearchSuggestionTitle": {}, "metadataSearchSuggestionMessage": "Mulai mengetik untuk mencari metadata.", "@metadataSearchSuggestionMessage": {}, + "metadataSearchDisabledHint": "Pencarian metadata tidak tersedia untuk tipe koleksi ini atau sedang dinonaktifkan.", + "@metadataSearchDisabledHint": {}, + "metadataNoMatchForBarcode": "Tidak ditemukan metadata yang cocok untuk barcode ini.", + "@metadataNoMatchForBarcode": {}, + "metadataSearchUnavailableForType": "Pencarian metadata tidak tersedia untuk {collectionType}.", + "@metadataSearchUnavailableForType": { + "placeholders": { + "collectionType": { + "type": "String" + } + } + }, "tagItemsTitle": "Tag: {tag}", "@tagItemsTitle": { "placeholders": { @@ -749,7 +799,7 @@ }, "tagManagementDeleteSelectedTitle": "Hapus Tag Terpilih", "@tagManagementDeleteSelectedTitle": {}, - "tagManagementDeleteSelectedMessage": "Hapus {count} tag terpilih dari semua item?\\n\\nTindakan ini tidak dapat dibatalkan.", + "tagManagementDeleteSelectedMessage": "Hapus {count} tag terpilih dari semua item?\n\nTindakan ini tidak dapat dibatalkan.", "@tagManagementDeleteSelectedMessage": { "placeholders": { "count": { @@ -780,7 +830,7 @@ }, "tagManagementDeleteTitle": "Hapus Tag", "@tagManagementDeleteTitle": {}, - "tagManagementDeleteMessage": "Hapus \"{tagName}\" dari semua item?\\n\\nTindakan ini tidak dapat dibatalkan.", + "tagManagementDeleteMessage": "Hapus \"{tagName}\" dari semua item?\n\nTindakan ini tidak dapat dibatalkan.", "@tagManagementDeleteMessage": { "placeholders": { "tagName": { @@ -976,5 +1026,273 @@ "collectionTypeMusic": "Musik", "@collectionTypeMusic": {}, "collectionTypeCustom": "Kustom", - "@collectionTypeCustom": {} + "@collectionTypeCustom": {}, + "loanTrackingTitle": "Pelacakan Pinjaman", + "@loanTrackingTitle": {}, + "loanTrackingNewLoan": "Pinjaman Baru", + "@loanTrackingNewLoan": {}, + "loanTrackingFilterActive": "Aktif", + "@loanTrackingFilterActive": {}, + "loanTrackingFilterHistory": "Riwayat", + "@loanTrackingFilterHistory": {}, + "loanTrackingEmptyHistoryTitle": "Belum ada pinjaman yang dikembalikan", + "@loanTrackingEmptyHistoryTitle": {}, + "loanTrackingEmptyHistoryMessage": "Item yang sudah dikembalikan akan muncul di sini.", + "@loanTrackingEmptyHistoryMessage": {}, + "loanTrackingEmptyActiveTitle": "Tidak ada pinjaman aktif", + "@loanTrackingEmptyActiveTitle": {}, + "loanTrackingEmptyActiveMessage": "Buat pinjaman untuk mulai melacak item yang dipinjam.", + "@loanTrackingEmptyActiveMessage": {}, + "loanTrackingLoadingLoans": "Memuat pinjaman...", + "@loanTrackingLoadingLoans": {}, + "loanTrackingLoadFailed": "Gagal memuat pinjaman: {error}", + "@loanTrackingLoadFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loanTrackingMarkReturnedConfirmTitle": "Tandai sudah dikembalikan?", + "@loanTrackingMarkReturnedConfirmTitle": {}, + "loanTrackingMarkReturnedConfirmMessage": "Konfirmasi pengembalian untuk \"{itemTitle}\".", + "@loanTrackingMarkReturnedConfirmMessage": { + "placeholders": { + "itemTitle": { + "type": "String" + } + } + }, + "loanTrackingMarkReturnedAction": "Tandai Dikembalikan", + "@loanTrackingMarkReturnedAction": {}, + "loanTrackingMarkedReturnedSuccess": "Pinjaman ditandai sudah dikembalikan.", + "@loanTrackingMarkedReturnedSuccess": {}, + "loanTrackingMarkReturnedFailed": "Gagal menandai pengembalian: {error}", + "@loanTrackingMarkReturnedFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loanTrackingDeleteConfirmTitle": "Hapus catatan pinjaman?", + "@loanTrackingDeleteConfirmTitle": {}, + "loanTrackingDeleteConfirmMessage": "Hapus catatan pinjaman untuk \"{itemTitle}\".", + "@loanTrackingDeleteConfirmMessage": { + "placeholders": { + "itemTitle": { + "type": "String" + } + } + }, + "loanTrackingDeleteSuccess": "Pinjaman dihapus.", + "@loanTrackingDeleteSuccess": {}, + "loanTrackingDeleteFailed": "Gagal menghapus pinjaman: {error}", + "@loanTrackingDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loanTrackingSummaryActiveLabel": "Pinjaman Aktif", + "@loanTrackingSummaryActiveLabel": {}, + "loanTrackingSummaryOverdueLabel": "Terlambat", + "@loanTrackingSummaryOverdueLabel": {}, + "loanTrackingSummaryLoadFailed": "Tidak dapat memuat ringkasan pinjaman.", + "@loanTrackingSummaryLoadFailed": {}, + "loanTrackingFieldBorrower": "Peminjam", + "@loanTrackingFieldBorrower": {}, + "loanTrackingFieldContact": "Kontak", + "@loanTrackingFieldContact": {}, + "loanTrackingFieldLoaned": "Dipinjamkan", + "@loanTrackingFieldLoaned": {}, + "loanTrackingFieldDue": "Jatuh tempo", + "@loanTrackingFieldDue": {}, + "loanTrackingFieldReturned": "Dikembalikan", + "@loanTrackingFieldReturned": {}, + "loanTrackingStatusReturned": "Dikembalikan", + "@loanTrackingStatusReturned": {}, + "loanTrackingStatusOverdue": "Terlambat", + "@loanTrackingStatusOverdue": {}, + "loanTrackingStatusActive": "Aktif", + "@loanTrackingStatusActive": {}, + "loanTrackingCreateTitle": "Buat Pinjaman", + "@loanTrackingCreateTitle": {}, + "loanTrackingCreateDescription": "Lacak siapa yang meminjam item dan kapan harus dikembalikan.", + "@loanTrackingCreateDescription": {}, + "loanTrackingCreateNoItemsTitle": "Tidak ada item yang tersedia", + "@loanTrackingCreateNoItemsTitle": {}, + "loanTrackingCreateNoItemsMessage": "Semua item sedang dipinjam atau belum ada item.", + "@loanTrackingCreateNoItemsMessage": {}, + "loanTrackingCreateItemLabel": "Item", + "@loanTrackingCreateItemLabel": {}, + "loanTrackingCreateBorrowerLabel": "Nama peminjam", + "@loanTrackingCreateBorrowerLabel": {}, + "loanTrackingCreateBorrowerHint": "mis. Budi Santoso", + "@loanTrackingCreateBorrowerHint": {}, + "loanTrackingCreateContactLabel": "Kontak (opsional)", + "@loanTrackingCreateContactLabel": {}, + "loanTrackingCreateContactHint": "Telepon, email, atau @username", + "@loanTrackingCreateContactHint": {}, + "loanTrackingCreateNotesLabel": "Catatan (opsional)", + "@loanTrackingCreateNotesLabel": {}, + "loanTrackingCreateNotesHint": "Detail tambahan untuk pinjaman ini", + "@loanTrackingCreateNotesHint": {}, + "loanTrackingCreateSubmitting": "Membuat...", + "@loanTrackingCreateSubmitting": {}, + "loanTrackingCreateAction": "Buat Pinjaman", + "@loanTrackingCreateAction": {}, + "loanTrackingLoadingItems": "Memuat item...", + "@loanTrackingLoadingItems": {}, + "loanTrackingLoadItemsFailed": "Gagal memuat item: {error}", + "@loanTrackingLoadItemsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loanTrackingBorrowerRequired": "Nama peminjam wajib diisi.", + "@loanTrackingBorrowerRequired": {}, + "loanTrackingCreateSuccess": "Pinjaman berhasil dibuat.", + "@loanTrackingCreateSuccess": {}, + "loanTrackingCreateFailed": "Gagal membuat pinjaman: {error}", + "@loanTrackingCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loanTrackingNoDueDate": "Tanpa tanggal jatuh tempo", + "@loanTrackingNoDueDate": {}, + "loanTrackingPickDateAction": "Pilih", + "@loanTrackingPickDateAction": {}, + "loanTrackingClearDateAction": "Hapus", + "@loanTrackingClearDateAction": {}, + "loanTrackingDueDateLabel": "Tanggal jatuh tempo", + "@loanTrackingDueDateLabel": {}, + "authTitleAccount": "Akun", + "@authTitleAccount": {}, + "authCreateAccountHeading": "Buat Akun", + "@authCreateAccountHeading": {}, + "authSignInHeading": "Masuk", + "@authSignInHeading": {}, + "authCreateAccountDescription": "Buat akun untuk menyinkronkan koleksi Anda di berbagai perangkat.", + "@authCreateAccountDescription": {}, + "authSignInDescription": "Masuk untuk mengaktifkan sinkronisasi cloud dan fitur akun.", + "@authSignInDescription": {}, + "authSignInChoice": "Masuk", + "@authSignInChoice": {}, + "authRegisterChoice": "Daftar", + "@authRegisterChoice": {}, + "authEmailLabel": "Email", + "@authEmailLabel": {}, + "authEmailHint": "anda@contoh.com", + "@authEmailHint": {}, + "authEmailRequiredError": "Email wajib diisi.", + "@authEmailRequiredError": {}, + "authEmailInvalidError": "Masukkan email yang valid.", + "@authEmailInvalidError": {}, + "authPasswordLabel": "Kata sandi", + "@authPasswordLabel": {}, + "authPasswordHint": "Min 8 karakter, A-Z, a-z, 0-9", + "@authPasswordHint": {}, + "authPasswordRequiredError": "Kata sandi wajib diisi.", + "@authPasswordRequiredError": {}, + "authPasswordLengthError": "Kata sandi minimal 8 karakter.", + "@authPasswordLengthError": {}, + "authPasswordPolicyError": "Kata sandi harus mengandung huruf besar, huruf kecil, dan angka.", + "@authPasswordPolicyError": {}, + "authDisplayNameLabel": "Nama tampilan (opsional)", + "@authDisplayNameLabel": {}, + "authDisplayNameHint": "Kami memanggil Anda dengan nama apa?", + "@authDisplayNameHint": {}, + "authCreateAccountAction": "Buat akun", + "@authCreateAccountAction": {}, + "authNotNowAction": "Nanti saja", + "@authNotNowAction": {}, + "authUnavailableMessage": "Autentikasi sedang tidak tersedia.", + "@authUnavailableMessage": {}, + "authRegisterSuccess": "Akun berhasil dibuat dan Anda sudah masuk.", + "@authRegisterSuccess": {}, + "authSignInSuccess": "Berhasil masuk.", + "@authSignInSuccess": {}, + "authSignInFailed": "Gagal masuk: {error}", + "@authSignInFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "authSignedOut": "Berhasil keluar.", + "@authSignedOut": {}, + "authFinalConfirmationTitle": "Konfirmasi akhir", + "@authFinalConfirmationTitle": {}, + "authFinalConfirmationMessage": "Kirim permintaan penghapusan akun sekarang? Anda akan langsung keluar dari perangkat ini.", + "@authFinalConfirmationMessage": {}, + "authBackAction": "Kembali", + "@authBackAction": {}, + "authSubmitRequestAction": "Kirim Permintaan", + "@authSubmitRequestAction": {}, + "authDeletionRequestSubmitted": "Permintaan penghapusan akun dikirim. Anda telah keluar.", + "@authDeletionRequestSubmitted": {}, + "authDeletionEndpointMissing": "Endpoint permintaan penghapusan belum dikonfigurasi di backend.", + "@authDeletionEndpointMissing": {}, + "authDeletionImpactDialogTitle": "Sebelum meminta penghapusan akun", + "@authDeletionImpactDialogTitle": {}, + "authDeletionImpactReviewPrompt": "Tinjau dampaknya dengan saksama.", + "@authDeletionImpactReviewPrompt": {}, + "authIrreversibleRequestTitle": "Permintaan tidak dapat dibatalkan", + "@authIrreversibleRequestTitle": {}, + "authImpactLineSessionRevoked": "Sesi akun Anda dicabut segera setelah permintaan dikirim.", + "@authImpactLineSessionRevoked": {}, + "authImpactLineCloudDataDeleted": "Data cloud tersinkron yang terkait akun ini dapat dihapus permanen saat diproses.", + "@authImpactLineCloudDataDeleted": {}, + "authImpactLineCannotRestore": "Data akun yang sudah dihapus tidak dapat dipulihkan setelah diproses.", + "@authImpactLineCannotRestore": {}, + "authUnderstandAction": "Saya mengerti", + "@authUnderstandAction": {}, + "authPasswordPolicySuffix": "Gunakan huruf dan angka keyboard Inggris (A-Z, a-z, 0-9).", + "@authPasswordPolicySuffix": {}, + "authAccountConnected": "Akun terhubung", + "@authAccountConnected": {}, + "authSignedInReadySubtitle": "Sudah masuk dan siap untuk sinkronisasi cloud", + "@authSignedInReadySubtitle": {}, + "authActiveStatus": "Aktif", + "@authActiveStatus": {}, + "authSessionDetailsTitle": "Detail sesi", + "@authSessionDetailsTitle": {}, + "authUserIdLabel": "ID Pengguna", + "@authUserIdLabel": {}, + "authDeviceIdLabel": "ID Perangkat", + "@authDeviceIdLabel": {}, + "authUnknownValue": "Tidak diketahui", + "@authUnknownValue": {}, + "authDeletionNoticeTitle": "Pemberitahuan penghapusan akun", + "@authDeletionNoticeTitle": {}, + "authDeletionNoticeSubtitle": "Permintaan penghapusan bersifat permanen setelah diproses.", + "@authDeletionNoticeSubtitle": {}, + "authDeletionNoticeLineProfileSessions": "Profil akun dan sesi aktif akan dihapus dari akses cloud.", + "@authDeletionNoticeLineProfileSessions": {}, + "authDeletionNoticeLineSyncedData": "Koleksi, item, tag, dan pinjaman tersinkron dapat dihapus permanen.", + "@authDeletionNoticeLineSyncedData": {}, + "authRequestDeletionAction": "Minta penghapusan akun", + "@authRequestDeletionAction": {}, + "authSignOutAction": "Keluar", + "@authSignOutAction": {}, + "authDoneAction": "Selesai", + "@authDoneAction": {}, + "authHeaderCreateTitle": "Buat akun Anda", + "@authHeaderCreateTitle": {}, + "authHeaderWelcomeTitle": "Selamat datang kembali", + "@authHeaderWelcomeTitle": {}, + "authHeaderCreateSubtitle": "Akun bersifat opsional, tetapi diperlukan untuk sinkronisasi cloud dan akses multi-perangkat.", + "@authHeaderCreateSubtitle": {}, + "authHeaderSignInSubtitle": "Masuk untuk menggunakan sinkronisasi cloud dan fitur berbasis akun.", + "@authHeaderSignInSubtitle": {}, + "authUnavailableTitle": "Autentikasi tidak tersedia", + "@authUnavailableTitle": {} } diff --git a/apps/mobile/lib/l10n/arb/app_ja.arb b/apps/mobile/lib/l10n/arb/app_ja.arb index de08977..24ede4f 100644 --- a/apps/mobile/lib/l10n/arb/app_ja.arb +++ b/apps/mobile/lib/l10n/arb/app_ja.arb @@ -1,6 +1,6 @@ { "@@locale": "ja", - "appTitle": "Collection Tracker", + "appTitle": "Collectra", "@appTitle": {}, "navHome": "ホーム", "@navHome": {}, @@ -68,6 +68,10 @@ "@settingsManageTagsTitle": {}, "settingsManageTagsSubtitle": "タグの名前変更・統合・削除", "@settingsManageTagsSubtitle": {}, + "settingsLoanTrackingTitle": "貸出管理", + "@settingsLoanTrackingTitle": {}, + "settingsLoanTrackingSubtitle": "貸し出したアイテムと返却予定日を追跡", + "@settingsLoanTrackingSubtitle": {}, "settingsVersionTitle": "バージョン", "@settingsVersionTitle": {}, "settingsPrivacyPolicyTitle": "プライバシーポリシー", @@ -108,7 +112,7 @@ "@settingsAnalyticsConsentAccepted": {}, "settingsAnalyticsConsentDeclined": "アナリティクスへの同意を拒否しました。", "@settingsAnalyticsConsentDeclined": {}, - "analyticsConsentDialogTitle": "Collection Tracker の改善にご協力ください", + "analyticsConsentDialogTitle": "Collectra の改善にご協力ください", "@analyticsConsentDialogTitle": {}, "analyticsConsentDialogMessage": "アプリ品質と機能改善のため、匿名の利用データ収集にご協力いただけますか?この設定はいつでも設定画面で変更できます。", "@analyticsConsentDialogMessage": {}, @@ -140,6 +144,40 @@ "@settingsFirebaseRuntimeConfigTitle": {}, "settingsFirebaseRuntimeConfigSubtitle": "ランタイム機能フラグを確認して更新", "@settingsFirebaseRuntimeConfigSubtitle": {}, + "settingsMetadataTitle": "メタデータと自動入力", + "@settingsMetadataTitle": {}, + "settingsMetadataSummaryEnabled": "バーコード自動検索で有効", + "@settingsMetadataSummaryEnabled": {}, + "settingsMetadataSummaryManual": "手動検索で有効", + "@settingsMetadataSummaryManual": {}, + "settingsMetadataSummaryDisabled": "無効", + "@settingsMetadataSummaryDisabled": {}, + "settingsMetadataSummaryFeatureDisabled": "実行時の機能フラグにより無効", + "@settingsMetadataSummaryFeatureDisabled": {}, + "settingsMetadataEnableToggleTitle": "メタデータ補助を有効化", + "@settingsMetadataEnableToggleTitle": {}, + "settingsMetadataEnableToggleSubtitle": "アイテムフォームでメタデータ検索とバーコード自動入力を利用します。", + "@settingsMetadataEnableToggleSubtitle": {}, + "settingsMetadataAutoFetchToggleTitle": "バーコード読み取り時に自動取得", + "@settingsMetadataAutoFetchToggleTitle": {}, + "settingsMetadataAutoFetchToggleSubtitle": "バーコードを読み取った後、メタデータを自動取得します。", + "@settingsMetadataAutoFetchToggleSubtitle": {}, + "settingsMetadataFillEmptyOnlyToggleTitle": "空欄のみ入力", + "@settingsMetadataFillEmptyOnlyToggleTitle": {}, + "settingsMetadataFillEmptyOnlyToggleSubtitle": "メタデータ検出時に既存のタイトルや説明を上書きしません。", + "@settingsMetadataFillEmptyOnlyToggleSubtitle": {}, + "settingsMetadataSourcesSectionTitle": "ソース", + "@settingsMetadataSourcesSectionTitle": {}, + "settingsMetadataSourceAvailable": "利用可能", + "@settingsMetadataSourceAvailable": {}, + "settingsMetadataSourceNotConfigured": "未設定", + "@settingsMetadataSourceNotConfigured": {}, + "settingsMetadataSourceManualOnly": "手動のみ", + "@settingsMetadataSourceManualOnly": {}, + "settingsMetadataManualCollectionsLabel": "コミック・音楽・カスタム", + "@settingsMetadataManualCollectionsLabel": {}, + "settingsMetadataFeatureDisabledMessage": "メタデータ補助は実行時設定で無効化されています。", + "@settingsMetadataFeatureDisabledMessage": {}, "settingsFirebaseRuntimeConfigSheetTitle": "Firebase ランタイム設定", "@settingsFirebaseRuntimeConfigSheetTitle": {}, "settingsFirebaseRuntimeConfigDescription": "値は Firebase Remote Config から取得され、実行時に適用されます。", @@ -204,7 +242,7 @@ }, "settingsImportDataTitle": "データをインポート", "@settingsImportDataTitle": {}, - "settingsImportDataMessage": "JSONファイルからコレクションとアイテムをインポートします。既存データは削除されません。\\n\\n続行しますか?", + "settingsImportDataMessage": "JSONファイルからコレクションとアイテムをインポートします。既存データは削除されません。\n\n続行しますか?", "@settingsImportDataMessage": {}, "settingsImportingData": "データをインポート中...", "@settingsImportingData": {}, @@ -609,6 +647,18 @@ "@metadataSearchSuggestionTitle": {}, "metadataSearchSuggestionMessage": "入力してメタデータを検索してください。", "@metadataSearchSuggestionMessage": {}, + "metadataSearchDisabledHint": "このコレクション種別ではメタデータ検索が利用できないか、現在無効です。", + "@metadataSearchDisabledHint": {}, + "metadataNoMatchForBarcode": "このバーコードに一致するメタデータが見つかりません。", + "@metadataNoMatchForBarcode": {}, + "metadataSearchUnavailableForType": "{collectionType} ではメタデータ検索を利用できません。", + "@metadataSearchUnavailableForType": { + "placeholders": { + "collectionType": { + "type": "String" + } + } + }, "tagItemsTitle": "タグ: {tag}", "@tagItemsTitle": { "placeholders": { @@ -749,7 +799,7 @@ }, "tagManagementDeleteSelectedTitle": "選択したタグを削除", "@tagManagementDeleteSelectedTitle": {}, - "tagManagementDeleteSelectedMessage": "すべてのアイテムから選択した {count} 件のタグを削除しますか?\\n\\nこの操作は元に戻せません。", + "tagManagementDeleteSelectedMessage": "すべてのアイテムから選択した {count} 件のタグを削除しますか?\n\nこの操作は元に戻せません。", "@tagManagementDeleteSelectedMessage": { "placeholders": { "count": { @@ -780,7 +830,7 @@ }, "tagManagementDeleteTitle": "タグを削除", "@tagManagementDeleteTitle": {}, - "tagManagementDeleteMessage": "すべてのアイテムから \"{tagName}\" を削除しますか?\\n\\nこの操作は元に戻せません。", + "tagManagementDeleteMessage": "すべてのアイテムから \"{tagName}\" を削除しますか?\n\nこの操作は元に戻せません。", "@tagManagementDeleteMessage": { "placeholders": { "tagName": { @@ -976,5 +1026,273 @@ "collectionTypeMusic": "音楽", "@collectionTypeMusic": {}, "collectionTypeCustom": "カスタム", - "@collectionTypeCustom": {} + "@collectionTypeCustom": {}, + "loanTrackingTitle": "貸出管理", + "@loanTrackingTitle": {}, + "loanTrackingNewLoan": "新しい貸出", + "@loanTrackingNewLoan": {}, + "loanTrackingFilterActive": "アクティブ", + "@loanTrackingFilterActive": {}, + "loanTrackingFilterHistory": "履歴", + "@loanTrackingFilterHistory": {}, + "loanTrackingEmptyHistoryTitle": "返却済みの貸出はまだありません", + "@loanTrackingEmptyHistoryTitle": {}, + "loanTrackingEmptyHistoryMessage": "返却されたアイテムはここに表示されます。", + "@loanTrackingEmptyHistoryMessage": {}, + "loanTrackingEmptyActiveTitle": "アクティブな貸出はありません", + "@loanTrackingEmptyActiveTitle": {}, + "loanTrackingEmptyActiveMessage": "貸出を作成して、借りられたアイテムの管理を始めましょう。", + "@loanTrackingEmptyActiveMessage": {}, + "loanTrackingLoadingLoans": "貸出を読み込み中...", + "@loanTrackingLoadingLoans": {}, + "loanTrackingLoadFailed": "貸出の読み込みに失敗しました: {error}", + "@loanTrackingLoadFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loanTrackingMarkReturnedConfirmTitle": "返却済みにしますか?", + "@loanTrackingMarkReturnedConfirmTitle": {}, + "loanTrackingMarkReturnedConfirmMessage": "\"{itemTitle}\" の返却を確認します。", + "@loanTrackingMarkReturnedConfirmMessage": { + "placeholders": { + "itemTitle": { + "type": "String" + } + } + }, + "loanTrackingMarkReturnedAction": "返却済みにする", + "@loanTrackingMarkReturnedAction": {}, + "loanTrackingMarkedReturnedSuccess": "貸出を返却済みにしました。", + "@loanTrackingMarkedReturnedSuccess": {}, + "loanTrackingMarkReturnedFailed": "返却の更新に失敗しました: {error}", + "@loanTrackingMarkReturnedFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loanTrackingDeleteConfirmTitle": "貸出記録を削除しますか?", + "@loanTrackingDeleteConfirmTitle": {}, + "loanTrackingDeleteConfirmMessage": "\"{itemTitle}\" の貸出記録を削除します。", + "@loanTrackingDeleteConfirmMessage": { + "placeholders": { + "itemTitle": { + "type": "String" + } + } + }, + "loanTrackingDeleteSuccess": "貸出記録を削除しました。", + "@loanTrackingDeleteSuccess": {}, + "loanTrackingDeleteFailed": "貸出の削除に失敗しました: {error}", + "@loanTrackingDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loanTrackingSummaryActiveLabel": "アクティブな貸出", + "@loanTrackingSummaryActiveLabel": {}, + "loanTrackingSummaryOverdueLabel": "期限超過", + "@loanTrackingSummaryOverdueLabel": {}, + "loanTrackingSummaryLoadFailed": "貸出サマリーを読み込めませんでした。", + "@loanTrackingSummaryLoadFailed": {}, + "loanTrackingFieldBorrower": "借り手", + "@loanTrackingFieldBorrower": {}, + "loanTrackingFieldContact": "連絡先", + "@loanTrackingFieldContact": {}, + "loanTrackingFieldLoaned": "貸出日", + "@loanTrackingFieldLoaned": {}, + "loanTrackingFieldDue": "返却期限", + "@loanTrackingFieldDue": {}, + "loanTrackingFieldReturned": "返却日", + "@loanTrackingFieldReturned": {}, + "loanTrackingStatusReturned": "返却済み", + "@loanTrackingStatusReturned": {}, + "loanTrackingStatusOverdue": "期限超過", + "@loanTrackingStatusOverdue": {}, + "loanTrackingStatusActive": "アクティブ", + "@loanTrackingStatusActive": {}, + "loanTrackingCreateTitle": "貸出を作成", + "@loanTrackingCreateTitle": {}, + "loanTrackingCreateDescription": "誰がアイテムを借りたか、いつ返却予定かを記録します。", + "@loanTrackingCreateDescription": {}, + "loanTrackingCreateNoItemsTitle": "利用可能なアイテムがありません", + "@loanTrackingCreateNoItemsTitle": {}, + "loanTrackingCreateNoItemsMessage": "すべて貸出中か、まだアイテムがありません。", + "@loanTrackingCreateNoItemsMessage": {}, + "loanTrackingCreateItemLabel": "アイテム", + "@loanTrackingCreateItemLabel": {}, + "loanTrackingCreateBorrowerLabel": "借り手の名前", + "@loanTrackingCreateBorrowerLabel": {}, + "loanTrackingCreateBorrowerHint": "例: 山田 太郎", + "@loanTrackingCreateBorrowerHint": {}, + "loanTrackingCreateContactLabel": "連絡先(任意)", + "@loanTrackingCreateContactLabel": {}, + "loanTrackingCreateContactHint": "電話、メール、または @username", + "@loanTrackingCreateContactHint": {}, + "loanTrackingCreateNotesLabel": "メモ(任意)", + "@loanTrackingCreateNotesLabel": {}, + "loanTrackingCreateNotesHint": "この貸出の追加情報", + "@loanTrackingCreateNotesHint": {}, + "loanTrackingCreateSubmitting": "作成中...", + "@loanTrackingCreateSubmitting": {}, + "loanTrackingCreateAction": "貸出を作成", + "@loanTrackingCreateAction": {}, + "loanTrackingLoadingItems": "アイテムを読み込み中...", + "@loanTrackingLoadingItems": {}, + "loanTrackingLoadItemsFailed": "アイテムの読み込みに失敗しました: {error}", + "@loanTrackingLoadItemsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loanTrackingBorrowerRequired": "借り手の名前は必須です。", + "@loanTrackingBorrowerRequired": {}, + "loanTrackingCreateSuccess": "貸出を作成しました。", + "@loanTrackingCreateSuccess": {}, + "loanTrackingCreateFailed": "貸出の作成に失敗しました: {error}", + "@loanTrackingCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loanTrackingNoDueDate": "返却期限なし", + "@loanTrackingNoDueDate": {}, + "loanTrackingPickDateAction": "選択", + "@loanTrackingPickDateAction": {}, + "loanTrackingClearDateAction": "クリア", + "@loanTrackingClearDateAction": {}, + "loanTrackingDueDateLabel": "返却期限", + "@loanTrackingDueDateLabel": {}, + "authTitleAccount": "アカウント", + "@authTitleAccount": {}, + "authCreateAccountHeading": "アカウント作成", + "@authCreateAccountHeading": {}, + "authSignInHeading": "サインイン", + "@authSignInHeading": {}, + "authCreateAccountDescription": "アカウントを作成すると、コレクションを複数端末で同期できます。", + "@authCreateAccountDescription": {}, + "authSignInDescription": "サインインするとクラウド同期とアカウント機能を利用できます。", + "@authSignInDescription": {}, + "authSignInChoice": "サインイン", + "@authSignInChoice": {}, + "authRegisterChoice": "登録", + "@authRegisterChoice": {}, + "authEmailLabel": "メールアドレス", + "@authEmailLabel": {}, + "authEmailHint": "you@example.com", + "@authEmailHint": {}, + "authEmailRequiredError": "メールアドレスは必須です。", + "@authEmailRequiredError": {}, + "authEmailInvalidError": "有効なメールアドレスを入力してください。", + "@authEmailInvalidError": {}, + "authPasswordLabel": "パスワード", + "@authPasswordLabel": {}, + "authPasswordHint": "8文字以上、A-Z、a-z、0-9", + "@authPasswordHint": {}, + "authPasswordRequiredError": "パスワードは必須です。", + "@authPasswordRequiredError": {}, + "authPasswordLengthError": "パスワードは8文字以上で入力してください。", + "@authPasswordLengthError": {}, + "authPasswordPolicyError": "パスワードには大文字、小文字、数字を含めてください。", + "@authPasswordPolicyError": {}, + "authDisplayNameLabel": "表示名(任意)", + "@authDisplayNameLabel": {}, + "authDisplayNameHint": "呼び名を入力してください", + "@authDisplayNameHint": {}, + "authCreateAccountAction": "アカウントを作成", + "@authCreateAccountAction": {}, + "authNotNowAction": "今はしない", + "@authNotNowAction": {}, + "authUnavailableMessage": "認証は現在利用できません。", + "@authUnavailableMessage": {}, + "authRegisterSuccess": "アカウントを作成してサインインしました。", + "@authRegisterSuccess": {}, + "authSignInSuccess": "サインインしました。", + "@authSignInSuccess": {}, + "authSignInFailed": "サインインに失敗しました: {error}", + "@authSignInFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "authSignedOut": "サインアウトしました。", + "@authSignedOut": {}, + "authFinalConfirmationTitle": "最終確認", + "@authFinalConfirmationTitle": {}, + "authFinalConfirmationMessage": "今すぐアカウント削除リクエストを送信しますか?この端末では直ちにサインアウトされます。", + "@authFinalConfirmationMessage": {}, + "authBackAction": "戻る", + "@authBackAction": {}, + "authSubmitRequestAction": "リクエストを送信", + "@authSubmitRequestAction": {}, + "authDeletionRequestSubmitted": "アカウント削除リクエストを送信しました。サインアウトされました。", + "@authDeletionRequestSubmitted": {}, + "authDeletionEndpointMissing": "削除リクエストのエンドポイントがバックエンドにまだ設定されていません。", + "@authDeletionEndpointMissing": {}, + "authDeletionImpactDialogTitle": "アカウント削除をリクエストする前に", + "@authDeletionImpactDialogTitle": {}, + "authDeletionImpactReviewPrompt": "影響をよくご確認ください。", + "@authDeletionImpactReviewPrompt": {}, + "authIrreversibleRequestTitle": "取り消し不可のリクエスト", + "@authIrreversibleRequestTitle": {}, + "authImpactLineSessionRevoked": "リクエスト送信後、アカウントセッションはすぐに無効化されます。", + "@authImpactLineSessionRevoked": {}, + "authImpactLineCloudDataDeleted": "このアカウントに紐づく同期済みクラウドデータは、処理中に完全削除される可能性があります。", + "@authImpactLineCloudDataDeleted": {}, + "authImpactLineCannotRestore": "削除されたアカウントデータは処理後に復元できません。", + "@authImpactLineCannotRestore": {}, + "authUnderstandAction": "理解しました", + "@authUnderstandAction": {}, + "authPasswordPolicySuffix": "英字キーボードの文字と数字(A-Z、a-z、0-9)を使用してください。", + "@authPasswordPolicySuffix": {}, + "authAccountConnected": "アカウント接続済み", + "@authAccountConnected": {}, + "authSignedInReadySubtitle": "サインイン済みでクラウド同期の準備ができています", + "@authSignedInReadySubtitle": {}, + "authActiveStatus": "有効", + "@authActiveStatus": {}, + "authSessionDetailsTitle": "セッション詳細", + "@authSessionDetailsTitle": {}, + "authUserIdLabel": "ユーザーID", + "@authUserIdLabel": {}, + "authDeviceIdLabel": "デバイスID", + "@authDeviceIdLabel": {}, + "authUnknownValue": "不明", + "@authUnknownValue": {}, + "authDeletionNoticeTitle": "アカウント削除に関する注意", + "@authDeletionNoticeTitle": {}, + "authDeletionNoticeSubtitle": "削除リクエストは処理されると元に戻せません。", + "@authDeletionNoticeSubtitle": {}, + "authDeletionNoticeLineProfileSessions": "アカウントプロフィールと有効なセッションはクラウドアクセスから削除されます。", + "@authDeletionNoticeLineProfileSessions": {}, + "authDeletionNoticeLineSyncedData": "同期済みのコレクション、アイテム、タグ、貸出データは完全に削除される可能性があります。", + "@authDeletionNoticeLineSyncedData": {}, + "authRequestDeletionAction": "アカウント削除をリクエスト", + "@authRequestDeletionAction": {}, + "authSignOutAction": "サインアウト", + "@authSignOutAction": {}, + "authDoneAction": "完了", + "@authDoneAction": {}, + "authHeaderCreateTitle": "アカウントを作成", + "@authHeaderCreateTitle": {}, + "authHeaderWelcomeTitle": "おかえりなさい", + "@authHeaderWelcomeTitle": {}, + "authHeaderCreateSubtitle": "アカウントは任意ですが、クラウド同期と複数端末アクセスには必要です。", + "@authHeaderCreateSubtitle": {}, + "authHeaderSignInSubtitle": "サインインしてクラウド同期とアカウント機能を利用しましょう。", + "@authHeaderSignInSubtitle": {}, + "authUnavailableTitle": "認証を利用できません", + "@authUnavailableTitle": {} } diff --git a/apps/mobile/lib/l10n/arb/app_ko.arb b/apps/mobile/lib/l10n/arb/app_ko.arb index 7543352..69c77d5 100644 --- a/apps/mobile/lib/l10n/arb/app_ko.arb +++ b/apps/mobile/lib/l10n/arb/app_ko.arb @@ -1,6 +1,6 @@ { "@@locale": "ko", - "appTitle": "컬렉션 트래커", + "appTitle": "Collectra", "@appTitle": {}, "navHome": "홈", "@navHome": {}, @@ -68,6 +68,10 @@ "@settingsManageTagsTitle": {}, "settingsManageTagsSubtitle": "태그 이름 변경, 병합, 삭제", "@settingsManageTagsSubtitle": {}, + "settingsLoanTrackingTitle": "대여 추적", + "@settingsLoanTrackingTitle": {}, + "settingsLoanTrackingSubtitle": "대여한 항목과 반납 예정일을 추적합니다", + "@settingsLoanTrackingSubtitle": {}, "settingsVersionTitle": "버전", "@settingsVersionTitle": {}, "settingsPrivacyPolicyTitle": "개인정보 처리방침", @@ -108,7 +112,7 @@ "@settingsAnalyticsConsentAccepted": {}, "settingsAnalyticsConsentDeclined": "분석 동의가 거부되었습니다.", "@settingsAnalyticsConsentDeclined": {}, - "analyticsConsentDialogTitle": "Collection Tracker 개선에 도움을 주세요", + "analyticsConsentDialogTitle": "Collectra 개선에 도움을 주세요", "@analyticsConsentDialogTitle": {}, "analyticsConsentDialogMessage": "앱 품질과 기능 개선을 위해 익명 사용 분석을 수집해도 될까요? 이 설정은 언제든지 설정에서 변경할 수 있습니다.", "@analyticsConsentDialogMessage": {}, @@ -140,6 +144,40 @@ "@settingsFirebaseRuntimeConfigTitle": {}, "settingsFirebaseRuntimeConfigSubtitle": "런타임 기능 플래그 확인 및 새로고침", "@settingsFirebaseRuntimeConfigSubtitle": {}, + "settingsMetadataTitle": "메타데이터 및 자동완성", + "@settingsMetadataTitle": {}, + "settingsMetadataSummaryEnabled": "자동 바코드 조회로 사용 중", + "@settingsMetadataSummaryEnabled": {}, + "settingsMetadataSummaryManual": "수동 조회로 사용 중", + "@settingsMetadataSummaryManual": {}, + "settingsMetadataSummaryDisabled": "사용 안 함", + "@settingsMetadataSummaryDisabled": {}, + "settingsMetadataSummaryFeatureDisabled": "런타임 기능 플래그로 비활성화됨", + "@settingsMetadataSummaryFeatureDisabled": {}, + "settingsMetadataEnableToggleTitle": "메타데이터 보조 사용", + "@settingsMetadataEnableToggleTitle": {}, + "settingsMetadataEnableToggleSubtitle": "아이템 폼에서 메타데이터 검색과 바코드 자동완성을 허용합니다.", + "@settingsMetadataEnableToggleSubtitle": {}, + "settingsMetadataAutoFetchToggleTitle": "바코드 스캔 시 자동 조회", + "@settingsMetadataAutoFetchToggleTitle": {}, + "settingsMetadataAutoFetchToggleSubtitle": "바코드를 스캔한 뒤 메타데이터를 자동으로 가져옵니다.", + "@settingsMetadataAutoFetchToggleSubtitle": {}, + "settingsMetadataFillEmptyOnlyToggleTitle": "빈 필드만 채우기", + "@settingsMetadataFillEmptyOnlyToggleTitle": {}, + "settingsMetadataFillEmptyOnlyToggleSubtitle": "메타데이터를 찾았을 때 기존 제목이나 설명을 덮어쓰지 않습니다.", + "@settingsMetadataFillEmptyOnlyToggleSubtitle": {}, + "settingsMetadataSourcesSectionTitle": "소스", + "@settingsMetadataSourcesSectionTitle": {}, + "settingsMetadataSourceAvailable": "사용 가능", + "@settingsMetadataSourceAvailable": {}, + "settingsMetadataSourceNotConfigured": "미설정", + "@settingsMetadataSourceNotConfigured": {}, + "settingsMetadataSourceManualOnly": "수동만", + "@settingsMetadataSourceManualOnly": {}, + "settingsMetadataManualCollectionsLabel": "코믹, 음악 및 사용자 지정", + "@settingsMetadataManualCollectionsLabel": {}, + "settingsMetadataFeatureDisabledMessage": "메타데이터 보조가 런타임 구성으로 비활성화되었습니다.", + "@settingsMetadataFeatureDisabledMessage": {}, "settingsFirebaseRuntimeConfigSheetTitle": "Firebase 런타임 구성", "@settingsFirebaseRuntimeConfigSheetTitle": {}, "settingsFirebaseRuntimeConfigDescription": "값은 Firebase Remote Config에서 가져와 런타임에 적용됩니다.", @@ -204,7 +242,7 @@ }, "settingsImportDataTitle": "데이터 가져오기", "@settingsImportDataTitle": {}, - "settingsImportDataMessage": "JSON 파일에서 컬렉션과 항목을 가져옵니다. 기존 데이터는 삭제되지 않습니다.\\n\\n계속할까요?", + "settingsImportDataMessage": "JSON 파일에서 컬렉션과 항목을 가져옵니다. 기존 데이터는 삭제되지 않습니다.\n\n계속할까요?", "@settingsImportDataMessage": {}, "settingsImportingData": "데이터 가져오는 중...", "@settingsImportingData": {}, @@ -609,6 +647,18 @@ "@metadataSearchSuggestionTitle": {}, "metadataSearchSuggestionMessage": "메타데이터를 찾으려면 입력하세요.", "@metadataSearchSuggestionMessage": {}, + "metadataSearchDisabledHint": "이 컬렉션 유형에서는 메타데이터 검색을 사용할 수 없거나 현재 비활성화되어 있습니다.", + "@metadataSearchDisabledHint": {}, + "metadataNoMatchForBarcode": "이 바코드와 일치하는 메타데이터를 찾지 못했습니다.", + "@metadataNoMatchForBarcode": {}, + "metadataSearchUnavailableForType": "{collectionType}에는 메타데이터 검색을 사용할 수 없습니다.", + "@metadataSearchUnavailableForType": { + "placeholders": { + "collectionType": { + "type": "String" + } + } + }, "tagItemsTitle": "태그: {tag}", "@tagItemsTitle": { "placeholders": { @@ -749,7 +799,7 @@ }, "tagManagementDeleteSelectedTitle": "선택한 태그 삭제", "@tagManagementDeleteSelectedTitle": {}, - "tagManagementDeleteSelectedMessage": "모든 항목에서 선택한 태그 {count}개를 삭제할까요?\\n\\n이 작업은 되돌릴 수 없습니다.", + "tagManagementDeleteSelectedMessage": "모든 항목에서 선택한 태그 {count}개를 삭제할까요?\n\n이 작업은 되돌릴 수 없습니다.", "@tagManagementDeleteSelectedMessage": { "placeholders": { "count": { @@ -780,7 +830,7 @@ }, "tagManagementDeleteTitle": "태그 삭제", "@tagManagementDeleteTitle": {}, - "tagManagementDeleteMessage": "모든 항목에서 \"{tagName}\"을(를) 삭제할까요?\\n\\n이 작업은 되돌릴 수 없습니다.", + "tagManagementDeleteMessage": "모든 항목에서 \"{tagName}\"을(를) 삭제할까요?\n\n이 작업은 되돌릴 수 없습니다.", "@tagManagementDeleteMessage": { "placeholders": { "tagName": { @@ -976,5 +1026,273 @@ "collectionTypeMusic": "음악", "@collectionTypeMusic": {}, "collectionTypeCustom": "사용자 지정", - "@collectionTypeCustom": {} + "@collectionTypeCustom": {}, + "loanTrackingTitle": "대여 추적", + "@loanTrackingTitle": {}, + "loanTrackingNewLoan": "새 대여", + "@loanTrackingNewLoan": {}, + "loanTrackingFilterActive": "활성", + "@loanTrackingFilterActive": {}, + "loanTrackingFilterHistory": "기록", + "@loanTrackingFilterHistory": {}, + "loanTrackingEmptyHistoryTitle": "아직 반납된 대여가 없습니다", + "@loanTrackingEmptyHistoryTitle": {}, + "loanTrackingEmptyHistoryMessage": "반납된 항목이 여기에 표시됩니다.", + "@loanTrackingEmptyHistoryMessage": {}, + "loanTrackingEmptyActiveTitle": "활성 대여가 없습니다", + "@loanTrackingEmptyActiveTitle": {}, + "loanTrackingEmptyActiveMessage": "대여를 생성해 빌려준 항목을 추적해 보세요.", + "@loanTrackingEmptyActiveMessage": {}, + "loanTrackingLoadingLoans": "대여 불러오는 중...", + "@loanTrackingLoadingLoans": {}, + "loanTrackingLoadFailed": "대여를 불러오지 못했습니다: {error}", + "@loanTrackingLoadFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loanTrackingMarkReturnedConfirmTitle": "반납으로 표시할까요?", + "@loanTrackingMarkReturnedConfirmTitle": {}, + "loanTrackingMarkReturnedConfirmMessage": "\"{itemTitle}\" 반납을 확인합니다.", + "@loanTrackingMarkReturnedConfirmMessage": { + "placeholders": { + "itemTitle": { + "type": "String" + } + } + }, + "loanTrackingMarkReturnedAction": "반납 처리", + "@loanTrackingMarkReturnedAction": {}, + "loanTrackingMarkedReturnedSuccess": "대여가 반납 처리되었습니다.", + "@loanTrackingMarkedReturnedSuccess": {}, + "loanTrackingMarkReturnedFailed": "반납 처리에 실패했습니다: {error}", + "@loanTrackingMarkReturnedFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loanTrackingDeleteConfirmTitle": "대여 기록을 삭제할까요?", + "@loanTrackingDeleteConfirmTitle": {}, + "loanTrackingDeleteConfirmMessage": "\"{itemTitle}\" 대여 기록을 삭제합니다.", + "@loanTrackingDeleteConfirmMessage": { + "placeholders": { + "itemTitle": { + "type": "String" + } + } + }, + "loanTrackingDeleteSuccess": "대여가 삭제되었습니다.", + "@loanTrackingDeleteSuccess": {}, + "loanTrackingDeleteFailed": "대여 삭제에 실패했습니다: {error}", + "@loanTrackingDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loanTrackingSummaryActiveLabel": "활성 대여", + "@loanTrackingSummaryActiveLabel": {}, + "loanTrackingSummaryOverdueLabel": "연체", + "@loanTrackingSummaryOverdueLabel": {}, + "loanTrackingSummaryLoadFailed": "대여 요약을 불러올 수 없습니다.", + "@loanTrackingSummaryLoadFailed": {}, + "loanTrackingFieldBorrower": "대여자", + "@loanTrackingFieldBorrower": {}, + "loanTrackingFieldContact": "연락처", + "@loanTrackingFieldContact": {}, + "loanTrackingFieldLoaned": "대여일", + "@loanTrackingFieldLoaned": {}, + "loanTrackingFieldDue": "반납 예정일", + "@loanTrackingFieldDue": {}, + "loanTrackingFieldReturned": "반납일", + "@loanTrackingFieldReturned": {}, + "loanTrackingStatusReturned": "반납됨", + "@loanTrackingStatusReturned": {}, + "loanTrackingStatusOverdue": "연체", + "@loanTrackingStatusOverdue": {}, + "loanTrackingStatusActive": "활성", + "@loanTrackingStatusActive": {}, + "loanTrackingCreateTitle": "대여 생성", + "@loanTrackingCreateTitle": {}, + "loanTrackingCreateDescription": "누가 항목을 빌렸는지와 반납 예정일을 추적합니다.", + "@loanTrackingCreateDescription": {}, + "loanTrackingCreateNoItemsTitle": "사용 가능한 항목이 없습니다", + "@loanTrackingCreateNoItemsTitle": {}, + "loanTrackingCreateNoItemsMessage": "모든 항목이 이미 대여 중이거나 아직 항목이 없습니다.", + "@loanTrackingCreateNoItemsMessage": {}, + "loanTrackingCreateItemLabel": "항목", + "@loanTrackingCreateItemLabel": {}, + "loanTrackingCreateBorrowerLabel": "대여자 이름", + "@loanTrackingCreateBorrowerLabel": {}, + "loanTrackingCreateBorrowerHint": "예: 홍길동", + "@loanTrackingCreateBorrowerHint": {}, + "loanTrackingCreateContactLabel": "연락처 (선택)", + "@loanTrackingCreateContactLabel": {}, + "loanTrackingCreateContactHint": "전화번호, 이메일 또는 @username", + "@loanTrackingCreateContactHint": {}, + "loanTrackingCreateNotesLabel": "메모 (선택)", + "@loanTrackingCreateNotesLabel": {}, + "loanTrackingCreateNotesHint": "이 대여에 대한 추가 정보", + "@loanTrackingCreateNotesHint": {}, + "loanTrackingCreateSubmitting": "생성 중...", + "@loanTrackingCreateSubmitting": {}, + "loanTrackingCreateAction": "대여 생성", + "@loanTrackingCreateAction": {}, + "loanTrackingLoadingItems": "항목 불러오는 중...", + "@loanTrackingLoadingItems": {}, + "loanTrackingLoadItemsFailed": "항목을 불러오지 못했습니다: {error}", + "@loanTrackingLoadItemsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loanTrackingBorrowerRequired": "대여자 이름은 필수입니다.", + "@loanTrackingBorrowerRequired": {}, + "loanTrackingCreateSuccess": "대여가 생성되었습니다.", + "@loanTrackingCreateSuccess": {}, + "loanTrackingCreateFailed": "대여 생성에 실패했습니다: {error}", + "@loanTrackingCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loanTrackingNoDueDate": "반납 예정일 없음", + "@loanTrackingNoDueDate": {}, + "loanTrackingPickDateAction": "선택", + "@loanTrackingPickDateAction": {}, + "loanTrackingClearDateAction": "지우기", + "@loanTrackingClearDateAction": {}, + "loanTrackingDueDateLabel": "반납 예정일", + "@loanTrackingDueDateLabel": {}, + "authTitleAccount": "계정", + "@authTitleAccount": {}, + "authCreateAccountHeading": "계정 만들기", + "@authCreateAccountHeading": {}, + "authSignInHeading": "로그인", + "@authSignInHeading": {}, + "authCreateAccountDescription": "계정을 만들어 컬렉션을 여러 기기에서 동기화하세요.", + "@authCreateAccountDescription": {}, + "authSignInDescription": "로그인하면 클라우드 동기화와 계정 기능을 사용할 수 있습니다.", + "@authSignInDescription": {}, + "authSignInChoice": "로그인", + "@authSignInChoice": {}, + "authRegisterChoice": "회원가입", + "@authRegisterChoice": {}, + "authEmailLabel": "이메일", + "@authEmailLabel": {}, + "authEmailHint": "you@example.com", + "@authEmailHint": {}, + "authEmailRequiredError": "이메일은 필수입니다.", + "@authEmailRequiredError": {}, + "authEmailInvalidError": "유효한 이메일을 입력하세요.", + "@authEmailInvalidError": {}, + "authPasswordLabel": "비밀번호", + "@authPasswordLabel": {}, + "authPasswordHint": "최소 8자, A-Z, a-z, 0-9", + "@authPasswordHint": {}, + "authPasswordRequiredError": "비밀번호는 필수입니다.", + "@authPasswordRequiredError": {}, + "authPasswordLengthError": "비밀번호는 최소 8자 이상이어야 합니다.", + "@authPasswordLengthError": {}, + "authPasswordPolicyError": "비밀번호에는 대문자, 소문자, 숫자가 포함되어야 합니다.", + "@authPasswordPolicyError": {}, + "authDisplayNameLabel": "표시 이름 (선택)", + "@authDisplayNameLabel": {}, + "authDisplayNameHint": "어떻게 불러드릴까요?", + "@authDisplayNameHint": {}, + "authCreateAccountAction": "계정 만들기", + "@authCreateAccountAction": {}, + "authNotNowAction": "나중에", + "@authNotNowAction": {}, + "authUnavailableMessage": "현재 인증을 사용할 수 없습니다.", + "@authUnavailableMessage": {}, + "authRegisterSuccess": "계정이 생성되었고 로그인되었습니다.", + "@authRegisterSuccess": {}, + "authSignInSuccess": "로그인되었습니다.", + "@authSignInSuccess": {}, + "authSignInFailed": "로그인 실패: {error}", + "@authSignInFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "authSignedOut": "로그아웃되었습니다.", + "@authSignedOut": {}, + "authFinalConfirmationTitle": "최종 확인", + "@authFinalConfirmationTitle": {}, + "authFinalConfirmationMessage": "지금 계정 삭제 요청을 제출할까요? 이 기기에서 즉시 로그아웃됩니다.", + "@authFinalConfirmationMessage": {}, + "authBackAction": "뒤로", + "@authBackAction": {}, + "authSubmitRequestAction": "요청 제출", + "@authSubmitRequestAction": {}, + "authDeletionRequestSubmitted": "계정 삭제 요청이 제출되었습니다. 로그아웃되었습니다.", + "@authDeletionRequestSubmitted": {}, + "authDeletionEndpointMissing": "계정 삭제 요청 엔드포인트가 아직 백엔드에 구성되지 않았습니다.", + "@authDeletionEndpointMissing": {}, + "authDeletionImpactDialogTitle": "계정 삭제를 요청하기 전에", + "@authDeletionImpactDialogTitle": {}, + "authDeletionImpactReviewPrompt": "영향을 신중히 확인해 주세요.", + "@authDeletionImpactReviewPrompt": {}, + "authIrreversibleRequestTitle": "되돌릴 수 없는 요청", + "@authIrreversibleRequestTitle": {}, + "authImpactLineSessionRevoked": "요청 즉시 계정 세션이 취소됩니다.", + "@authImpactLineSessionRevoked": {}, + "authImpactLineCloudDataDeleted": "이 계정과 연결된 동기화 클라우드 데이터는 처리 중 영구 삭제될 수 있습니다.", + "@authImpactLineCloudDataDeleted": {}, + "authImpactLineCannotRestore": "삭제된 계정 데이터는 처리 후 복구할 수 없습니다.", + "@authImpactLineCannotRestore": {}, + "authUnderstandAction": "이해했습니다", + "@authUnderstandAction": {}, + "authPasswordPolicySuffix": "영문 키보드 문자와 숫자(A-Z, a-z, 0-9)를 사용하세요.", + "@authPasswordPolicySuffix": {}, + "authAccountConnected": "계정 연결됨", + "@authAccountConnected": {}, + "authSignedInReadySubtitle": "로그인되어 클라우드 동기화 준비 완료", + "@authSignedInReadySubtitle": {}, + "authActiveStatus": "활성", + "@authActiveStatus": {}, + "authSessionDetailsTitle": "세션 정보", + "@authSessionDetailsTitle": {}, + "authUserIdLabel": "사용자 ID", + "@authUserIdLabel": {}, + "authDeviceIdLabel": "기기 ID", + "@authDeviceIdLabel": {}, + "authUnknownValue": "알 수 없음", + "@authUnknownValue": {}, + "authDeletionNoticeTitle": "계정 삭제 안내", + "@authDeletionNoticeTitle": {}, + "authDeletionNoticeSubtitle": "삭제 요청은 처리되면 되돌릴 수 없습니다.", + "@authDeletionNoticeSubtitle": {}, + "authDeletionNoticeLineProfileSessions": "계정 프로필과 활성 세션은 클라우드 접근에서 제거됩니다.", + "@authDeletionNoticeLineProfileSessions": {}, + "authDeletionNoticeLineSyncedData": "동기화된 컬렉션, 항목, 태그, 대여 데이터가 영구 삭제될 수 있습니다.", + "@authDeletionNoticeLineSyncedData": {}, + "authRequestDeletionAction": "계정 삭제 요청", + "@authRequestDeletionAction": {}, + "authSignOutAction": "로그아웃", + "@authSignOutAction": {}, + "authDoneAction": "완료", + "@authDoneAction": {}, + "authHeaderCreateTitle": "계정을 만들어 보세요", + "@authHeaderCreateTitle": {}, + "authHeaderWelcomeTitle": "다시 오신 것을 환영합니다", + "@authHeaderWelcomeTitle": {}, + "authHeaderCreateSubtitle": "계정은 선택 사항이지만 클라우드 동기화와 다중 기기 접근에 필요합니다.", + "@authHeaderCreateSubtitle": {}, + "authHeaderSignInSubtitle": "로그인하여 클라우드 동기화와 계정 기능을 사용하세요.", + "@authHeaderSignInSubtitle": {}, + "authUnavailableTitle": "인증을 사용할 수 없음", + "@authUnavailableTitle": {} } diff --git a/apps/mobile/lib/l10n/arb/app_my.arb b/apps/mobile/lib/l10n/arb/app_my.arb index ced5bf2..789765a 100644 --- a/apps/mobile/lib/l10n/arb/app_my.arb +++ b/apps/mobile/lib/l10n/arb/app_my.arb @@ -1,6 +1,6 @@ { "@@locale": "my", - "appTitle": "Collection Tracker", + "appTitle": "Collectra", "@appTitle": {}, "navHome": "ပင်မ", "@navHome": {}, @@ -68,6 +68,10 @@ "@settingsManageTagsTitle": {}, "settingsManageTagsSubtitle": "Tag အမည်ပြောင်း၊ ပေါင်းစည်း၊ ဖျက်နိုင်သည်", "@settingsManageTagsSubtitle": {}, + "settingsLoanTrackingTitle": "ငှားရမ်းမှု ခြေရာခံ", + "@settingsLoanTrackingTitle": {}, + "settingsLoanTrackingSubtitle": "ငှားထားသော ပစ္စည်းများနှင့် ပြန်အပ်ရမည့်ရက်ကို ခြေရာခံပါ", + "@settingsLoanTrackingSubtitle": {}, "settingsVersionTitle": "ဗားရှင်း", "@settingsVersionTitle": {}, "settingsPrivacyPolicyTitle": "ကိုယ်ရေးကိုယ်တာ မူဝါဒ", @@ -108,7 +112,7 @@ "@settingsAnalyticsConsentAccepted": {}, "settingsAnalyticsConsentDeclined": "Analytics သဘောတူညီချက် ငြင်းဆိုထားသည်။", "@settingsAnalyticsConsentDeclined": {}, - "analyticsConsentDialogTitle": "Collection Tracker ကို တိုးတက်စေရန် ကူညီပါ", + "analyticsConsentDialogTitle": "Collectra ကို တိုးတက်စေရန် ကူညီပါ", "@analyticsConsentDialogTitle": {}, "analyticsConsentDialogMessage": "အက်ပ်အရည်အသွေးနှင့် အင်္ဂါရပ်များ တိုးတက်စေရန် အမည်မဖော်ထားသော အသုံးပြုမှု analytics ကို စုဆောင်းခွင့်ပြုမလား? ဤဆက်တင်ကို Settings တွင် အချိန်မရွေး ပြောင်းလဲနိုင်သည်။", "@analyticsConsentDialogMessage": {}, @@ -140,6 +144,40 @@ "@settingsFirebaseRuntimeConfigTitle": {}, "settingsFirebaseRuntimeConfigSubtitle": "runtime feature flags ကို စစ်ဆေးပြီး refresh လုပ်ပါ", "@settingsFirebaseRuntimeConfigSubtitle": {}, + "settingsMetadataTitle": "Metadata နှင့် Auto-fill", + "@settingsMetadataTitle": {}, + "settingsMetadataSummaryEnabled": "Barcode အလိုအလျောက်ရှာဖွေမှုဖြင့် ဖွင့်ထားသည်", + "@settingsMetadataSummaryEnabled": {}, + "settingsMetadataSummaryManual": "လက်ဖြင့်ရှာဖွေမှုဖြင့် ဖွင့်ထားသည်", + "@settingsMetadataSummaryManual": {}, + "settingsMetadataSummaryDisabled": "ပိတ်ထားသည်", + "@settingsMetadataSummaryDisabled": {}, + "settingsMetadataSummaryFeatureDisabled": "Runtime feature flag ကြောင့် ပိတ်ထားသည်", + "@settingsMetadataSummaryFeatureDisabled": {}, + "settingsMetadataEnableToggleTitle": "Metadata အကူအညီ ဖွင့်မည်", + "@settingsMetadataEnableToggleTitle": {}, + "settingsMetadataEnableToggleSubtitle": "Item form များတွင် metadata ရှာဖွေမှုနှင့် barcode auto-fill ကို ခွင့်ပြုပါသည်။", + "@settingsMetadataEnableToggleSubtitle": {}, + "settingsMetadataAutoFetchToggleTitle": "Barcode scan ပြီးနောက် အလိုအလျောက် ရယူမည်", + "@settingsMetadataAutoFetchToggleTitle": {}, + "settingsMetadataAutoFetchToggleSubtitle": "Barcode scan ပြီးနောက် metadata ကို အလိုအလျောက် ရယူပါသည်။", + "@settingsMetadataAutoFetchToggleSubtitle": {}, + "settingsMetadataFillEmptyOnlyToggleTitle": "လွတ်နေသော fields များကိုသာ ဖြည့်မည်", + "@settingsMetadataFillEmptyOnlyToggleTitle": {}, + "settingsMetadataFillEmptyOnlyToggleSubtitle": "Metadata တွေ့ရှိသောအခါ ရှိပြီးသား title သို့မဟုတ် description ကို မရေးကျော်ပါ။", + "@settingsMetadataFillEmptyOnlyToggleSubtitle": {}, + "settingsMetadataSourcesSectionTitle": "Sources", + "@settingsMetadataSourcesSectionTitle": {}, + "settingsMetadataSourceAvailable": "အသုံးပြုနိုင်သည်", + "@settingsMetadataSourceAvailable": {}, + "settingsMetadataSourceNotConfigured": "မသတ်မှတ်ရသေးပါ", + "@settingsMetadataSourceNotConfigured": {}, + "settingsMetadataSourceManualOnly": "လက်ဖြင့်သာ", + "@settingsMetadataSourceManualOnly": {}, + "settingsMetadataManualCollectionsLabel": "Comics, Music နှင့် Custom", + "@settingsMetadataManualCollectionsLabel": {}, + "settingsMetadataFeatureDisabledMessage": "Metadata အကူအညီကို runtime configuration ဖြင့် ပိတ်ထားသည်။", + "@settingsMetadataFeatureDisabledMessage": {}, "settingsFirebaseRuntimeConfigSheetTitle": "Firebase Runtime Config", "@settingsFirebaseRuntimeConfigSheetTitle": {}, "settingsFirebaseRuntimeConfigDescription": "values တွေကို Firebase Remote Config မှ ရယူပြီး runtime တွင် apply လုပ်ပါသည်။", @@ -204,7 +242,7 @@ }, "settingsImportDataTitle": "ဒေတာ ထည့်သွင်းရန်", "@settingsImportDataTitle": {}, - "settingsImportDataMessage": "ဤလုပ်ဆောင်မှုသည် JSON ဖိုင်မှ collection များနှင့် item များကို ထည့်သွင်းမည်ဖြစ်သည်။ ရှိပြီးသားဒေတာ မဖျက်ပါ။\\n\\nဆက်လုပ်မလား?", + "settingsImportDataMessage": "ဤလုပ်ဆောင်မှုသည် JSON ဖိုင်မှ collection များနှင့် item များကို ထည့်သွင်းမည်ဖြစ်သည်။ ရှိပြီးသားဒေတာ မဖျက်ပါ။\n\nဆက်လုပ်မလား?", "@settingsImportDataMessage": {}, "settingsImportingData": "ဒေတာ ထည့်သွင်းနေသည်...", "@settingsImportingData": {}, @@ -609,6 +647,18 @@ "@metadataSearchSuggestionTitle": {}, "metadataSearchSuggestionMessage": "Start typing to look up metadata.", "@metadataSearchSuggestionMessage": {}, + "metadataSearchDisabledHint": "ဤ collection type အတွက် metadata ရှာဖွေမှု မရနိုင်ပါ သို့မဟုတ် လက်ရှိပိတ်ထားသည်။", + "@metadataSearchDisabledHint": {}, + "metadataNoMatchForBarcode": "ဤ barcode အတွက် ကိုက်ညီသော metadata မတွေ့ပါ။", + "@metadataNoMatchForBarcode": {}, + "metadataSearchUnavailableForType": "{collectionType} အတွက် metadata ရှာဖွေမှု မရနိုင်ပါ။", + "@metadataSearchUnavailableForType": { + "placeholders": { + "collectionType": { + "type": "String" + } + } + }, "tagItemsTitle": "Tag: {tag}", "@tagItemsTitle": { "placeholders": { @@ -749,7 +799,7 @@ }, "tagManagementDeleteSelectedTitle": "Delete Selected Tags", "@tagManagementDeleteSelectedTitle": {}, - "tagManagementDeleteSelectedMessage": "Delete {count} selected tags from all items?\\n\\nThis cannot be undone.", + "tagManagementDeleteSelectedMessage": "Delete {count} selected tags from all items?\n\nThis cannot be undone.", "@tagManagementDeleteSelectedMessage": { "placeholders": { "count": { @@ -780,7 +830,7 @@ }, "tagManagementDeleteTitle": "Delete Tag", "@tagManagementDeleteTitle": {}, - "tagManagementDeleteMessage": "Delete \"{tagName}\" from all items?\\n\\nThis cannot be undone.", + "tagManagementDeleteMessage": "Delete \"{tagName}\" from all items?\n\nThis cannot be undone.", "@tagManagementDeleteMessage": { "placeholders": { "tagName": { @@ -976,5 +1026,273 @@ "collectionTypeMusic": "ဂီတ", "@collectionTypeMusic": {}, "collectionTypeCustom": "စိတ်ကြိုက်", - "@collectionTypeCustom": {} + "@collectionTypeCustom": {}, + "loanTrackingTitle": "ငှားရမ်းမှု ခြေရာခံ", + "@loanTrackingTitle": {}, + "loanTrackingNewLoan": "ငှားရမ်းမှု အသစ်", + "@loanTrackingNewLoan": {}, + "loanTrackingFilterActive": "လက်ရှိ", + "@loanTrackingFilterActive": {}, + "loanTrackingFilterHistory": "မှတ်တမ်း", + "@loanTrackingFilterHistory": {}, + "loanTrackingEmptyHistoryTitle": "ပြန်အပ်ပြီး ငှားရမ်းမှု မရှိသေးပါ", + "@loanTrackingEmptyHistoryTitle": {}, + "loanTrackingEmptyHistoryMessage": "ပြန်အပ်ပြီး ပစ္စည်းများကို ဒီနေရာတွင် ပြပါမည်။", + "@loanTrackingEmptyHistoryMessage": {}, + "loanTrackingEmptyActiveTitle": "လက်ရှိ ငှားရမ်းမှု မရှိပါ", + "@loanTrackingEmptyActiveTitle": {}, + "loanTrackingEmptyActiveMessage": "ငှားသွားသော ပစ္စည်းများကို ခြေရာခံရန် ငှားရမ်းမှု အသစ် ဖန်တီးပါ။", + "@loanTrackingEmptyActiveMessage": {}, + "loanTrackingLoadingLoans": "ငှားရမ်းမှုများ တင်နေသည်...", + "@loanTrackingLoadingLoans": {}, + "loanTrackingLoadFailed": "ငှားရမ်းမှုများ တင်မရပါ: {error}", + "@loanTrackingLoadFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loanTrackingMarkReturnedConfirmTitle": "ပြန်အပ်ပြီးဟု မှတ်မလား?", + "@loanTrackingMarkReturnedConfirmTitle": {}, + "loanTrackingMarkReturnedConfirmMessage": "\"{itemTitle}\" ကို ပြန်အပ်ပြီးဟု အတည်ပြုမည်။", + "@loanTrackingMarkReturnedConfirmMessage": { + "placeholders": { + "itemTitle": { + "type": "String" + } + } + }, + "loanTrackingMarkReturnedAction": "ပြန်အပ်ပြီး မှတ်မည်", + "@loanTrackingMarkReturnedAction": {}, + "loanTrackingMarkedReturnedSuccess": "ငှားရမ်းမှုကို ပြန်အပ်ပြီးအဖြစ် မှတ်သားပြီးပါပြီ။", + "@loanTrackingMarkedReturnedSuccess": {}, + "loanTrackingMarkReturnedFailed": "ပြန်အပ်ပြီး မှတ်မရပါ: {error}", + "@loanTrackingMarkReturnedFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loanTrackingDeleteConfirmTitle": "ငှားရမ်းမှု မှတ်တမ်း ဖျက်မလား?", + "@loanTrackingDeleteConfirmTitle": {}, + "loanTrackingDeleteConfirmMessage": "\"{itemTitle}\" အတွက် ငှားရမ်းမှု မှတ်တမ်းကို ဖျက်မည်။", + "@loanTrackingDeleteConfirmMessage": { + "placeholders": { + "itemTitle": { + "type": "String" + } + } + }, + "loanTrackingDeleteSuccess": "ငှားရမ်းမှုကို ဖျက်ပြီးပါပြီ။", + "@loanTrackingDeleteSuccess": {}, + "loanTrackingDeleteFailed": "ငှားရမ်းမှု ဖျက်မရပါ: {error}", + "@loanTrackingDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loanTrackingSummaryActiveLabel": "လက်ရှိ ငှားရမ်းမှု", + "@loanTrackingSummaryActiveLabel": {}, + "loanTrackingSummaryOverdueLabel": "ကျော်လွန်", + "@loanTrackingSummaryOverdueLabel": {}, + "loanTrackingSummaryLoadFailed": "ငှားရမ်းမှု အနှစ်ချုပ် တင်မရပါ။", + "@loanTrackingSummaryLoadFailed": {}, + "loanTrackingFieldBorrower": "ငှားယူသူ", + "@loanTrackingFieldBorrower": {}, + "loanTrackingFieldContact": "ဆက်သွယ်ရန်", + "@loanTrackingFieldContact": {}, + "loanTrackingFieldLoaned": "ငှားသည့်နေ့", + "@loanTrackingFieldLoaned": {}, + "loanTrackingFieldDue": "ပြန်အပ်ရမည့်နေ့", + "@loanTrackingFieldDue": {}, + "loanTrackingFieldReturned": "ပြန်အပ်သည့်နေ့", + "@loanTrackingFieldReturned": {}, + "loanTrackingStatusReturned": "ပြန်အပ်ပြီး", + "@loanTrackingStatusReturned": {}, + "loanTrackingStatusOverdue": "ကျော်လွန်", + "@loanTrackingStatusOverdue": {}, + "loanTrackingStatusActive": "လက်ရှိ", + "@loanTrackingStatusActive": {}, + "loanTrackingCreateTitle": "ငှားရမ်းမှု ဖန်တီး", + "@loanTrackingCreateTitle": {}, + "loanTrackingCreateDescription": "ဘယ်သူငှားယူသွားသည်နှင့် ဘယ်နေ့ ပြန်အပ်ရမည်ကို မှတ်တမ်းတင်ပါ။", + "@loanTrackingCreateDescription": {}, + "loanTrackingCreateNoItemsTitle": "အသုံးပြုနိုင်သော ပစ္စည်း မရှိပါ", + "@loanTrackingCreateNoItemsTitle": {}, + "loanTrackingCreateNoItemsMessage": "ပစ္စည်းအားလုံး ငှားထားပြီး သို့မဟုတ် ပစ္စည်းမရှိသေးပါ။", + "@loanTrackingCreateNoItemsMessage": {}, + "loanTrackingCreateItemLabel": "ပစ္စည်း", + "@loanTrackingCreateItemLabel": {}, + "loanTrackingCreateBorrowerLabel": "ငှားယူသူ အမည်", + "@loanTrackingCreateBorrowerLabel": {}, + "loanTrackingCreateBorrowerHint": "ဥပမာ - Aung Aung", + "@loanTrackingCreateBorrowerHint": {}, + "loanTrackingCreateContactLabel": "ဆက်သွယ်ရန် (မဖြည့်လည်းရ)", + "@loanTrackingCreateContactLabel": {}, + "loanTrackingCreateContactHint": "ဖုန်း၊ အီးမေးလ် သို့မဟုတ် @username", + "@loanTrackingCreateContactHint": {}, + "loanTrackingCreateNotesLabel": "မှတ်စု (မဖြည့်လည်းရ)", + "@loanTrackingCreateNotesLabel": {}, + "loanTrackingCreateNotesHint": "ဒီငှားရမ်းမှုအတွက် အသေးစိတ်", + "@loanTrackingCreateNotesHint": {}, + "loanTrackingCreateSubmitting": "ဖန်တီးနေသည်...", + "@loanTrackingCreateSubmitting": {}, + "loanTrackingCreateAction": "ငှားရမ်းမှု ဖန်တီး", + "@loanTrackingCreateAction": {}, + "loanTrackingLoadingItems": "ပစ္စည်းများ တင်နေသည်...", + "@loanTrackingLoadingItems": {}, + "loanTrackingLoadItemsFailed": "ပစ္စည်းများ တင်မရပါ: {error}", + "@loanTrackingLoadItemsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loanTrackingBorrowerRequired": "ငှားယူသူ အမည် မဖြစ်မနေလိုအပ်သည်။", + "@loanTrackingBorrowerRequired": {}, + "loanTrackingCreateSuccess": "ငှားရမ်းမှု အောင်မြင်စွာ ဖန်တီးပြီးပါပြီ။", + "@loanTrackingCreateSuccess": {}, + "loanTrackingCreateFailed": "ငှားရမ်းမှု ဖန်တီးမရပါ: {error}", + "@loanTrackingCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loanTrackingNoDueDate": "ပြန်အပ်ရမည့်နေ့ မသတ်မှတ်ထားပါ", + "@loanTrackingNoDueDate": {}, + "loanTrackingPickDateAction": "ရွေးမည်", + "@loanTrackingPickDateAction": {}, + "loanTrackingClearDateAction": "ရှင်းမည်", + "@loanTrackingClearDateAction": {}, + "loanTrackingDueDateLabel": "ပြန်အပ်ရမည့်နေ့", + "@loanTrackingDueDateLabel": {}, + "authTitleAccount": "အကောင့်", + "@authTitleAccount": {}, + "authCreateAccountHeading": "အကောင့် ဖန်တီးရန်", + "@authCreateAccountHeading": {}, + "authSignInHeading": "ဝင်မည်", + "@authSignInHeading": {}, + "authCreateAccountDescription": "စက်များအကြား စုစည်းမှုများကို sync လုပ်ရန် အကောင့်တစ်ခု ဖန်တီးပါ။", + "@authCreateAccountDescription": {}, + "authSignInDescription": "Cloud sync နှင့် အကောင့်ဆိုင်ရာ လုပ်ဆောင်ချက်များအသုံးပြုရန် ဝင်ပါ။", + "@authSignInDescription": {}, + "authSignInChoice": "ဝင်မည်", + "@authSignInChoice": {}, + "authRegisterChoice": "မှတ်ပုံတင်မည်", + "@authRegisterChoice": {}, + "authEmailLabel": "အီးမေးလ်", + "@authEmailLabel": {}, + "authEmailHint": "you@example.com", + "@authEmailHint": {}, + "authEmailRequiredError": "အီးမေးလ် မဖြစ်မနေလိုအပ်သည်။", + "@authEmailRequiredError": {}, + "authEmailInvalidError": "မှန်ကန်သော အီးမေးလ် ထည့်ပါ။", + "@authEmailInvalidError": {}, + "authPasswordLabel": "လျှို့ဝှက်နံပါတ်", + "@authPasswordLabel": {}, + "authPasswordHint": "အနည်းဆုံး ၈ လုံး၊ A-Z, a-z, 0-9", + "@authPasswordHint": {}, + "authPasswordRequiredError": "လျှို့ဝှက်နံပါတ် မဖြစ်မနေလိုအပ်သည်။", + "@authPasswordRequiredError": {}, + "authPasswordLengthError": "လျှို့ဝှက်နံပါတ်မှာ အနည်းဆုံး ၈ လုံး ရှိရမည်။", + "@authPasswordLengthError": {}, + "authPasswordPolicyError": "လျှို့ဝှက်နံပါတ်တွင် စာလုံးကြီး၊ စာလုံးသေးနှင့် ဂဏန်း ပါဝင်ရမည်။", + "@authPasswordPolicyError": {}, + "authDisplayNameLabel": "ပြသမည့်အမည် (မဖြည့်လည်းရ)", + "@authDisplayNameLabel": {}, + "authDisplayNameHint": "သင့်ကို ဘယ်လိုခေါ်မလဲ?", + "@authDisplayNameHint": {}, + "authCreateAccountAction": "အကောင့်ဖန်တီးမည်", + "@authCreateAccountAction": {}, + "authNotNowAction": "အခုမလုပ်တော့", + "@authNotNowAction": {}, + "authUnavailableMessage": "ယခု အတည်ပြုဝင်ရောက်မှု မရနိုင်သေးပါ။", + "@authUnavailableMessage": {}, + "authRegisterSuccess": "အကောင့် ဖန်တီးပြီး ဝင်ရောက်ပြီးပါပြီ။", + "@authRegisterSuccess": {}, + "authSignInSuccess": "ဝင်ရောက်မှု အောင်မြင်သည်။", + "@authSignInSuccess": {}, + "authSignInFailed": "ဝင်ရောက်မှု မအောင်မြင်ပါ: {error}", + "@authSignInFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "authSignedOut": "ထွက်ပြီးပါပြီ။", + "@authSignedOut": {}, + "authFinalConfirmationTitle": "နောက်ဆုံးအတည်ပြုချက်", + "@authFinalConfirmationTitle": {}, + "authFinalConfirmationMessage": "အကောင့်ဖျက်ရန် တောင်းဆိုချက်ကို ယခု ပို့မလား? ဒီစက်တွင် ချက်ချင်း ထွက်သွားပါမည်။", + "@authFinalConfirmationMessage": {}, + "authBackAction": "နောက်သို့", + "@authBackAction": {}, + "authSubmitRequestAction": "တောင်းဆိုချက် ပို့မည်", + "@authSubmitRequestAction": {}, + "authDeletionRequestSubmitted": "အကောင့်ဖျက်ရန် တောင်းဆိုချက် ပို့ပြီးပါပြီ။ သင့်ကို ထွက်ပေးလိုက်ပါပြီ။", + "@authDeletionRequestSubmitted": {}, + "authDeletionEndpointMissing": "ဖျက်ရန် တောင်းဆို endpoint ကို backend တွင် မသတ်မှတ်ရသေးပါ။", + "@authDeletionEndpointMissing": {}, + "authDeletionImpactDialogTitle": "အကောင့်ဖျက်ရန် တောင်းဆိုမီ", + "@authDeletionImpactDialogTitle": {}, + "authDeletionImpactReviewPrompt": "သက်ရောက်မှုကို သေချာစွာ စစ်ဆေးပါ။", + "@authDeletionImpactReviewPrompt": {}, + "authIrreversibleRequestTitle": "ပြန်မယူနိုင်သော တောင်းဆိုချက်", + "@authIrreversibleRequestTitle": {}, + "authImpactLineSessionRevoked": "တောင်းဆိုပြီးချင်း သင့်အကောင့် session ကို ပိတ်ပါမည်။", + "@authImpactLineSessionRevoked": {}, + "authImpactLineCloudDataDeleted": "ဒီအကောင့်နှင့်ချိတ်ဆက်ထားသော sync cloud ဒေတာများကို လုပ်ဆောင်နေစဉ် အပြီးအပိုင် ဖျက်နိုင်ပါသည်။", + "@authImpactLineCloudDataDeleted": {}, + "authImpactLineCannotRestore": "ဖျက်ပြီးသော အကောင့်ဒေတာကို လုပ်ဆောင်ပြီးနောက် ပြန်မရနိုင်ပါ။", + "@authImpactLineCannotRestore": {}, + "authUnderstandAction": "နားလည်ပါသည်", + "@authUnderstandAction": {}, + "authPasswordPolicySuffix": "အင်္ဂလိပ်ကီးဘုတ် စာလုံးများနှင့် ဂဏန်းများ (A-Z, a-z, 0-9) ကို အသုံးပြုပါ။", + "@authPasswordPolicySuffix": {}, + "authAccountConnected": "အကောင့် ချိတ်ဆက်ပြီး", + "@authAccountConnected": {}, + "authSignedInReadySubtitle": "ဝင်ထားပြီး Cloud sync အတွက် အဆင်သင့်", + "@authSignedInReadySubtitle": {}, + "authActiveStatus": "အသုံးပြုနေ", + "@authActiveStatus": {}, + "authSessionDetailsTitle": "Session အသေးစိတ်", + "@authSessionDetailsTitle": {}, + "authUserIdLabel": "User ID", + "@authUserIdLabel": {}, + "authDeviceIdLabel": "Device ID", + "@authDeviceIdLabel": {}, + "authUnknownValue": "မသိ", + "@authUnknownValue": {}, + "authDeletionNoticeTitle": "အကောင့်ဖျက်ရန် အသိပေးချက်", + "@authDeletionNoticeTitle": {}, + "authDeletionNoticeSubtitle": "ဖျက်ရန် တောင်းဆိုချက်သည် လုပ်ဆောင်ပြီးပါက ပြန်မရနိုင်ပါ။", + "@authDeletionNoticeSubtitle": {}, + "authDeletionNoticeLineProfileSessions": "အကောင့်ပရိုဖိုင်နှင့် active session များကို cloud access မှ ဖယ်ရှားပါမည်။", + "@authDeletionNoticeLineProfileSessions": {}, + "authDeletionNoticeLineSyncedData": "Sync လုပ်ထားသော collections, items, tags နှင့် loans များကို အပြီးအပိုင် ဖျက်နိုင်ပါသည်။", + "@authDeletionNoticeLineSyncedData": {}, + "authRequestDeletionAction": "အကောင့်ဖျက်ရန် တောင်းဆို", + "@authRequestDeletionAction": {}, + "authSignOutAction": "ထွက်မည်", + "@authSignOutAction": {}, + "authDoneAction": "ပြီးပါပြီ", + "@authDoneAction": {}, + "authHeaderCreateTitle": "သင့်အကောင့် ဖန်တီးပါ", + "@authHeaderCreateTitle": {}, + "authHeaderWelcomeTitle": "ပြန်လည်ကြိုဆိုပါသည်", + "@authHeaderWelcomeTitle": {}, + "authHeaderCreateSubtitle": "အကောင့်မဖွင့်လည်း ရပါသည်၊ သို့သော် cloud sync နှင့် စက်အများအပြား အသုံးပြုရန် လိုအပ်ပါသည်။", + "@authHeaderCreateSubtitle": {}, + "authHeaderSignInSubtitle": "Cloud sync နှင့် အကောင့်အခြေပြု လုပ်ဆောင်ချက်များ အသုံးပြုရန် ဝင်ပါ။", + "@authHeaderSignInSubtitle": {}, + "authUnavailableTitle": "အတည်ပြုဝင်ရောက်မှု မရနိုင်ပါ", + "@authUnavailableTitle": {} } diff --git a/apps/mobile/lib/l10n/arb/app_zh.arb b/apps/mobile/lib/l10n/arb/app_zh.arb index 41eb06c..7006f12 100644 --- a/apps/mobile/lib/l10n/arb/app_zh.arb +++ b/apps/mobile/lib/l10n/arb/app_zh.arb @@ -1,6 +1,6 @@ { "@@locale": "zh", - "appTitle": "Collection Tracker", + "appTitle": "Collectra", "@appTitle": {}, "navHome": "首页", "@navHome": {}, @@ -68,6 +68,10 @@ "@settingsManageTagsTitle": {}, "settingsManageTagsSubtitle": "重命名、合并和删除标签", "@settingsManageTagsSubtitle": {}, + "settingsLoanTrackingTitle": "借出追踪", + "@settingsLoanTrackingTitle": {}, + "settingsLoanTrackingSubtitle": "跟踪借出物品和归还日期", + "@settingsLoanTrackingSubtitle": {}, "settingsVersionTitle": "版本", "@settingsVersionTitle": {}, "settingsPrivacyPolicyTitle": "隐私政策", @@ -108,7 +112,7 @@ "@settingsAnalyticsConsentAccepted": {}, "settingsAnalyticsConsentDeclined": "已拒绝分析收集。", "@settingsAnalyticsConsentDeclined": {}, - "analyticsConsentDialogTitle": "帮助改进 Collection Tracker", + "analyticsConsentDialogTitle": "帮助改进 Collectra", "@analyticsConsentDialogTitle": {}, "analyticsConsentDialogMessage": "我们可以收集匿名使用分析以改进应用质量和功能吗?你可以随时在设置中更改。", "@analyticsConsentDialogMessage": {}, @@ -140,6 +144,40 @@ "@settingsFirebaseRuntimeConfigTitle": {}, "settingsFirebaseRuntimeConfigSubtitle": "查看并刷新运行时功能开关", "@settingsFirebaseRuntimeConfigSubtitle": {}, + "settingsMetadataTitle": "元数据与自动填充", + "@settingsMetadataTitle": {}, + "settingsMetadataSummaryEnabled": "已启用(自动条码查询)", + "@settingsMetadataSummaryEnabled": {}, + "settingsMetadataSummaryManual": "已启用(手动查询)", + "@settingsMetadataSummaryManual": {}, + "settingsMetadataSummaryDisabled": "已禁用", + "@settingsMetadataSummaryDisabled": {}, + "settingsMetadataSummaryFeatureDisabled": "已被运行时功能开关禁用", + "@settingsMetadataSummaryFeatureDisabled": {}, + "settingsMetadataEnableToggleTitle": "启用元数据辅助", + "@settingsMetadataEnableToggleTitle": {}, + "settingsMetadataEnableToggleSubtitle": "在条目表单中允许元数据搜索和基于条码的自动填充。", + "@settingsMetadataEnableToggleSubtitle": {}, + "settingsMetadataAutoFetchToggleTitle": "扫描条码后自动获取", + "@settingsMetadataAutoFetchToggleTitle": {}, + "settingsMetadataAutoFetchToggleSubtitle": "扫描条码后自动获取元数据。", + "@settingsMetadataAutoFetchToggleSubtitle": {}, + "settingsMetadataFillEmptyOnlyToggleTitle": "仅填充空字段", + "@settingsMetadataFillEmptyOnlyToggleTitle": {}, + "settingsMetadataFillEmptyOnlyToggleSubtitle": "找到元数据时不覆盖现有标题或描述。", + "@settingsMetadataFillEmptyOnlyToggleSubtitle": {}, + "settingsMetadataSourcesSectionTitle": "数据源", + "@settingsMetadataSourcesSectionTitle": {}, + "settingsMetadataSourceAvailable": "可用", + "@settingsMetadataSourceAvailable": {}, + "settingsMetadataSourceNotConfigured": "未配置", + "@settingsMetadataSourceNotConfigured": {}, + "settingsMetadataSourceManualOnly": "仅手动", + "@settingsMetadataSourceManualOnly": {}, + "settingsMetadataManualCollectionsLabel": "漫画、音乐和自定义", + "@settingsMetadataManualCollectionsLabel": {}, + "settingsMetadataFeatureDisabledMessage": "元数据辅助已被运行时配置禁用。", + "@settingsMetadataFeatureDisabledMessage": {}, "settingsFirebaseRuntimeConfigSheetTitle": "Firebase 运行时配置", "@settingsFirebaseRuntimeConfigSheetTitle": {}, "settingsFirebaseRuntimeConfigDescription": "这些值来自 Firebase Remote Config,并在运行时生效。", @@ -204,7 +242,7 @@ }, "settingsImportDataTitle": "导入数据", "@settingsImportDataTitle": {}, - "settingsImportDataMessage": "这将从 JSON 文件导入集合和条目。现有数据不会被删除。\\n\\n是否继续?", + "settingsImportDataMessage": "这将从 JSON 文件导入集合和条目。现有数据不会被删除。\n\n是否继续?", "@settingsImportDataMessage": {}, "settingsImportingData": "正在导入数据...", "@settingsImportingData": {}, @@ -609,6 +647,18 @@ "@metadataSearchSuggestionTitle": {}, "metadataSearchSuggestionMessage": "开始输入以查找元数据。", "@metadataSearchSuggestionMessage": {}, + "metadataSearchDisabledHint": "此集合类型不支持元数据搜索,或当前已禁用。", + "@metadataSearchDisabledHint": {}, + "metadataNoMatchForBarcode": "未找到与此条码匹配的元数据。", + "@metadataNoMatchForBarcode": {}, + "metadataSearchUnavailableForType": "{collectionType} 不支持元数据搜索。", + "@metadataSearchUnavailableForType": { + "placeholders": { + "collectionType": { + "type": "String" + } + } + }, "tagItemsTitle": "标签:{tag}", "@tagItemsTitle": { "placeholders": { @@ -749,7 +799,7 @@ }, "tagManagementDeleteSelectedTitle": "删除所选标签", "@tagManagementDeleteSelectedTitle": {}, - "tagManagementDeleteSelectedMessage": "要从所有条目中删除所选的 {count} 个标签吗?\\n\\n此操作无法撤销。", + "tagManagementDeleteSelectedMessage": "要从所有条目中删除所选的 {count} 个标签吗?\n\n此操作无法撤销。", "@tagManagementDeleteSelectedMessage": { "placeholders": { "count": { @@ -780,7 +830,7 @@ }, "tagManagementDeleteTitle": "删除标签", "@tagManagementDeleteTitle": {}, - "tagManagementDeleteMessage": "要从所有条目中删除“{tagName}”吗?\\n\\n此操作无法撤销。", + "tagManagementDeleteMessage": "要从所有条目中删除“{tagName}”吗?\n\n此操作无法撤销。", "@tagManagementDeleteMessage": { "placeholders": { "tagName": { @@ -976,5 +1026,273 @@ "collectionTypeMusic": "音乐", "@collectionTypeMusic": {}, "collectionTypeCustom": "自定义", - "@collectionTypeCustom": {} + "@collectionTypeCustom": {}, + "loanTrackingTitle": "借出追踪", + "@loanTrackingTitle": {}, + "loanTrackingNewLoan": "新建借出", + "@loanTrackingNewLoan": {}, + "loanTrackingFilterActive": "进行中", + "@loanTrackingFilterActive": {}, + "loanTrackingFilterHistory": "历史", + "@loanTrackingFilterHistory": {}, + "loanTrackingEmptyHistoryTitle": "暂无已归还借出", + "@loanTrackingEmptyHistoryTitle": {}, + "loanTrackingEmptyHistoryMessage": "已归还条目会显示在这里。", + "@loanTrackingEmptyHistoryMessage": {}, + "loanTrackingEmptyActiveTitle": "暂无进行中借出", + "@loanTrackingEmptyActiveTitle": {}, + "loanTrackingEmptyActiveMessage": "创建一条借出记录来跟踪借出物品。", + "@loanTrackingEmptyActiveMessage": {}, + "loanTrackingLoadingLoans": "正在加载借出记录...", + "@loanTrackingLoadingLoans": {}, + "loanTrackingLoadFailed": "加载借出记录失败:{error}", + "@loanTrackingLoadFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loanTrackingMarkReturnedConfirmTitle": "标记为已归还?", + "@loanTrackingMarkReturnedConfirmTitle": {}, + "loanTrackingMarkReturnedConfirmMessage": "确认「{itemTitle}」已归还。", + "@loanTrackingMarkReturnedConfirmMessage": { + "placeholders": { + "itemTitle": { + "type": "String" + } + } + }, + "loanTrackingMarkReturnedAction": "标记已归还", + "@loanTrackingMarkReturnedAction": {}, + "loanTrackingMarkedReturnedSuccess": "借出已标记为归还。", + "@loanTrackingMarkedReturnedSuccess": {}, + "loanTrackingMarkReturnedFailed": "标记归还失败:{error}", + "@loanTrackingMarkReturnedFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loanTrackingDeleteConfirmTitle": "删除借出记录?", + "@loanTrackingDeleteConfirmTitle": {}, + "loanTrackingDeleteConfirmMessage": "删除「{itemTitle}」的借出记录。", + "@loanTrackingDeleteConfirmMessage": { + "placeholders": { + "itemTitle": { + "type": "String" + } + } + }, + "loanTrackingDeleteSuccess": "借出记录已删除。", + "@loanTrackingDeleteSuccess": {}, + "loanTrackingDeleteFailed": "删除借出记录失败:{error}", + "@loanTrackingDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loanTrackingSummaryActiveLabel": "进行中借出", + "@loanTrackingSummaryActiveLabel": {}, + "loanTrackingSummaryOverdueLabel": "逾期", + "@loanTrackingSummaryOverdueLabel": {}, + "loanTrackingSummaryLoadFailed": "无法加载借出摘要。", + "@loanTrackingSummaryLoadFailed": {}, + "loanTrackingFieldBorrower": "借用人", + "@loanTrackingFieldBorrower": {}, + "loanTrackingFieldContact": "联系方式", + "@loanTrackingFieldContact": {}, + "loanTrackingFieldLoaned": "借出日期", + "@loanTrackingFieldLoaned": {}, + "loanTrackingFieldDue": "到期日期", + "@loanTrackingFieldDue": {}, + "loanTrackingFieldReturned": "归还日期", + "@loanTrackingFieldReturned": {}, + "loanTrackingStatusReturned": "已归还", + "@loanTrackingStatusReturned": {}, + "loanTrackingStatusOverdue": "逾期", + "@loanTrackingStatusOverdue": {}, + "loanTrackingStatusActive": "进行中", + "@loanTrackingStatusActive": {}, + "loanTrackingCreateTitle": "创建借出", + "@loanTrackingCreateTitle": {}, + "loanTrackingCreateDescription": "记录谁借走了物品以及应归还时间。", + "@loanTrackingCreateDescription": {}, + "loanTrackingCreateNoItemsTitle": "暂无可借出条目", + "@loanTrackingCreateNoItemsTitle": {}, + "loanTrackingCreateNoItemsMessage": "所有条目都在借出中,或还没有条目。", + "@loanTrackingCreateNoItemsMessage": {}, + "loanTrackingCreateItemLabel": "条目", + "@loanTrackingCreateItemLabel": {}, + "loanTrackingCreateBorrowerLabel": "借用人姓名", + "@loanTrackingCreateBorrowerLabel": {}, + "loanTrackingCreateBorrowerHint": "例如:张三", + "@loanTrackingCreateBorrowerHint": {}, + "loanTrackingCreateContactLabel": "联系方式(可选)", + "@loanTrackingCreateContactLabel": {}, + "loanTrackingCreateContactHint": "电话、邮箱或 @username", + "@loanTrackingCreateContactHint": {}, + "loanTrackingCreateNotesLabel": "备注(可选)", + "@loanTrackingCreateNotesLabel": {}, + "loanTrackingCreateNotesHint": "这次借出的额外说明", + "@loanTrackingCreateNotesHint": {}, + "loanTrackingCreateSubmitting": "创建中...", + "@loanTrackingCreateSubmitting": {}, + "loanTrackingCreateAction": "创建借出", + "@loanTrackingCreateAction": {}, + "loanTrackingLoadingItems": "正在加载条目...", + "@loanTrackingLoadingItems": {}, + "loanTrackingLoadItemsFailed": "加载条目失败:{error}", + "@loanTrackingLoadItemsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loanTrackingBorrowerRequired": "借用人姓名不能为空。", + "@loanTrackingBorrowerRequired": {}, + "loanTrackingCreateSuccess": "借出创建成功。", + "@loanTrackingCreateSuccess": {}, + "loanTrackingCreateFailed": "创建借出失败:{error}", + "@loanTrackingCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loanTrackingNoDueDate": "无到期日期", + "@loanTrackingNoDueDate": {}, + "loanTrackingPickDateAction": "选择", + "@loanTrackingPickDateAction": {}, + "loanTrackingClearDateAction": "清除", + "@loanTrackingClearDateAction": {}, + "loanTrackingDueDateLabel": "到期日期", + "@loanTrackingDueDateLabel": {}, + "authTitleAccount": "账户", + "@authTitleAccount": {}, + "authCreateAccountHeading": "创建账户", + "@authCreateAccountHeading": {}, + "authSignInHeading": "登录", + "@authSignInHeading": {}, + "authCreateAccountDescription": "创建账户以在多台设备间同步你的收藏。", + "@authCreateAccountDescription": {}, + "authSignInDescription": "登录以启用云同步和账户功能。", + "@authSignInDescription": {}, + "authSignInChoice": "登录", + "@authSignInChoice": {}, + "authRegisterChoice": "注册", + "@authRegisterChoice": {}, + "authEmailLabel": "邮箱", + "@authEmailLabel": {}, + "authEmailHint": "you@example.com", + "@authEmailHint": {}, + "authEmailRequiredError": "邮箱为必填项。", + "@authEmailRequiredError": {}, + "authEmailInvalidError": "请输入有效的邮箱地址。", + "@authEmailInvalidError": {}, + "authPasswordLabel": "密码", + "@authPasswordLabel": {}, + "authPasswordHint": "至少8位,A-Z、a-z、0-9", + "@authPasswordHint": {}, + "authPasswordRequiredError": "密码为必填项。", + "@authPasswordRequiredError": {}, + "authPasswordLengthError": "密码至少需要8位字符。", + "@authPasswordLengthError": {}, + "authPasswordPolicyError": "密码必须包含大写字母、小写字母和数字。", + "@authPasswordPolicyError": {}, + "authDisplayNameLabel": "显示名称(可选)", + "@authDisplayNameLabel": {}, + "authDisplayNameHint": "我们应该如何称呼你?", + "@authDisplayNameHint": {}, + "authCreateAccountAction": "创建账户", + "@authCreateAccountAction": {}, + "authNotNowAction": "暂不", + "@authNotNowAction": {}, + "authUnavailableMessage": "当前无法使用身份验证。", + "@authUnavailableMessage": {}, + "authRegisterSuccess": "账户已创建并已登录。", + "@authRegisterSuccess": {}, + "authSignInSuccess": "登录成功。", + "@authSignInSuccess": {}, + "authSignInFailed": "登录失败:{error}", + "@authSignInFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "authSignedOut": "已退出登录。", + "@authSignedOut": {}, + "authFinalConfirmationTitle": "最终确认", + "@authFinalConfirmationTitle": {}, + "authFinalConfirmationMessage": "现在提交账户删除请求吗?你将立即在此设备上退出登录。", + "@authFinalConfirmationMessage": {}, + "authBackAction": "返回", + "@authBackAction": {}, + "authSubmitRequestAction": "提交请求", + "@authSubmitRequestAction": {}, + "authDeletionRequestSubmitted": "账户删除请求已提交。你已退出登录。", + "@authDeletionRequestSubmitted": {}, + "authDeletionEndpointMissing": "后端尚未配置删除请求接口。", + "@authDeletionEndpointMissing": {}, + "authDeletionImpactDialogTitle": "请求删除账户前", + "@authDeletionImpactDialogTitle": {}, + "authDeletionImpactReviewPrompt": "请仔细确认影响。", + "@authDeletionImpactReviewPrompt": {}, + "authIrreversibleRequestTitle": "不可逆请求", + "@authIrreversibleRequestTitle": {}, + "authImpactLineSessionRevoked": "提交请求后,你的账户会话将立即失效。", + "@authImpactLineSessionRevoked": {}, + "authImpactLineCloudDataDeleted": "与该账户关联的云端同步数据在处理过程中可能被永久删除。", + "@authImpactLineCloudDataDeleted": {}, + "authImpactLineCannotRestore": "账户数据一旦删除并处理完成将无法恢复。", + "@authImpactLineCannotRestore": {}, + "authUnderstandAction": "我已了解", + "@authUnderstandAction": {}, + "authPasswordPolicySuffix": "请使用英文键盘字母和数字(A-Z、a-z、0-9)。", + "@authPasswordPolicySuffix": {}, + "authAccountConnected": "账户已连接", + "@authAccountConnected": {}, + "authSignedInReadySubtitle": "已登录并可进行云同步", + "@authSignedInReadySubtitle": {}, + "authActiveStatus": "已激活", + "@authActiveStatus": {}, + "authSessionDetailsTitle": "会话详情", + "@authSessionDetailsTitle": {}, + "authUserIdLabel": "用户 ID", + "@authUserIdLabel": {}, + "authDeviceIdLabel": "设备 ID", + "@authDeviceIdLabel": {}, + "authUnknownValue": "未知", + "@authUnknownValue": {}, + "authDeletionNoticeTitle": "账户删除提示", + "@authDeletionNoticeTitle": {}, + "authDeletionNoticeSubtitle": "删除请求一旦处理即不可撤销。", + "@authDeletionNoticeSubtitle": {}, + "authDeletionNoticeLineProfileSessions": "账户资料和活跃会话将从云端访问中移除。", + "@authDeletionNoticeLineProfileSessions": {}, + "authDeletionNoticeLineSyncedData": "已同步的收藏、条目、标签和借出记录可能被永久删除。", + "@authDeletionNoticeLineSyncedData": {}, + "authRequestDeletionAction": "请求删除账户", + "@authRequestDeletionAction": {}, + "authSignOutAction": "退出登录", + "@authSignOutAction": {}, + "authDoneAction": "完成", + "@authDoneAction": {}, + "authHeaderCreateTitle": "创建你的账户", + "@authHeaderCreateTitle": {}, + "authHeaderWelcomeTitle": "欢迎回来", + "@authHeaderWelcomeTitle": {}, + "authHeaderCreateSubtitle": "账户不是必需的,但云同步和多设备访问需要登录账户。", + "@authHeaderCreateSubtitle": {}, + "authHeaderSignInSubtitle": "登录以使用云同步和账户相关功能。", + "@authHeaderSignInSubtitle": {}, + "authUnavailableTitle": "身份验证不可用", + "@authUnavailableTitle": {} } diff --git a/apps/mobile/lib/l10n/gen/app_localizations.dart b/apps/mobile/lib/l10n/gen/app_localizations.dart index d4e4491..e55dba3 100644 --- a/apps/mobile/lib/l10n/gen/app_localizations.dart +++ b/apps/mobile/lib/l10n/gen/app_localizations.dart @@ -110,7 +110,7 @@ abstract class AppLocalizations { /// No description provided for @appTitle. /// /// In en, this message translates to: - /// **'Collection Tracker'** + /// **'Collectra'** String get appTitle; /// No description provided for @navHome. @@ -311,6 +311,18 @@ abstract class AppLocalizations { /// **'Rename, merge, and delete tags'** String get settingsManageTagsSubtitle; + /// No description provided for @settingsLoanTrackingTitle. + /// + /// In en, this message translates to: + /// **'Loan Tracking'** + String get settingsLoanTrackingTitle; + + /// No description provided for @settingsLoanTrackingSubtitle. + /// + /// In en, this message translates to: + /// **'Track borrowed items and return dates'** + String get settingsLoanTrackingSubtitle; + /// No description provided for @settingsVersionTitle. /// /// In en, this message translates to: @@ -434,7 +446,7 @@ abstract class AppLocalizations { /// No description provided for @analyticsConsentDialogTitle. /// /// In en, this message translates to: - /// **'Help Improve Collection Tracker'** + /// **'Help Improve Collectra'** String get analyticsConsentDialogTitle; /// No description provided for @analyticsConsentDialogMessage. @@ -509,6 +521,108 @@ abstract class AppLocalizations { /// **'Inspect and refresh runtime feature flags'** String get settingsFirebaseRuntimeConfigSubtitle; + /// No description provided for @settingsMetadataTitle. + /// + /// In en, this message translates to: + /// **'Metadata & Autofill'** + String get settingsMetadataTitle; + + /// No description provided for @settingsMetadataSummaryEnabled. + /// + /// In en, this message translates to: + /// **'Enabled with automatic barcode lookup'** + String get settingsMetadataSummaryEnabled; + + /// No description provided for @settingsMetadataSummaryManual. + /// + /// In en, this message translates to: + /// **'Enabled with manual lookup'** + String get settingsMetadataSummaryManual; + + /// No description provided for @settingsMetadataSummaryDisabled. + /// + /// In en, this message translates to: + /// **'Disabled'** + String get settingsMetadataSummaryDisabled; + + /// No description provided for @settingsMetadataSummaryFeatureDisabled. + /// + /// In en, this message translates to: + /// **'Disabled by runtime feature flag'** + String get settingsMetadataSummaryFeatureDisabled; + + /// No description provided for @settingsMetadataEnableToggleTitle. + /// + /// In en, this message translates to: + /// **'Enable metadata assistance'** + String get settingsMetadataEnableToggleTitle; + + /// No description provided for @settingsMetadataEnableToggleSubtitle. + /// + /// In en, this message translates to: + /// **'Allow metadata search and barcode-based autofill in item forms.'** + String get settingsMetadataEnableToggleSubtitle; + + /// No description provided for @settingsMetadataAutoFetchToggleTitle. + /// + /// In en, this message translates to: + /// **'Auto-fetch from barcode scan'** + String get settingsMetadataAutoFetchToggleTitle; + + /// No description provided for @settingsMetadataAutoFetchToggleSubtitle. + /// + /// In en, this message translates to: + /// **'After scanning a barcode, fetch metadata automatically.'** + String get settingsMetadataAutoFetchToggleSubtitle; + + /// No description provided for @settingsMetadataFillEmptyOnlyToggleTitle. + /// + /// In en, this message translates to: + /// **'Fill empty fields only'** + String get settingsMetadataFillEmptyOnlyToggleTitle; + + /// No description provided for @settingsMetadataFillEmptyOnlyToggleSubtitle. + /// + /// In en, this message translates to: + /// **'Do not overwrite existing title or description when metadata is found.'** + String get settingsMetadataFillEmptyOnlyToggleSubtitle; + + /// No description provided for @settingsMetadataSourcesSectionTitle. + /// + /// In en, this message translates to: + /// **'Sources'** + String get settingsMetadataSourcesSectionTitle; + + /// No description provided for @settingsMetadataSourceAvailable. + /// + /// In en, this message translates to: + /// **'Available'** + String get settingsMetadataSourceAvailable; + + /// No description provided for @settingsMetadataSourceNotConfigured. + /// + /// In en, this message translates to: + /// **'Not configured'** + String get settingsMetadataSourceNotConfigured; + + /// No description provided for @settingsMetadataSourceManualOnly. + /// + /// In en, this message translates to: + /// **'Manual only'** + String get settingsMetadataSourceManualOnly; + + /// No description provided for @settingsMetadataManualCollectionsLabel. + /// + /// In en, this message translates to: + /// **'Comics, Music, and Custom'** + String get settingsMetadataManualCollectionsLabel; + + /// No description provided for @settingsMetadataFeatureDisabledMessage. + /// + /// In en, this message translates to: + /// **'Metadata assistance is disabled by runtime configuration.'** + String get settingsMetadataFeatureDisabledMessage; + /// No description provided for @settingsFirebaseRuntimeConfigSheetTitle. /// /// In en, this message translates to: @@ -650,7 +764,7 @@ abstract class AppLocalizations { /// No description provided for @settingsImportDataMessage. /// /// In en, this message translates to: - /// **'This will import collections and items from a JSON file. Existing data will not be deleted.\\n\\nContinue?'** + /// **'This will import collections and items from a JSON file. Existing data will not be deleted.\n\nContinue?'** String get settingsImportDataMessage; /// No description provided for @settingsImportingData. @@ -1475,6 +1589,24 @@ abstract class AppLocalizations { /// **'Start typing to look up metadata.'** String get metadataSearchSuggestionMessage; + /// No description provided for @metadataSearchDisabledHint. + /// + /// In en, this message translates to: + /// **'Metadata search is unavailable for this collection type or currently disabled.'** + String get metadataSearchDisabledHint; + + /// No description provided for @metadataNoMatchForBarcode. + /// + /// In en, this message translates to: + /// **'No metadata match found for this barcode.'** + String get metadataNoMatchForBarcode; + + /// No description provided for @metadataSearchUnavailableForType. + /// + /// In en, this message translates to: + /// **'Metadata search is unavailable for {collectionType}.'** + String metadataSearchUnavailableForType(String collectionType); + /// No description provided for @tagItemsTitle. /// /// In en, this message translates to: @@ -1700,7 +1832,7 @@ abstract class AppLocalizations { /// No description provided for @tagManagementDeleteSelectedMessage. /// /// In en, this message translates to: - /// **'Delete {count} selected tags from all items?\\n\\nThis cannot be undone.'** + /// **'Delete {count} selected tags from all items?\n\nThis cannot be undone.'** String tagManagementDeleteSelectedMessage(int count); /// No description provided for @tagManagementDeleteSelectedSuccess. @@ -1730,7 +1862,7 @@ abstract class AppLocalizations { /// No description provided for @tagManagementDeleteMessage. /// /// In en, this message translates to: - /// **'Delete \"{tagName}\" from all items?\\n\\nThis cannot be undone.'** + /// **'Delete \"{tagName}\" from all items?\n\nThis cannot be undone.'** String tagManagementDeleteMessage(String tagName); /// No description provided for @tagManagementDeleteSuccess. @@ -2002,6 +2134,666 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Custom'** String get collectionTypeCustom; + + /// No description provided for @loanTrackingTitle. + /// + /// In en, this message translates to: + /// **'Loan Tracking'** + String get loanTrackingTitle; + + /// No description provided for @loanTrackingNewLoan. + /// + /// In en, this message translates to: + /// **'New Loan'** + String get loanTrackingNewLoan; + + /// No description provided for @loanTrackingFilterActive. + /// + /// In en, this message translates to: + /// **'Active'** + String get loanTrackingFilterActive; + + /// No description provided for @loanTrackingFilterHistory. + /// + /// In en, this message translates to: + /// **'History'** + String get loanTrackingFilterHistory; + + /// No description provided for @loanTrackingEmptyHistoryTitle. + /// + /// In en, this message translates to: + /// **'No returned loans yet'** + String get loanTrackingEmptyHistoryTitle; + + /// No description provided for @loanTrackingEmptyHistoryMessage. + /// + /// In en, this message translates to: + /// **'Returned items will appear here.'** + String get loanTrackingEmptyHistoryMessage; + + /// No description provided for @loanTrackingEmptyActiveTitle. + /// + /// In en, this message translates to: + /// **'No active loans'** + String get loanTrackingEmptyActiveTitle; + + /// No description provided for @loanTrackingEmptyActiveMessage. + /// + /// In en, this message translates to: + /// **'Create a loan to start tracking borrowed items.'** + String get loanTrackingEmptyActiveMessage; + + /// No description provided for @loanTrackingLoadingLoans. + /// + /// In en, this message translates to: + /// **'Loading loans...'** + String get loanTrackingLoadingLoans; + + /// No description provided for @loanTrackingLoadFailed. + /// + /// In en, this message translates to: + /// **'Failed to load loans: {error}'** + String loanTrackingLoadFailed(String error); + + /// No description provided for @loanTrackingMarkReturnedConfirmTitle. + /// + /// In en, this message translates to: + /// **'Mark as returned?'** + String get loanTrackingMarkReturnedConfirmTitle; + + /// No description provided for @loanTrackingMarkReturnedConfirmMessage. + /// + /// In en, this message translates to: + /// **'Confirm return for \"{itemTitle}\".'** + String loanTrackingMarkReturnedConfirmMessage(String itemTitle); + + /// No description provided for @loanTrackingMarkReturnedAction. + /// + /// In en, this message translates to: + /// **'Mark Returned'** + String get loanTrackingMarkReturnedAction; + + /// No description provided for @loanTrackingMarkedReturnedSuccess. + /// + /// In en, this message translates to: + /// **'Loan marked as returned.'** + String get loanTrackingMarkedReturnedSuccess; + + /// No description provided for @loanTrackingMarkReturnedFailed. + /// + /// In en, this message translates to: + /// **'Failed to mark return: {error}'** + String loanTrackingMarkReturnedFailed(String error); + + /// No description provided for @loanTrackingDeleteConfirmTitle. + /// + /// In en, this message translates to: + /// **'Delete loan record?'** + String get loanTrackingDeleteConfirmTitle; + + /// No description provided for @loanTrackingDeleteConfirmMessage. + /// + /// In en, this message translates to: + /// **'Delete loan record for \"{itemTitle}\".'** + String loanTrackingDeleteConfirmMessage(String itemTitle); + + /// No description provided for @loanTrackingDeleteSuccess. + /// + /// In en, this message translates to: + /// **'Loan deleted.'** + String get loanTrackingDeleteSuccess; + + /// No description provided for @loanTrackingDeleteFailed. + /// + /// In en, this message translates to: + /// **'Failed to delete loan: {error}'** + String loanTrackingDeleteFailed(String error); + + /// No description provided for @loanTrackingSummaryActiveLabel. + /// + /// In en, this message translates to: + /// **'Active Loans'** + String get loanTrackingSummaryActiveLabel; + + /// No description provided for @loanTrackingSummaryOverdueLabel. + /// + /// In en, this message translates to: + /// **'Overdue'** + String get loanTrackingSummaryOverdueLabel; + + /// No description provided for @loanTrackingSummaryLoadFailed. + /// + /// In en, this message translates to: + /// **'Unable to load loan summary.'** + String get loanTrackingSummaryLoadFailed; + + /// No description provided for @loanTrackingFieldBorrower. + /// + /// In en, this message translates to: + /// **'Borrower'** + String get loanTrackingFieldBorrower; + + /// No description provided for @loanTrackingFieldContact. + /// + /// In en, this message translates to: + /// **'Contact'** + String get loanTrackingFieldContact; + + /// No description provided for @loanTrackingFieldLoaned. + /// + /// In en, this message translates to: + /// **'Loaned'** + String get loanTrackingFieldLoaned; + + /// No description provided for @loanTrackingFieldDue. + /// + /// In en, this message translates to: + /// **'Due'** + String get loanTrackingFieldDue; + + /// No description provided for @loanTrackingFieldReturned. + /// + /// In en, this message translates to: + /// **'Returned'** + String get loanTrackingFieldReturned; + + /// No description provided for @loanTrackingStatusReturned. + /// + /// In en, this message translates to: + /// **'Returned'** + String get loanTrackingStatusReturned; + + /// No description provided for @loanTrackingStatusOverdue. + /// + /// In en, this message translates to: + /// **'Overdue'** + String get loanTrackingStatusOverdue; + + /// No description provided for @loanTrackingStatusActive. + /// + /// In en, this message translates to: + /// **'Active'** + String get loanTrackingStatusActive; + + /// No description provided for @loanTrackingCreateTitle. + /// + /// In en, this message translates to: + /// **'Create Loan'** + String get loanTrackingCreateTitle; + + /// No description provided for @loanTrackingCreateDescription. + /// + /// In en, this message translates to: + /// **'Track who borrowed an item and when it should be returned.'** + String get loanTrackingCreateDescription; + + /// No description provided for @loanTrackingCreateNoItemsTitle. + /// + /// In en, this message translates to: + /// **'No available items'** + String get loanTrackingCreateNoItemsTitle; + + /// No description provided for @loanTrackingCreateNoItemsMessage. + /// + /// In en, this message translates to: + /// **'All items are currently loaned or there are no items yet.'** + String get loanTrackingCreateNoItemsMessage; + + /// No description provided for @loanTrackingCreateItemLabel. + /// + /// In en, this message translates to: + /// **'Item'** + String get loanTrackingCreateItemLabel; + + /// No description provided for @loanTrackingCreateBorrowerLabel. + /// + /// In en, this message translates to: + /// **'Borrower name'** + String get loanTrackingCreateBorrowerLabel; + + /// No description provided for @loanTrackingCreateBorrowerHint. + /// + /// In en, this message translates to: + /// **'e.g. John Doe'** + String get loanTrackingCreateBorrowerHint; + + /// No description provided for @loanTrackingCreateContactLabel. + /// + /// In en, this message translates to: + /// **'Contact (optional)'** + String get loanTrackingCreateContactLabel; + + /// No description provided for @loanTrackingCreateContactHint. + /// + /// In en, this message translates to: + /// **'Phone, email, or @username'** + String get loanTrackingCreateContactHint; + + /// No description provided for @loanTrackingCreateNotesLabel. + /// + /// In en, this message translates to: + /// **'Notes (optional)'** + String get loanTrackingCreateNotesLabel; + + /// No description provided for @loanTrackingCreateNotesHint. + /// + /// In en, this message translates to: + /// **'Extra details for this loan'** + String get loanTrackingCreateNotesHint; + + /// No description provided for @loanTrackingCreateSubmitting. + /// + /// In en, this message translates to: + /// **'Creating...'** + String get loanTrackingCreateSubmitting; + + /// No description provided for @loanTrackingCreateAction. + /// + /// In en, this message translates to: + /// **'Create Loan'** + String get loanTrackingCreateAction; + + /// No description provided for @loanTrackingLoadingItems. + /// + /// In en, this message translates to: + /// **'Loading items...'** + String get loanTrackingLoadingItems; + + /// No description provided for @loanTrackingLoadItemsFailed. + /// + /// In en, this message translates to: + /// **'Failed to load items: {error}'** + String loanTrackingLoadItemsFailed(String error); + + /// No description provided for @loanTrackingBorrowerRequired. + /// + /// In en, this message translates to: + /// **'Borrower name is required.'** + String get loanTrackingBorrowerRequired; + + /// No description provided for @loanTrackingCreateSuccess. + /// + /// In en, this message translates to: + /// **'Loan created successfully.'** + String get loanTrackingCreateSuccess; + + /// No description provided for @loanTrackingCreateFailed. + /// + /// In en, this message translates to: + /// **'Failed to create loan: {error}'** + String loanTrackingCreateFailed(String error); + + /// No description provided for @loanTrackingNoDueDate. + /// + /// In en, this message translates to: + /// **'No due date'** + String get loanTrackingNoDueDate; + + /// No description provided for @loanTrackingPickDateAction. + /// + /// In en, this message translates to: + /// **'Pick'** + String get loanTrackingPickDateAction; + + /// No description provided for @loanTrackingClearDateAction. + /// + /// In en, this message translates to: + /// **'Clear'** + String get loanTrackingClearDateAction; + + /// No description provided for @loanTrackingDueDateLabel. + /// + /// In en, this message translates to: + /// **'Due date'** + String get loanTrackingDueDateLabel; + + /// No description provided for @authTitleAccount. + /// + /// In en, this message translates to: + /// **'Account'** + String get authTitleAccount; + + /// No description provided for @authCreateAccountHeading. + /// + /// In en, this message translates to: + /// **'Create Account'** + String get authCreateAccountHeading; + + /// No description provided for @authSignInHeading. + /// + /// In en, this message translates to: + /// **'Sign In'** + String get authSignInHeading; + + /// No description provided for @authCreateAccountDescription. + /// + /// In en, this message translates to: + /// **'Create an account to sync your collections across devices.'** + String get authCreateAccountDescription; + + /// No description provided for @authSignInDescription. + /// + /// In en, this message translates to: + /// **'Sign in to enable cloud sync and account features.'** + String get authSignInDescription; + + /// No description provided for @authSignInChoice. + /// + /// In en, this message translates to: + /// **'Sign in'** + String get authSignInChoice; + + /// No description provided for @authRegisterChoice. + /// + /// In en, this message translates to: + /// **'Register'** + String get authRegisterChoice; + + /// No description provided for @authEmailLabel. + /// + /// In en, this message translates to: + /// **'Email'** + String get authEmailLabel; + + /// No description provided for @authEmailHint. + /// + /// In en, this message translates to: + /// **'you@example.com'** + String get authEmailHint; + + /// No description provided for @authEmailRequiredError. + /// + /// In en, this message translates to: + /// **'Email is required.'** + String get authEmailRequiredError; + + /// No description provided for @authEmailInvalidError. + /// + /// In en, this message translates to: + /// **'Enter a valid email.'** + String get authEmailInvalidError; + + /// No description provided for @authPasswordLabel. + /// + /// In en, this message translates to: + /// **'Password'** + String get authPasswordLabel; + + /// No description provided for @authPasswordHint. + /// + /// In en, this message translates to: + /// **'Min 8 chars, A-Z, a-z, 0-9'** + String get authPasswordHint; + + /// No description provided for @authPasswordRequiredError. + /// + /// In en, this message translates to: + /// **'Password is required.'** + String get authPasswordRequiredError; + + /// No description provided for @authPasswordLengthError. + /// + /// In en, this message translates to: + /// **'Password must be at least 8 characters.'** + String get authPasswordLengthError; + + /// No description provided for @authPasswordPolicyError. + /// + /// In en, this message translates to: + /// **'Password must include uppercase, lowercase, and number.'** + String get authPasswordPolicyError; + + /// No description provided for @authDisplayNameLabel. + /// + /// In en, this message translates to: + /// **'Display Name (optional)'** + String get authDisplayNameLabel; + + /// No description provided for @authDisplayNameHint. + /// + /// In en, this message translates to: + /// **'How should we call you?'** + String get authDisplayNameHint; + + /// No description provided for @authCreateAccountAction. + /// + /// In en, this message translates to: + /// **'Create account'** + String get authCreateAccountAction; + + /// No description provided for @authNotNowAction. + /// + /// In en, this message translates to: + /// **'Not now'** + String get authNotNowAction; + + /// No description provided for @authUnavailableMessage. + /// + /// In en, this message translates to: + /// **'Authentication is currently unavailable.'** + String get authUnavailableMessage; + + /// No description provided for @authRegisterSuccess. + /// + /// In en, this message translates to: + /// **'Account created and signed in.'** + String get authRegisterSuccess; + + /// No description provided for @authSignInSuccess. + /// + /// In en, this message translates to: + /// **'Signed in successfully.'** + String get authSignInSuccess; + + /// No description provided for @authSignInFailed. + /// + /// In en, this message translates to: + /// **'Sign-in failed: {error}'** + String authSignInFailed(String error); + + /// No description provided for @authSignedOut. + /// + /// In en, this message translates to: + /// **'Signed out.'** + String get authSignedOut; + + /// No description provided for @authFinalConfirmationTitle. + /// + /// In en, this message translates to: + /// **'Final confirmation'** + String get authFinalConfirmationTitle; + + /// No description provided for @authFinalConfirmationMessage. + /// + /// In en, this message translates to: + /// **'Submit account deletion request now? You will be signed out immediately from this device.'** + String get authFinalConfirmationMessage; + + /// No description provided for @authBackAction. + /// + /// In en, this message translates to: + /// **'Back'** + String get authBackAction; + + /// No description provided for @authSubmitRequestAction. + /// + /// In en, this message translates to: + /// **'Submit Request'** + String get authSubmitRequestAction; + + /// No description provided for @authDeletionRequestSubmitted. + /// + /// In en, this message translates to: + /// **'Account deletion request submitted. You have been signed out.'** + String get authDeletionRequestSubmitted; + + /// No description provided for @authDeletionEndpointMissing. + /// + /// In en, this message translates to: + /// **'Deletion request endpoint is not configured on backend yet.'** + String get authDeletionEndpointMissing; + + /// No description provided for @authDeletionImpactDialogTitle. + /// + /// In en, this message translates to: + /// **'Before requesting account deletion'** + String get authDeletionImpactDialogTitle; + + /// No description provided for @authDeletionImpactReviewPrompt. + /// + /// In en, this message translates to: + /// **'Please review the impact carefully.'** + String get authDeletionImpactReviewPrompt; + + /// No description provided for @authIrreversibleRequestTitle. + /// + /// In en, this message translates to: + /// **'Irreversible request'** + String get authIrreversibleRequestTitle; + + /// No description provided for @authImpactLineSessionRevoked. + /// + /// In en, this message translates to: + /// **'Your account session is revoked immediately on request.'** + String get authImpactLineSessionRevoked; + + /// No description provided for @authImpactLineCloudDataDeleted. + /// + /// In en, this message translates to: + /// **'Synced cloud data linked to this account may be permanently deleted during processing.'** + String get authImpactLineCloudDataDeleted; + + /// No description provided for @authImpactLineCannotRestore. + /// + /// In en, this message translates to: + /// **'Deleted account data cannot be restored once processed.'** + String get authImpactLineCannotRestore; + + /// No description provided for @authUnderstandAction. + /// + /// In en, this message translates to: + /// **'I understand'** + String get authUnderstandAction; + + /// No description provided for @authPasswordPolicySuffix. + /// + /// In en, this message translates to: + /// **'Use English keyboard letters and digits (A-Z, a-z, 0-9).'** + String get authPasswordPolicySuffix; + + /// No description provided for @authAccountConnected. + /// + /// In en, this message translates to: + /// **'Account connected'** + String get authAccountConnected; + + /// No description provided for @authSignedInReadySubtitle. + /// + /// In en, this message translates to: + /// **'Signed in and ready for cloud sync'** + String get authSignedInReadySubtitle; + + /// No description provided for @authActiveStatus. + /// + /// In en, this message translates to: + /// **'Active'** + String get authActiveStatus; + + /// No description provided for @authSessionDetailsTitle. + /// + /// In en, this message translates to: + /// **'Session details'** + String get authSessionDetailsTitle; + + /// No description provided for @authUserIdLabel. + /// + /// In en, this message translates to: + /// **'User ID'** + String get authUserIdLabel; + + /// No description provided for @authDeviceIdLabel. + /// + /// In en, this message translates to: + /// **'Device ID'** + String get authDeviceIdLabel; + + /// No description provided for @authUnknownValue. + /// + /// In en, this message translates to: + /// **'Unknown'** + String get authUnknownValue; + + /// No description provided for @authDeletionNoticeTitle. + /// + /// In en, this message translates to: + /// **'Account deletion notice'** + String get authDeletionNoticeTitle; + + /// No description provided for @authDeletionNoticeSubtitle. + /// + /// In en, this message translates to: + /// **'Deletion requests are irreversible once processed.'** + String get authDeletionNoticeSubtitle; + + /// No description provided for @authDeletionNoticeLineProfileSessions. + /// + /// In en, this message translates to: + /// **'Account profile and active sessions will be removed from cloud access.'** + String get authDeletionNoticeLineProfileSessions; + + /// No description provided for @authDeletionNoticeLineSyncedData. + /// + /// In en, this message translates to: + /// **'Synced collections, items, tags, and loans may be permanently deleted.'** + String get authDeletionNoticeLineSyncedData; + + /// No description provided for @authRequestDeletionAction. + /// + /// In en, this message translates to: + /// **'Request account deletion'** + String get authRequestDeletionAction; + + /// No description provided for @authSignOutAction. + /// + /// In en, this message translates to: + /// **'Sign out'** + String get authSignOutAction; + + /// No description provided for @authDoneAction. + /// + /// In en, this message translates to: + /// **'Done'** + String get authDoneAction; + + /// No description provided for @authHeaderCreateTitle. + /// + /// In en, this message translates to: + /// **'Create your account'** + String get authHeaderCreateTitle; + + /// No description provided for @authHeaderWelcomeTitle. + /// + /// In en, this message translates to: + /// **'Welcome back'** + String get authHeaderWelcomeTitle; + + /// No description provided for @authHeaderCreateSubtitle. + /// + /// In en, this message translates to: + /// **'Accounts are optional, but required for cloud sync and multi-device access.'** + String get authHeaderCreateSubtitle; + + /// No description provided for @authHeaderSignInSubtitle. + /// + /// In en, this message translates to: + /// **'Sign in to access cloud sync and account-based features.'** + String get authHeaderSignInSubtitle; + + /// No description provided for @authUnavailableTitle. + /// + /// In en, this message translates to: + /// **'Authentication unavailable'** + String get authUnavailableTitle; } class _AppLocalizationsDelegate extends LocalizationsDelegate { diff --git a/apps/mobile/lib/l10n/gen/app_localizations_en.dart b/apps/mobile/lib/l10n/gen/app_localizations_en.dart index c8b4487..7622e72 100644 --- a/apps/mobile/lib/l10n/gen/app_localizations_en.dart +++ b/apps/mobile/lib/l10n/gen/app_localizations_en.dart @@ -12,7 +12,7 @@ class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); @override - String get appTitle => 'Collection Tracker'; + String get appTitle => 'Collectra'; @override String get navHome => 'Home'; @@ -113,6 +113,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settingsManageTagsSubtitle => 'Rename, merge, and delete tags'; + @override + String get settingsLoanTrackingTitle => 'Loan Tracking'; + + @override + String get settingsLoanTrackingSubtitle => 'Track borrowed items and return dates'; + @override String get settingsVersionTitle => 'Version'; @@ -174,7 +180,7 @@ class AppLocalizationsEn extends AppLocalizations { String get settingsAnalyticsConsentDeclined => 'Analytics consent declined.'; @override - String get analyticsConsentDialogTitle => 'Help Improve Collection Tracker'; + String get analyticsConsentDialogTitle => 'Help Improve Collectra'; @override String get analyticsConsentDialogMessage => 'Can we collect anonymous usage analytics to improve app quality and features? You can change this anytime in Settings.'; @@ -214,6 +220,57 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settingsFirebaseRuntimeConfigSubtitle => 'Inspect and refresh runtime feature flags'; + @override + String get settingsMetadataTitle => 'Metadata & Autofill'; + + @override + String get settingsMetadataSummaryEnabled => 'Enabled with automatic barcode lookup'; + + @override + String get settingsMetadataSummaryManual => 'Enabled with manual lookup'; + + @override + String get settingsMetadataSummaryDisabled => 'Disabled'; + + @override + String get settingsMetadataSummaryFeatureDisabled => 'Disabled by runtime feature flag'; + + @override + String get settingsMetadataEnableToggleTitle => 'Enable metadata assistance'; + + @override + String get settingsMetadataEnableToggleSubtitle => 'Allow metadata search and barcode-based autofill in item forms.'; + + @override + String get settingsMetadataAutoFetchToggleTitle => 'Auto-fetch from barcode scan'; + + @override + String get settingsMetadataAutoFetchToggleSubtitle => 'After scanning a barcode, fetch metadata automatically.'; + + @override + String get settingsMetadataFillEmptyOnlyToggleTitle => 'Fill empty fields only'; + + @override + String get settingsMetadataFillEmptyOnlyToggleSubtitle => 'Do not overwrite existing title or description when metadata is found.'; + + @override + String get settingsMetadataSourcesSectionTitle => 'Sources'; + + @override + String get settingsMetadataSourceAvailable => 'Available'; + + @override + String get settingsMetadataSourceNotConfigured => 'Not configured'; + + @override + String get settingsMetadataSourceManualOnly => 'Manual only'; + + @override + String get settingsMetadataManualCollectionsLabel => 'Comics, Music, and Custom'; + + @override + String get settingsMetadataFeatureDisabledMessage => 'Metadata assistance is disabled by runtime configuration.'; + @override String get settingsFirebaseRuntimeConfigSheetTitle => 'Firebase Runtime Config'; @@ -290,7 +347,7 @@ class AppLocalizationsEn extends AppLocalizations { String get settingsImportDataTitle => 'Import Data'; @override - String get settingsImportDataMessage => 'This will import collections and items from a JSON file. Existing data will not be deleted.\\n\\nContinue?'; + String get settingsImportDataMessage => 'This will import collections and items from a JSON file. Existing data will not be deleted.\n\nContinue?'; @override String get settingsImportingData => 'Importing data...'; @@ -745,6 +802,17 @@ class AppLocalizationsEn extends AppLocalizations { @override String get metadataSearchSuggestionMessage => 'Start typing to look up metadata.'; + @override + String get metadataSearchDisabledHint => 'Metadata search is unavailable for this collection type or currently disabled.'; + + @override + String get metadataNoMatchForBarcode => 'No metadata match found for this barcode.'; + + @override + String metadataSearchUnavailableForType(String collectionType) { + return 'Metadata search is unavailable for $collectionType.'; + } + @override String tagItemsTitle(String tag) { return 'Tag: $tag'; @@ -878,7 +946,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String tagManagementDeleteSelectedMessage(int count) { - return 'Delete $count selected tags from all items?\\n\\nThis cannot be undone.'; + return 'Delete $count selected tags from all items?\n\nThis cannot be undone.'; } @override @@ -899,7 +967,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String tagManagementDeleteMessage(String tagName) { - return 'Delete \"$tagName\" from all items?\\n\\nThis cannot be undone.'; + return 'Delete \"$tagName\" from all items?\n\nThis cannot be undone.'; } @override @@ -1064,4 +1132,350 @@ class AppLocalizationsEn extends AppLocalizations { @override String get collectionTypeCustom => 'Custom'; + + @override + String get loanTrackingTitle => 'Loan Tracking'; + + @override + String get loanTrackingNewLoan => 'New Loan'; + + @override + String get loanTrackingFilterActive => 'Active'; + + @override + String get loanTrackingFilterHistory => 'History'; + + @override + String get loanTrackingEmptyHistoryTitle => 'No returned loans yet'; + + @override + String get loanTrackingEmptyHistoryMessage => 'Returned items will appear here.'; + + @override + String get loanTrackingEmptyActiveTitle => 'No active loans'; + + @override + String get loanTrackingEmptyActiveMessage => 'Create a loan to start tracking borrowed items.'; + + @override + String get loanTrackingLoadingLoans => 'Loading loans...'; + + @override + String loanTrackingLoadFailed(String error) { + return 'Failed to load loans: $error'; + } + + @override + String get loanTrackingMarkReturnedConfirmTitle => 'Mark as returned?'; + + @override + String loanTrackingMarkReturnedConfirmMessage(String itemTitle) { + return 'Confirm return for \"$itemTitle\".'; + } + + @override + String get loanTrackingMarkReturnedAction => 'Mark Returned'; + + @override + String get loanTrackingMarkedReturnedSuccess => 'Loan marked as returned.'; + + @override + String loanTrackingMarkReturnedFailed(String error) { + return 'Failed to mark return: $error'; + } + + @override + String get loanTrackingDeleteConfirmTitle => 'Delete loan record?'; + + @override + String loanTrackingDeleteConfirmMessage(String itemTitle) { + return 'Delete loan record for \"$itemTitle\".'; + } + + @override + String get loanTrackingDeleteSuccess => 'Loan deleted.'; + + @override + String loanTrackingDeleteFailed(String error) { + return 'Failed to delete loan: $error'; + } + + @override + String get loanTrackingSummaryActiveLabel => 'Active Loans'; + + @override + String get loanTrackingSummaryOverdueLabel => 'Overdue'; + + @override + String get loanTrackingSummaryLoadFailed => 'Unable to load loan summary.'; + + @override + String get loanTrackingFieldBorrower => 'Borrower'; + + @override + String get loanTrackingFieldContact => 'Contact'; + + @override + String get loanTrackingFieldLoaned => 'Loaned'; + + @override + String get loanTrackingFieldDue => 'Due'; + + @override + String get loanTrackingFieldReturned => 'Returned'; + + @override + String get loanTrackingStatusReturned => 'Returned'; + + @override + String get loanTrackingStatusOverdue => 'Overdue'; + + @override + String get loanTrackingStatusActive => 'Active'; + + @override + String get loanTrackingCreateTitle => 'Create Loan'; + + @override + String get loanTrackingCreateDescription => 'Track who borrowed an item and when it should be returned.'; + + @override + String get loanTrackingCreateNoItemsTitle => 'No available items'; + + @override + String get loanTrackingCreateNoItemsMessage => 'All items are currently loaned or there are no items yet.'; + + @override + String get loanTrackingCreateItemLabel => 'Item'; + + @override + String get loanTrackingCreateBorrowerLabel => 'Borrower name'; + + @override + String get loanTrackingCreateBorrowerHint => 'e.g. John Doe'; + + @override + String get loanTrackingCreateContactLabel => 'Contact (optional)'; + + @override + String get loanTrackingCreateContactHint => 'Phone, email, or @username'; + + @override + String get loanTrackingCreateNotesLabel => 'Notes (optional)'; + + @override + String get loanTrackingCreateNotesHint => 'Extra details for this loan'; + + @override + String get loanTrackingCreateSubmitting => 'Creating...'; + + @override + String get loanTrackingCreateAction => 'Create Loan'; + + @override + String get loanTrackingLoadingItems => 'Loading items...'; + + @override + String loanTrackingLoadItemsFailed(String error) { + return 'Failed to load items: $error'; + } + + @override + String get loanTrackingBorrowerRequired => 'Borrower name is required.'; + + @override + String get loanTrackingCreateSuccess => 'Loan created successfully.'; + + @override + String loanTrackingCreateFailed(String error) { + return 'Failed to create loan: $error'; + } + + @override + String get loanTrackingNoDueDate => 'No due date'; + + @override + String get loanTrackingPickDateAction => 'Pick'; + + @override + String get loanTrackingClearDateAction => 'Clear'; + + @override + String get loanTrackingDueDateLabel => 'Due date'; + + @override + String get authTitleAccount => 'Account'; + + @override + String get authCreateAccountHeading => 'Create Account'; + + @override + String get authSignInHeading => 'Sign In'; + + @override + String get authCreateAccountDescription => 'Create an account to sync your collections across devices.'; + + @override + String get authSignInDescription => 'Sign in to enable cloud sync and account features.'; + + @override + String get authSignInChoice => 'Sign in'; + + @override + String get authRegisterChoice => 'Register'; + + @override + String get authEmailLabel => 'Email'; + + @override + String get authEmailHint => 'you@example.com'; + + @override + String get authEmailRequiredError => 'Email is required.'; + + @override + String get authEmailInvalidError => 'Enter a valid email.'; + + @override + String get authPasswordLabel => 'Password'; + + @override + String get authPasswordHint => 'Min 8 chars, A-Z, a-z, 0-9'; + + @override + String get authPasswordRequiredError => 'Password is required.'; + + @override + String get authPasswordLengthError => 'Password must be at least 8 characters.'; + + @override + String get authPasswordPolicyError => 'Password must include uppercase, lowercase, and number.'; + + @override + String get authDisplayNameLabel => 'Display Name (optional)'; + + @override + String get authDisplayNameHint => 'How should we call you?'; + + @override + String get authCreateAccountAction => 'Create account'; + + @override + String get authNotNowAction => 'Not now'; + + @override + String get authUnavailableMessage => 'Authentication is currently unavailable.'; + + @override + String get authRegisterSuccess => 'Account created and signed in.'; + + @override + String get authSignInSuccess => 'Signed in successfully.'; + + @override + String authSignInFailed(String error) { + return 'Sign-in failed: $error'; + } + + @override + String get authSignedOut => 'Signed out.'; + + @override + String get authFinalConfirmationTitle => 'Final confirmation'; + + @override + String get authFinalConfirmationMessage => 'Submit account deletion request now? You will be signed out immediately from this device.'; + + @override + String get authBackAction => 'Back'; + + @override + String get authSubmitRequestAction => 'Submit Request'; + + @override + String get authDeletionRequestSubmitted => 'Account deletion request submitted. You have been signed out.'; + + @override + String get authDeletionEndpointMissing => 'Deletion request endpoint is not configured on backend yet.'; + + @override + String get authDeletionImpactDialogTitle => 'Before requesting account deletion'; + + @override + String get authDeletionImpactReviewPrompt => 'Please review the impact carefully.'; + + @override + String get authIrreversibleRequestTitle => 'Irreversible request'; + + @override + String get authImpactLineSessionRevoked => 'Your account session is revoked immediately on request.'; + + @override + String get authImpactLineCloudDataDeleted => 'Synced cloud data linked to this account may be permanently deleted during processing.'; + + @override + String get authImpactLineCannotRestore => 'Deleted account data cannot be restored once processed.'; + + @override + String get authUnderstandAction => 'I understand'; + + @override + String get authPasswordPolicySuffix => 'Use English keyboard letters and digits (A-Z, a-z, 0-9).'; + + @override + String get authAccountConnected => 'Account connected'; + + @override + String get authSignedInReadySubtitle => 'Signed in and ready for cloud sync'; + + @override + String get authActiveStatus => 'Active'; + + @override + String get authSessionDetailsTitle => 'Session details'; + + @override + String get authUserIdLabel => 'User ID'; + + @override + String get authDeviceIdLabel => 'Device ID'; + + @override + String get authUnknownValue => 'Unknown'; + + @override + String get authDeletionNoticeTitle => 'Account deletion notice'; + + @override + String get authDeletionNoticeSubtitle => 'Deletion requests are irreversible once processed.'; + + @override + String get authDeletionNoticeLineProfileSessions => 'Account profile and active sessions will be removed from cloud access.'; + + @override + String get authDeletionNoticeLineSyncedData => 'Synced collections, items, tags, and loans may be permanently deleted.'; + + @override + String get authRequestDeletionAction => 'Request account deletion'; + + @override + String get authSignOutAction => 'Sign out'; + + @override + String get authDoneAction => 'Done'; + + @override + String get authHeaderCreateTitle => 'Create your account'; + + @override + String get authHeaderWelcomeTitle => 'Welcome back'; + + @override + String get authHeaderCreateSubtitle => 'Accounts are optional, but required for cloud sync and multi-device access.'; + + @override + String get authHeaderSignInSubtitle => 'Sign in to access cloud sync and account-based features.'; + + @override + String get authUnavailableTitle => 'Authentication unavailable'; } diff --git a/apps/mobile/lib/l10n/gen/app_localizations_es.dart b/apps/mobile/lib/l10n/gen/app_localizations_es.dart index 7fb797f..af6b468 100644 --- a/apps/mobile/lib/l10n/gen/app_localizations_es.dart +++ b/apps/mobile/lib/l10n/gen/app_localizations_es.dart @@ -12,7 +12,7 @@ class AppLocalizationsEs extends AppLocalizations { AppLocalizationsEs([String locale = 'es']) : super(locale); @override - String get appTitle => 'Collection Tracker'; + String get appTitle => 'Collectra'; @override String get navHome => 'Inicio'; @@ -113,6 +113,12 @@ class AppLocalizationsEs extends AppLocalizations { @override String get settingsManageTagsSubtitle => 'Renombrar, combinar y eliminar etiquetas'; + @override + String get settingsLoanTrackingTitle => 'Seguimiento de préstamos'; + + @override + String get settingsLoanTrackingSubtitle => 'Controla artículos prestados y fechas de devolución'; + @override String get settingsVersionTitle => 'Versión'; @@ -174,7 +180,7 @@ class AppLocalizationsEs extends AppLocalizations { String get settingsAnalyticsConsentDeclined => 'Consentimiento de analíticas rechazado.'; @override - String get analyticsConsentDialogTitle => 'Ayúdanos a mejorar Collection Tracker'; + String get analyticsConsentDialogTitle => 'Ayúdanos a mejorar Collectra'; @override String get analyticsConsentDialogMessage => '¿Podemos recopilar analíticas de uso anónimas para mejorar la calidad y las funciones de la app? Puedes cambiarlo en Configuración en cualquier momento.'; @@ -214,6 +220,57 @@ class AppLocalizationsEs extends AppLocalizations { @override String get settingsFirebaseRuntimeConfigSubtitle => 'Inspecciona y actualiza las banderas de ejecución'; + @override + String get settingsMetadataTitle => 'Metadatos y Autorrelleno'; + + @override + String get settingsMetadataSummaryEnabled => 'Activado con búsqueda automática por código de barras'; + + @override + String get settingsMetadataSummaryManual => 'Activado con búsqueda manual'; + + @override + String get settingsMetadataSummaryDisabled => 'Desactivado'; + + @override + String get settingsMetadataSummaryFeatureDisabled => 'Desactivado por bandera de ejecución'; + + @override + String get settingsMetadataEnableToggleTitle => 'Activar asistencia de metadatos'; + + @override + String get settingsMetadataEnableToggleSubtitle => 'Permite la búsqueda de metadatos y el autorrelleno por código de barras en formularios de artículos.'; + + @override + String get settingsMetadataAutoFetchToggleTitle => 'Buscar automáticamente al escanear código'; + + @override + String get settingsMetadataAutoFetchToggleSubtitle => 'Después de escanear un código de barras, obtiene metadatos automáticamente.'; + + @override + String get settingsMetadataFillEmptyOnlyToggleTitle => 'Rellenar solo campos vacíos'; + + @override + String get settingsMetadataFillEmptyOnlyToggleSubtitle => 'No sobrescribe el título ni la descripción existentes cuando se encuentran metadatos.'; + + @override + String get settingsMetadataSourcesSectionTitle => 'Fuentes'; + + @override + String get settingsMetadataSourceAvailable => 'Disponible'; + + @override + String get settingsMetadataSourceNotConfigured => 'Sin configurar'; + + @override + String get settingsMetadataSourceManualOnly => 'Solo manual'; + + @override + String get settingsMetadataManualCollectionsLabel => 'Cómics, Música y Personalizado'; + + @override + String get settingsMetadataFeatureDisabledMessage => 'La asistencia de metadatos está desactivada por configuración de ejecución.'; + @override String get settingsFirebaseRuntimeConfigSheetTitle => 'Configuración de ejecución de Firebase'; @@ -290,7 +347,7 @@ class AppLocalizationsEs extends AppLocalizations { String get settingsImportDataTitle => 'Importar datos'; @override - String get settingsImportDataMessage => 'Esto importará colecciones y artículos desde un archivo JSON. Los datos existentes no se eliminarán.\\n\\n¿Continuar?'; + String get settingsImportDataMessage => 'Esto importará colecciones y artículos desde un archivo JSON. Los datos existentes no se eliminarán.\n\n¿Continuar?'; @override String get settingsImportingData => 'Importando datos...'; @@ -745,6 +802,17 @@ class AppLocalizationsEs extends AppLocalizations { @override String get metadataSearchSuggestionMessage => 'Empieza a escribir para buscar metadatos.'; + @override + String get metadataSearchDisabledHint => 'La búsqueda de metadatos no está disponible para este tipo de colección o está desactivada.'; + + @override + String get metadataNoMatchForBarcode => 'No se encontraron metadatos para este código de barras.'; + + @override + String metadataSearchUnavailableForType(String collectionType) { + return 'La búsqueda de metadatos no está disponible para $collectionType.'; + } + @override String tagItemsTitle(String tag) { return 'Etiqueta: $tag'; @@ -878,7 +946,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String tagManagementDeleteSelectedMessage(int count) { - return '¿Eliminar $count etiquetas seleccionadas de todos los artículos?\\n\\nEsto no se puede deshacer.'; + return '¿Eliminar $count etiquetas seleccionadas de todos los artículos?\n\nEsto no se puede deshacer.'; } @override @@ -899,7 +967,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String tagManagementDeleteMessage(String tagName) { - return '¿Eliminar \"$tagName\" de todos los artículos?\\n\\nEsto no se puede deshacer.'; + return '¿Eliminar \"$tagName\" de todos los artículos?\n\nEsto no se puede deshacer.'; } @override @@ -1064,4 +1132,350 @@ class AppLocalizationsEs extends AppLocalizations { @override String get collectionTypeCustom => 'Personalizada'; + + @override + String get loanTrackingTitle => 'Seguimiento de préstamos'; + + @override + String get loanTrackingNewLoan => 'Nuevo préstamo'; + + @override + String get loanTrackingFilterActive => 'Activos'; + + @override + String get loanTrackingFilterHistory => 'Historial'; + + @override + String get loanTrackingEmptyHistoryTitle => 'Aún no hay préstamos devueltos'; + + @override + String get loanTrackingEmptyHistoryMessage => 'Los artículos devueltos aparecerán aquí.'; + + @override + String get loanTrackingEmptyActiveTitle => 'No hay préstamos activos'; + + @override + String get loanTrackingEmptyActiveMessage => 'Crea un préstamo para empezar a seguir artículos prestados.'; + + @override + String get loanTrackingLoadingLoans => 'Cargando préstamos...'; + + @override + String loanTrackingLoadFailed(String error) { + return 'No se pudieron cargar los préstamos: $error'; + } + + @override + String get loanTrackingMarkReturnedConfirmTitle => '¿Marcar como devuelto?'; + + @override + String loanTrackingMarkReturnedConfirmMessage(String itemTitle) { + return 'Confirmar devolución de \"$itemTitle\".'; + } + + @override + String get loanTrackingMarkReturnedAction => 'Marcar devuelto'; + + @override + String get loanTrackingMarkedReturnedSuccess => 'El préstamo se marcó como devuelto.'; + + @override + String loanTrackingMarkReturnedFailed(String error) { + return 'No se pudo marcar la devolución: $error'; + } + + @override + String get loanTrackingDeleteConfirmTitle => '¿Eliminar registro del préstamo?'; + + @override + String loanTrackingDeleteConfirmMessage(String itemTitle) { + return 'Eliminar registro del préstamo de \"$itemTitle\".'; + } + + @override + String get loanTrackingDeleteSuccess => 'Préstamo eliminado.'; + + @override + String loanTrackingDeleteFailed(String error) { + return 'No se pudo eliminar el préstamo: $error'; + } + + @override + String get loanTrackingSummaryActiveLabel => 'Préstamos activos'; + + @override + String get loanTrackingSummaryOverdueLabel => 'Vencidos'; + + @override + String get loanTrackingSummaryLoadFailed => 'No se pudo cargar el resumen de préstamos.'; + + @override + String get loanTrackingFieldBorrower => 'Prestatario'; + + @override + String get loanTrackingFieldContact => 'Contacto'; + + @override + String get loanTrackingFieldLoaned => 'Prestado'; + + @override + String get loanTrackingFieldDue => 'Vence'; + + @override + String get loanTrackingFieldReturned => 'Devuelto'; + + @override + String get loanTrackingStatusReturned => 'Devuelto'; + + @override + String get loanTrackingStatusOverdue => 'Vencido'; + + @override + String get loanTrackingStatusActive => 'Activo'; + + @override + String get loanTrackingCreateTitle => 'Crear préstamo'; + + @override + String get loanTrackingCreateDescription => 'Registra quién tomó un artículo prestado y cuándo debe devolverlo.'; + + @override + String get loanTrackingCreateNoItemsTitle => 'No hay artículos disponibles'; + + @override + String get loanTrackingCreateNoItemsMessage => 'Todos los artículos están prestados actualmente o aún no hay artículos.'; + + @override + String get loanTrackingCreateItemLabel => 'Artículo'; + + @override + String get loanTrackingCreateBorrowerLabel => 'Nombre del prestatario'; + + @override + String get loanTrackingCreateBorrowerHint => 'p. ej. Juan Pérez'; + + @override + String get loanTrackingCreateContactLabel => 'Contacto (opcional)'; + + @override + String get loanTrackingCreateContactHint => 'Teléfono, correo o @usuario'; + + @override + String get loanTrackingCreateNotesLabel => 'Notas (opcional)'; + + @override + String get loanTrackingCreateNotesHint => 'Detalles adicionales para este préstamo'; + + @override + String get loanTrackingCreateSubmitting => 'Creando...'; + + @override + String get loanTrackingCreateAction => 'Crear préstamo'; + + @override + String get loanTrackingLoadingItems => 'Cargando artículos...'; + + @override + String loanTrackingLoadItemsFailed(String error) { + return 'No se pudieron cargar los artículos: $error'; + } + + @override + String get loanTrackingBorrowerRequired => 'El nombre del prestatario es obligatorio.'; + + @override + String get loanTrackingCreateSuccess => 'Préstamo creado correctamente.'; + + @override + String loanTrackingCreateFailed(String error) { + return 'No se pudo crear el préstamo: $error'; + } + + @override + String get loanTrackingNoDueDate => 'Sin fecha de vencimiento'; + + @override + String get loanTrackingPickDateAction => 'Elegir'; + + @override + String get loanTrackingClearDateAction => 'Limpiar'; + + @override + String get loanTrackingDueDateLabel => 'Fecha de vencimiento'; + + @override + String get authTitleAccount => 'Cuenta'; + + @override + String get authCreateAccountHeading => 'Crear cuenta'; + + @override + String get authSignInHeading => 'Iniciar sesión'; + + @override + String get authCreateAccountDescription => 'Crea una cuenta para sincronizar tus colecciones entre dispositivos.'; + + @override + String get authSignInDescription => 'Inicia sesión para habilitar la sincronización en la nube y funciones de cuenta.'; + + @override + String get authSignInChoice => 'Iniciar sesión'; + + @override + String get authRegisterChoice => 'Registrarse'; + + @override + String get authEmailLabel => 'Correo electrónico'; + + @override + String get authEmailHint => 'tu@ejemplo.com'; + + @override + String get authEmailRequiredError => 'El correo electrónico es obligatorio.'; + + @override + String get authEmailInvalidError => 'Introduce un correo electrónico válido.'; + + @override + String get authPasswordLabel => 'Contraseña'; + + @override + String get authPasswordHint => 'Mín. 8 caracteres, A-Z, a-z, 0-9'; + + @override + String get authPasswordRequiredError => 'La contraseña es obligatoria.'; + + @override + String get authPasswordLengthError => 'La contraseña debe tener al menos 8 caracteres.'; + + @override + String get authPasswordPolicyError => 'La contraseña debe incluir mayúsculas, minúsculas y números.'; + + @override + String get authDisplayNameLabel => 'Nombre para mostrar (opcional)'; + + @override + String get authDisplayNameHint => '¿Cómo debemos llamarte?'; + + @override + String get authCreateAccountAction => 'Crear cuenta'; + + @override + String get authNotNowAction => 'Ahora no'; + + @override + String get authUnavailableMessage => 'La autenticación no está disponible en este momento.'; + + @override + String get authRegisterSuccess => 'Cuenta creada e inicio de sesión completado.'; + + @override + String get authSignInSuccess => 'Inicio de sesión exitoso.'; + + @override + String authSignInFailed(String error) { + return 'Error al iniciar sesión: $error'; + } + + @override + String get authSignedOut => 'Sesión cerrada.'; + + @override + String get authFinalConfirmationTitle => 'Confirmación final'; + + @override + String get authFinalConfirmationMessage => '¿Enviar solicitud de eliminación de cuenta ahora? Se cerrará la sesión de inmediato en este dispositivo.'; + + @override + String get authBackAction => 'Atrás'; + + @override + String get authSubmitRequestAction => 'Enviar solicitud'; + + @override + String get authDeletionRequestSubmitted => 'Solicitud de eliminación enviada. Se cerró tu sesión.'; + + @override + String get authDeletionEndpointMissing => 'El endpoint de solicitud de eliminación aún no está configurado en el backend.'; + + @override + String get authDeletionImpactDialogTitle => 'Antes de solicitar la eliminación de la cuenta'; + + @override + String get authDeletionImpactReviewPrompt => 'Revisa cuidadosamente el impacto.'; + + @override + String get authIrreversibleRequestTitle => 'Solicitud irreversible'; + + @override + String get authImpactLineSessionRevoked => 'La sesión de tu cuenta se revoca inmediatamente al solicitarla.'; + + @override + String get authImpactLineCloudDataDeleted => 'Los datos sincronizados en la nube vinculados a esta cuenta pueden eliminarse permanentemente durante el proceso.'; + + @override + String get authImpactLineCannotRestore => 'Los datos eliminados de la cuenta no se pueden restaurar una vez procesados.'; + + @override + String get authUnderstandAction => 'Entiendo'; + + @override + String get authPasswordPolicySuffix => 'Usa letras y dígitos del teclado en inglés (A-Z, a-z, 0-9).'; + + @override + String get authAccountConnected => 'Cuenta conectada'; + + @override + String get authSignedInReadySubtitle => 'Sesión iniciada y lista para sincronización en la nube'; + + @override + String get authActiveStatus => 'Activa'; + + @override + String get authSessionDetailsTitle => 'Detalles de la sesión'; + + @override + String get authUserIdLabel => 'ID de usuario'; + + @override + String get authDeviceIdLabel => 'ID del dispositivo'; + + @override + String get authUnknownValue => 'Desconocido'; + + @override + String get authDeletionNoticeTitle => 'Aviso de eliminación de cuenta'; + + @override + String get authDeletionNoticeSubtitle => 'Las solicitudes de eliminación son irreversibles una vez procesadas.'; + + @override + String get authDeletionNoticeLineProfileSessions => 'El perfil de la cuenta y las sesiones activas se eliminarán del acceso en la nube.'; + + @override + String get authDeletionNoticeLineSyncedData => 'Las colecciones, artículos, etiquetas y préstamos sincronizados pueden eliminarse permanentemente.'; + + @override + String get authRequestDeletionAction => 'Solicitar eliminación de cuenta'; + + @override + String get authSignOutAction => 'Cerrar sesión'; + + @override + String get authDoneAction => 'Listo'; + + @override + String get authHeaderCreateTitle => 'Crea tu cuenta'; + + @override + String get authHeaderWelcomeTitle => 'Bienvenido de nuevo'; + + @override + String get authHeaderCreateSubtitle => 'Las cuentas son opcionales, pero necesarias para sincronización en la nube y acceso multidispositivo.'; + + @override + String get authHeaderSignInSubtitle => 'Inicia sesión para acceder a sincronización en la nube y funciones de cuenta.'; + + @override + String get authUnavailableTitle => 'Autenticación no disponible'; } diff --git a/apps/mobile/lib/l10n/gen/app_localizations_id.dart b/apps/mobile/lib/l10n/gen/app_localizations_id.dart index fc22d03..e9d97fb 100644 --- a/apps/mobile/lib/l10n/gen/app_localizations_id.dart +++ b/apps/mobile/lib/l10n/gen/app_localizations_id.dart @@ -12,7 +12,7 @@ class AppLocalizationsId extends AppLocalizations { AppLocalizationsId([String locale = 'id']) : super(locale); @override - String get appTitle => 'Collection Tracker'; + String get appTitle => 'Collectra'; @override String get navHome => 'Beranda'; @@ -113,6 +113,12 @@ class AppLocalizationsId extends AppLocalizations { @override String get settingsManageTagsSubtitle => 'Ubah nama, gabungkan, dan hapus tag'; + @override + String get settingsLoanTrackingTitle => 'Pelacakan Pinjaman'; + + @override + String get settingsLoanTrackingSubtitle => 'Lacak item yang dipinjam dan tanggal pengembalian'; + @override String get settingsVersionTitle => 'Versi'; @@ -174,7 +180,7 @@ class AppLocalizationsId extends AppLocalizations { String get settingsAnalyticsConsentDeclined => 'Persetujuan analitik ditolak.'; @override - String get analyticsConsentDialogTitle => 'Bantu Tingkatkan Collection Tracker'; + String get analyticsConsentDialogTitle => 'Bantu Tingkatkan Collectra'; @override String get analyticsConsentDialogMessage => 'Bolehkah kami mengumpulkan analitik penggunaan anonim untuk meningkatkan kualitas dan fitur aplikasi? Anda dapat mengubahnya kapan saja di Pengaturan.'; @@ -214,6 +220,57 @@ class AppLocalizationsId extends AppLocalizations { @override String get settingsFirebaseRuntimeConfigSubtitle => 'Periksa dan segarkan flag fitur runtime'; + @override + String get settingsMetadataTitle => 'Metadata & Isi Otomatis'; + + @override + String get settingsMetadataSummaryEnabled => 'Aktif dengan pencarian barcode otomatis'; + + @override + String get settingsMetadataSummaryManual => 'Aktif dengan pencarian manual'; + + @override + String get settingsMetadataSummaryDisabled => 'Nonaktif'; + + @override + String get settingsMetadataSummaryFeatureDisabled => 'Dinonaktifkan oleh flag fitur runtime'; + + @override + String get settingsMetadataEnableToggleTitle => 'Aktifkan bantuan metadata'; + + @override + String get settingsMetadataEnableToggleSubtitle => 'Izinkan pencarian metadata dan isi otomatis berbasis barcode pada formulir item.'; + + @override + String get settingsMetadataAutoFetchToggleTitle => 'Ambil otomatis saat barcode dipindai'; + + @override + String get settingsMetadataAutoFetchToggleSubtitle => 'Setelah barcode dipindai, metadata diambil secara otomatis.'; + + @override + String get settingsMetadataFillEmptyOnlyToggleTitle => 'Isi hanya kolom kosong'; + + @override + String get settingsMetadataFillEmptyOnlyToggleSubtitle => 'Jangan menimpa judul atau deskripsi yang sudah ada saat metadata ditemukan.'; + + @override + String get settingsMetadataSourcesSectionTitle => 'Sumber'; + + @override + String get settingsMetadataSourceAvailable => 'Tersedia'; + + @override + String get settingsMetadataSourceNotConfigured => 'Belum dikonfigurasi'; + + @override + String get settingsMetadataSourceManualOnly => 'Manual saja'; + + @override + String get settingsMetadataManualCollectionsLabel => 'Komik, Musik, dan Kustom'; + + @override + String get settingsMetadataFeatureDisabledMessage => 'Bantuan metadata dinonaktifkan oleh konfigurasi runtime.'; + @override String get settingsFirebaseRuntimeConfigSheetTitle => 'Konfigurasi Runtime Firebase'; @@ -290,7 +347,7 @@ class AppLocalizationsId extends AppLocalizations { String get settingsImportDataTitle => 'Impor Data'; @override - String get settingsImportDataMessage => 'Ini akan mengimpor koleksi dan item dari file JSON. Data yang sudah ada tidak akan dihapus.\\n\\nLanjutkan?'; + String get settingsImportDataMessage => 'Ini akan mengimpor koleksi dan item dari file JSON. Data yang sudah ada tidak akan dihapus.\n\nLanjutkan?'; @override String get settingsImportingData => 'Mengimpor data...'; @@ -745,6 +802,17 @@ class AppLocalizationsId extends AppLocalizations { @override String get metadataSearchSuggestionMessage => 'Mulai mengetik untuk mencari metadata.'; + @override + String get metadataSearchDisabledHint => 'Pencarian metadata tidak tersedia untuk tipe koleksi ini atau sedang dinonaktifkan.'; + + @override + String get metadataNoMatchForBarcode => 'Tidak ditemukan metadata yang cocok untuk barcode ini.'; + + @override + String metadataSearchUnavailableForType(String collectionType) { + return 'Pencarian metadata tidak tersedia untuk $collectionType.'; + } + @override String tagItemsTitle(String tag) { return 'Tag: $tag'; @@ -878,7 +946,7 @@ class AppLocalizationsId extends AppLocalizations { @override String tagManagementDeleteSelectedMessage(int count) { - return 'Hapus $count tag terpilih dari semua item?\\n\\nTindakan ini tidak dapat dibatalkan.'; + return 'Hapus $count tag terpilih dari semua item?\n\nTindakan ini tidak dapat dibatalkan.'; } @override @@ -899,7 +967,7 @@ class AppLocalizationsId extends AppLocalizations { @override String tagManagementDeleteMessage(String tagName) { - return 'Hapus \"$tagName\" dari semua item?\\n\\nTindakan ini tidak dapat dibatalkan.'; + return 'Hapus \"$tagName\" dari semua item?\n\nTindakan ini tidak dapat dibatalkan.'; } @override @@ -1064,4 +1132,350 @@ class AppLocalizationsId extends AppLocalizations { @override String get collectionTypeCustom => 'Kustom'; + + @override + String get loanTrackingTitle => 'Pelacakan Pinjaman'; + + @override + String get loanTrackingNewLoan => 'Pinjaman Baru'; + + @override + String get loanTrackingFilterActive => 'Aktif'; + + @override + String get loanTrackingFilterHistory => 'Riwayat'; + + @override + String get loanTrackingEmptyHistoryTitle => 'Belum ada pinjaman yang dikembalikan'; + + @override + String get loanTrackingEmptyHistoryMessage => 'Item yang sudah dikembalikan akan muncul di sini.'; + + @override + String get loanTrackingEmptyActiveTitle => 'Tidak ada pinjaman aktif'; + + @override + String get loanTrackingEmptyActiveMessage => 'Buat pinjaman untuk mulai melacak item yang dipinjam.'; + + @override + String get loanTrackingLoadingLoans => 'Memuat pinjaman...'; + + @override + String loanTrackingLoadFailed(String error) { + return 'Gagal memuat pinjaman: $error'; + } + + @override + String get loanTrackingMarkReturnedConfirmTitle => 'Tandai sudah dikembalikan?'; + + @override + String loanTrackingMarkReturnedConfirmMessage(String itemTitle) { + return 'Konfirmasi pengembalian untuk \"$itemTitle\".'; + } + + @override + String get loanTrackingMarkReturnedAction => 'Tandai Dikembalikan'; + + @override + String get loanTrackingMarkedReturnedSuccess => 'Pinjaman ditandai sudah dikembalikan.'; + + @override + String loanTrackingMarkReturnedFailed(String error) { + return 'Gagal menandai pengembalian: $error'; + } + + @override + String get loanTrackingDeleteConfirmTitle => 'Hapus catatan pinjaman?'; + + @override + String loanTrackingDeleteConfirmMessage(String itemTitle) { + return 'Hapus catatan pinjaman untuk \"$itemTitle\".'; + } + + @override + String get loanTrackingDeleteSuccess => 'Pinjaman dihapus.'; + + @override + String loanTrackingDeleteFailed(String error) { + return 'Gagal menghapus pinjaman: $error'; + } + + @override + String get loanTrackingSummaryActiveLabel => 'Pinjaman Aktif'; + + @override + String get loanTrackingSummaryOverdueLabel => 'Terlambat'; + + @override + String get loanTrackingSummaryLoadFailed => 'Tidak dapat memuat ringkasan pinjaman.'; + + @override + String get loanTrackingFieldBorrower => 'Peminjam'; + + @override + String get loanTrackingFieldContact => 'Kontak'; + + @override + String get loanTrackingFieldLoaned => 'Dipinjamkan'; + + @override + String get loanTrackingFieldDue => 'Jatuh tempo'; + + @override + String get loanTrackingFieldReturned => 'Dikembalikan'; + + @override + String get loanTrackingStatusReturned => 'Dikembalikan'; + + @override + String get loanTrackingStatusOverdue => 'Terlambat'; + + @override + String get loanTrackingStatusActive => 'Aktif'; + + @override + String get loanTrackingCreateTitle => 'Buat Pinjaman'; + + @override + String get loanTrackingCreateDescription => 'Lacak siapa yang meminjam item dan kapan harus dikembalikan.'; + + @override + String get loanTrackingCreateNoItemsTitle => 'Tidak ada item yang tersedia'; + + @override + String get loanTrackingCreateNoItemsMessage => 'Semua item sedang dipinjam atau belum ada item.'; + + @override + String get loanTrackingCreateItemLabel => 'Item'; + + @override + String get loanTrackingCreateBorrowerLabel => 'Nama peminjam'; + + @override + String get loanTrackingCreateBorrowerHint => 'mis. Budi Santoso'; + + @override + String get loanTrackingCreateContactLabel => 'Kontak (opsional)'; + + @override + String get loanTrackingCreateContactHint => 'Telepon, email, atau @username'; + + @override + String get loanTrackingCreateNotesLabel => 'Catatan (opsional)'; + + @override + String get loanTrackingCreateNotesHint => 'Detail tambahan untuk pinjaman ini'; + + @override + String get loanTrackingCreateSubmitting => 'Membuat...'; + + @override + String get loanTrackingCreateAction => 'Buat Pinjaman'; + + @override + String get loanTrackingLoadingItems => 'Memuat item...'; + + @override + String loanTrackingLoadItemsFailed(String error) { + return 'Gagal memuat item: $error'; + } + + @override + String get loanTrackingBorrowerRequired => 'Nama peminjam wajib diisi.'; + + @override + String get loanTrackingCreateSuccess => 'Pinjaman berhasil dibuat.'; + + @override + String loanTrackingCreateFailed(String error) { + return 'Gagal membuat pinjaman: $error'; + } + + @override + String get loanTrackingNoDueDate => 'Tanpa tanggal jatuh tempo'; + + @override + String get loanTrackingPickDateAction => 'Pilih'; + + @override + String get loanTrackingClearDateAction => 'Hapus'; + + @override + String get loanTrackingDueDateLabel => 'Tanggal jatuh tempo'; + + @override + String get authTitleAccount => 'Akun'; + + @override + String get authCreateAccountHeading => 'Buat Akun'; + + @override + String get authSignInHeading => 'Masuk'; + + @override + String get authCreateAccountDescription => 'Buat akun untuk menyinkronkan koleksi Anda di berbagai perangkat.'; + + @override + String get authSignInDescription => 'Masuk untuk mengaktifkan sinkronisasi cloud dan fitur akun.'; + + @override + String get authSignInChoice => 'Masuk'; + + @override + String get authRegisterChoice => 'Daftar'; + + @override + String get authEmailLabel => 'Email'; + + @override + String get authEmailHint => 'anda@contoh.com'; + + @override + String get authEmailRequiredError => 'Email wajib diisi.'; + + @override + String get authEmailInvalidError => 'Masukkan email yang valid.'; + + @override + String get authPasswordLabel => 'Kata sandi'; + + @override + String get authPasswordHint => 'Min 8 karakter, A-Z, a-z, 0-9'; + + @override + String get authPasswordRequiredError => 'Kata sandi wajib diisi.'; + + @override + String get authPasswordLengthError => 'Kata sandi minimal 8 karakter.'; + + @override + String get authPasswordPolicyError => 'Kata sandi harus mengandung huruf besar, huruf kecil, dan angka.'; + + @override + String get authDisplayNameLabel => 'Nama tampilan (opsional)'; + + @override + String get authDisplayNameHint => 'Kami memanggil Anda dengan nama apa?'; + + @override + String get authCreateAccountAction => 'Buat akun'; + + @override + String get authNotNowAction => 'Nanti saja'; + + @override + String get authUnavailableMessage => 'Autentikasi sedang tidak tersedia.'; + + @override + String get authRegisterSuccess => 'Akun berhasil dibuat dan Anda sudah masuk.'; + + @override + String get authSignInSuccess => 'Berhasil masuk.'; + + @override + String authSignInFailed(String error) { + return 'Gagal masuk: $error'; + } + + @override + String get authSignedOut => 'Berhasil keluar.'; + + @override + String get authFinalConfirmationTitle => 'Konfirmasi akhir'; + + @override + String get authFinalConfirmationMessage => 'Kirim permintaan penghapusan akun sekarang? Anda akan langsung keluar dari perangkat ini.'; + + @override + String get authBackAction => 'Kembali'; + + @override + String get authSubmitRequestAction => 'Kirim Permintaan'; + + @override + String get authDeletionRequestSubmitted => 'Permintaan penghapusan akun dikirim. Anda telah keluar.'; + + @override + String get authDeletionEndpointMissing => 'Endpoint permintaan penghapusan belum dikonfigurasi di backend.'; + + @override + String get authDeletionImpactDialogTitle => 'Sebelum meminta penghapusan akun'; + + @override + String get authDeletionImpactReviewPrompt => 'Tinjau dampaknya dengan saksama.'; + + @override + String get authIrreversibleRequestTitle => 'Permintaan tidak dapat dibatalkan'; + + @override + String get authImpactLineSessionRevoked => 'Sesi akun Anda dicabut segera setelah permintaan dikirim.'; + + @override + String get authImpactLineCloudDataDeleted => 'Data cloud tersinkron yang terkait akun ini dapat dihapus permanen saat diproses.'; + + @override + String get authImpactLineCannotRestore => 'Data akun yang sudah dihapus tidak dapat dipulihkan setelah diproses.'; + + @override + String get authUnderstandAction => 'Saya mengerti'; + + @override + String get authPasswordPolicySuffix => 'Gunakan huruf dan angka keyboard Inggris (A-Z, a-z, 0-9).'; + + @override + String get authAccountConnected => 'Akun terhubung'; + + @override + String get authSignedInReadySubtitle => 'Sudah masuk dan siap untuk sinkronisasi cloud'; + + @override + String get authActiveStatus => 'Aktif'; + + @override + String get authSessionDetailsTitle => 'Detail sesi'; + + @override + String get authUserIdLabel => 'ID Pengguna'; + + @override + String get authDeviceIdLabel => 'ID Perangkat'; + + @override + String get authUnknownValue => 'Tidak diketahui'; + + @override + String get authDeletionNoticeTitle => 'Pemberitahuan penghapusan akun'; + + @override + String get authDeletionNoticeSubtitle => 'Permintaan penghapusan bersifat permanen setelah diproses.'; + + @override + String get authDeletionNoticeLineProfileSessions => 'Profil akun dan sesi aktif akan dihapus dari akses cloud.'; + + @override + String get authDeletionNoticeLineSyncedData => 'Koleksi, item, tag, dan pinjaman tersinkron dapat dihapus permanen.'; + + @override + String get authRequestDeletionAction => 'Minta penghapusan akun'; + + @override + String get authSignOutAction => 'Keluar'; + + @override + String get authDoneAction => 'Selesai'; + + @override + String get authHeaderCreateTitle => 'Buat akun Anda'; + + @override + String get authHeaderWelcomeTitle => 'Selamat datang kembali'; + + @override + String get authHeaderCreateSubtitle => 'Akun bersifat opsional, tetapi diperlukan untuk sinkronisasi cloud dan akses multi-perangkat.'; + + @override + String get authHeaderSignInSubtitle => 'Masuk untuk menggunakan sinkronisasi cloud dan fitur berbasis akun.'; + + @override + String get authUnavailableTitle => 'Autentikasi tidak tersedia'; } diff --git a/apps/mobile/lib/l10n/gen/app_localizations_ja.dart b/apps/mobile/lib/l10n/gen/app_localizations_ja.dart index e5d70dc..89175ae 100644 --- a/apps/mobile/lib/l10n/gen/app_localizations_ja.dart +++ b/apps/mobile/lib/l10n/gen/app_localizations_ja.dart @@ -12,7 +12,7 @@ class AppLocalizationsJa extends AppLocalizations { AppLocalizationsJa([String locale = 'ja']) : super(locale); @override - String get appTitle => 'Collection Tracker'; + String get appTitle => 'Collectra'; @override String get navHome => 'ホーム'; @@ -113,6 +113,12 @@ class AppLocalizationsJa extends AppLocalizations { @override String get settingsManageTagsSubtitle => 'タグの名前変更・統合・削除'; + @override + String get settingsLoanTrackingTitle => '貸出管理'; + + @override + String get settingsLoanTrackingSubtitle => '貸し出したアイテムと返却予定日を追跡'; + @override String get settingsVersionTitle => 'バージョン'; @@ -174,7 +180,7 @@ class AppLocalizationsJa extends AppLocalizations { String get settingsAnalyticsConsentDeclined => 'アナリティクスへの同意を拒否しました。'; @override - String get analyticsConsentDialogTitle => 'Collection Tracker の改善にご協力ください'; + String get analyticsConsentDialogTitle => 'Collectra の改善にご協力ください'; @override String get analyticsConsentDialogMessage => 'アプリ品質と機能改善のため、匿名の利用データ収集にご協力いただけますか?この設定はいつでも設定画面で変更できます。'; @@ -214,6 +220,57 @@ class AppLocalizationsJa extends AppLocalizations { @override String get settingsFirebaseRuntimeConfigSubtitle => 'ランタイム機能フラグを確認して更新'; + @override + String get settingsMetadataTitle => 'メタデータと自動入力'; + + @override + String get settingsMetadataSummaryEnabled => 'バーコード自動検索で有効'; + + @override + String get settingsMetadataSummaryManual => '手動検索で有効'; + + @override + String get settingsMetadataSummaryDisabled => '無効'; + + @override + String get settingsMetadataSummaryFeatureDisabled => '実行時の機能フラグにより無効'; + + @override + String get settingsMetadataEnableToggleTitle => 'メタデータ補助を有効化'; + + @override + String get settingsMetadataEnableToggleSubtitle => 'アイテムフォームでメタデータ検索とバーコード自動入力を利用します。'; + + @override + String get settingsMetadataAutoFetchToggleTitle => 'バーコード読み取り時に自動取得'; + + @override + String get settingsMetadataAutoFetchToggleSubtitle => 'バーコードを読み取った後、メタデータを自動取得します。'; + + @override + String get settingsMetadataFillEmptyOnlyToggleTitle => '空欄のみ入力'; + + @override + String get settingsMetadataFillEmptyOnlyToggleSubtitle => 'メタデータ検出時に既存のタイトルや説明を上書きしません。'; + + @override + String get settingsMetadataSourcesSectionTitle => 'ソース'; + + @override + String get settingsMetadataSourceAvailable => '利用可能'; + + @override + String get settingsMetadataSourceNotConfigured => '未設定'; + + @override + String get settingsMetadataSourceManualOnly => '手動のみ'; + + @override + String get settingsMetadataManualCollectionsLabel => 'コミック・音楽・カスタム'; + + @override + String get settingsMetadataFeatureDisabledMessage => 'メタデータ補助は実行時設定で無効化されています。'; + @override String get settingsFirebaseRuntimeConfigSheetTitle => 'Firebase ランタイム設定'; @@ -290,7 +347,7 @@ class AppLocalizationsJa extends AppLocalizations { String get settingsImportDataTitle => 'データをインポート'; @override - String get settingsImportDataMessage => 'JSONファイルからコレクションとアイテムをインポートします。既存データは削除されません。\\n\\n続行しますか?'; + String get settingsImportDataMessage => 'JSONファイルからコレクションとアイテムをインポートします。既存データは削除されません。\n\n続行しますか?'; @override String get settingsImportingData => 'データをインポート中...'; @@ -745,6 +802,17 @@ class AppLocalizationsJa extends AppLocalizations { @override String get metadataSearchSuggestionMessage => '入力してメタデータを検索してください。'; + @override + String get metadataSearchDisabledHint => 'このコレクション種別ではメタデータ検索が利用できないか、現在無効です。'; + + @override + String get metadataNoMatchForBarcode => 'このバーコードに一致するメタデータが見つかりません。'; + + @override + String metadataSearchUnavailableForType(String collectionType) { + return '$collectionType ではメタデータ検索を利用できません。'; + } + @override String tagItemsTitle(String tag) { return 'タグ: $tag'; @@ -878,7 +946,7 @@ class AppLocalizationsJa extends AppLocalizations { @override String tagManagementDeleteSelectedMessage(int count) { - return 'すべてのアイテムから選択した $count 件のタグを削除しますか?\\n\\nこの操作は元に戻せません。'; + return 'すべてのアイテムから選択した $count 件のタグを削除しますか?\n\nこの操作は元に戻せません。'; } @override @@ -899,7 +967,7 @@ class AppLocalizationsJa extends AppLocalizations { @override String tagManagementDeleteMessage(String tagName) { - return 'すべてのアイテムから \"$tagName\" を削除しますか?\\n\\nこの操作は元に戻せません。'; + return 'すべてのアイテムから \"$tagName\" を削除しますか?\n\nこの操作は元に戻せません。'; } @override @@ -1064,4 +1132,350 @@ class AppLocalizationsJa extends AppLocalizations { @override String get collectionTypeCustom => 'カスタム'; + + @override + String get loanTrackingTitle => '貸出管理'; + + @override + String get loanTrackingNewLoan => '新しい貸出'; + + @override + String get loanTrackingFilterActive => 'アクティブ'; + + @override + String get loanTrackingFilterHistory => '履歴'; + + @override + String get loanTrackingEmptyHistoryTitle => '返却済みの貸出はまだありません'; + + @override + String get loanTrackingEmptyHistoryMessage => '返却されたアイテムはここに表示されます。'; + + @override + String get loanTrackingEmptyActiveTitle => 'アクティブな貸出はありません'; + + @override + String get loanTrackingEmptyActiveMessage => '貸出を作成して、借りられたアイテムの管理を始めましょう。'; + + @override + String get loanTrackingLoadingLoans => '貸出を読み込み中...'; + + @override + String loanTrackingLoadFailed(String error) { + return '貸出の読み込みに失敗しました: $error'; + } + + @override + String get loanTrackingMarkReturnedConfirmTitle => '返却済みにしますか?'; + + @override + String loanTrackingMarkReturnedConfirmMessage(String itemTitle) { + return '\"$itemTitle\" の返却を確認します。'; + } + + @override + String get loanTrackingMarkReturnedAction => '返却済みにする'; + + @override + String get loanTrackingMarkedReturnedSuccess => '貸出を返却済みにしました。'; + + @override + String loanTrackingMarkReturnedFailed(String error) { + return '返却の更新に失敗しました: $error'; + } + + @override + String get loanTrackingDeleteConfirmTitle => '貸出記録を削除しますか?'; + + @override + String loanTrackingDeleteConfirmMessage(String itemTitle) { + return '\"$itemTitle\" の貸出記録を削除します。'; + } + + @override + String get loanTrackingDeleteSuccess => '貸出記録を削除しました。'; + + @override + String loanTrackingDeleteFailed(String error) { + return '貸出の削除に失敗しました: $error'; + } + + @override + String get loanTrackingSummaryActiveLabel => 'アクティブな貸出'; + + @override + String get loanTrackingSummaryOverdueLabel => '期限超過'; + + @override + String get loanTrackingSummaryLoadFailed => '貸出サマリーを読み込めませんでした。'; + + @override + String get loanTrackingFieldBorrower => '借り手'; + + @override + String get loanTrackingFieldContact => '連絡先'; + + @override + String get loanTrackingFieldLoaned => '貸出日'; + + @override + String get loanTrackingFieldDue => '返却期限'; + + @override + String get loanTrackingFieldReturned => '返却日'; + + @override + String get loanTrackingStatusReturned => '返却済み'; + + @override + String get loanTrackingStatusOverdue => '期限超過'; + + @override + String get loanTrackingStatusActive => 'アクティブ'; + + @override + String get loanTrackingCreateTitle => '貸出を作成'; + + @override + String get loanTrackingCreateDescription => '誰がアイテムを借りたか、いつ返却予定かを記録します。'; + + @override + String get loanTrackingCreateNoItemsTitle => '利用可能なアイテムがありません'; + + @override + String get loanTrackingCreateNoItemsMessage => 'すべて貸出中か、まだアイテムがありません。'; + + @override + String get loanTrackingCreateItemLabel => 'アイテム'; + + @override + String get loanTrackingCreateBorrowerLabel => '借り手の名前'; + + @override + String get loanTrackingCreateBorrowerHint => '例: 山田 太郎'; + + @override + String get loanTrackingCreateContactLabel => '連絡先(任意)'; + + @override + String get loanTrackingCreateContactHint => '電話、メール、または @username'; + + @override + String get loanTrackingCreateNotesLabel => 'メモ(任意)'; + + @override + String get loanTrackingCreateNotesHint => 'この貸出の追加情報'; + + @override + String get loanTrackingCreateSubmitting => '作成中...'; + + @override + String get loanTrackingCreateAction => '貸出を作成'; + + @override + String get loanTrackingLoadingItems => 'アイテムを読み込み中...'; + + @override + String loanTrackingLoadItemsFailed(String error) { + return 'アイテムの読み込みに失敗しました: $error'; + } + + @override + String get loanTrackingBorrowerRequired => '借り手の名前は必須です。'; + + @override + String get loanTrackingCreateSuccess => '貸出を作成しました。'; + + @override + String loanTrackingCreateFailed(String error) { + return '貸出の作成に失敗しました: $error'; + } + + @override + String get loanTrackingNoDueDate => '返却期限なし'; + + @override + String get loanTrackingPickDateAction => '選択'; + + @override + String get loanTrackingClearDateAction => 'クリア'; + + @override + String get loanTrackingDueDateLabel => '返却期限'; + + @override + String get authTitleAccount => 'アカウント'; + + @override + String get authCreateAccountHeading => 'アカウント作成'; + + @override + String get authSignInHeading => 'サインイン'; + + @override + String get authCreateAccountDescription => 'アカウントを作成すると、コレクションを複数端末で同期できます。'; + + @override + String get authSignInDescription => 'サインインするとクラウド同期とアカウント機能を利用できます。'; + + @override + String get authSignInChoice => 'サインイン'; + + @override + String get authRegisterChoice => '登録'; + + @override + String get authEmailLabel => 'メールアドレス'; + + @override + String get authEmailHint => 'you@example.com'; + + @override + String get authEmailRequiredError => 'メールアドレスは必須です。'; + + @override + String get authEmailInvalidError => '有効なメールアドレスを入力してください。'; + + @override + String get authPasswordLabel => 'パスワード'; + + @override + String get authPasswordHint => '8文字以上、A-Z、a-z、0-9'; + + @override + String get authPasswordRequiredError => 'パスワードは必須です。'; + + @override + String get authPasswordLengthError => 'パスワードは8文字以上で入力してください。'; + + @override + String get authPasswordPolicyError => 'パスワードには大文字、小文字、数字を含めてください。'; + + @override + String get authDisplayNameLabel => '表示名(任意)'; + + @override + String get authDisplayNameHint => '呼び名を入力してください'; + + @override + String get authCreateAccountAction => 'アカウントを作成'; + + @override + String get authNotNowAction => '今はしない'; + + @override + String get authUnavailableMessage => '認証は現在利用できません。'; + + @override + String get authRegisterSuccess => 'アカウントを作成してサインインしました。'; + + @override + String get authSignInSuccess => 'サインインしました。'; + + @override + String authSignInFailed(String error) { + return 'サインインに失敗しました: $error'; + } + + @override + String get authSignedOut => 'サインアウトしました。'; + + @override + String get authFinalConfirmationTitle => '最終確認'; + + @override + String get authFinalConfirmationMessage => '今すぐアカウント削除リクエストを送信しますか?この端末では直ちにサインアウトされます。'; + + @override + String get authBackAction => '戻る'; + + @override + String get authSubmitRequestAction => 'リクエストを送信'; + + @override + String get authDeletionRequestSubmitted => 'アカウント削除リクエストを送信しました。サインアウトされました。'; + + @override + String get authDeletionEndpointMissing => '削除リクエストのエンドポイントがバックエンドにまだ設定されていません。'; + + @override + String get authDeletionImpactDialogTitle => 'アカウント削除をリクエストする前に'; + + @override + String get authDeletionImpactReviewPrompt => '影響をよくご確認ください。'; + + @override + String get authIrreversibleRequestTitle => '取り消し不可のリクエスト'; + + @override + String get authImpactLineSessionRevoked => 'リクエスト送信後、アカウントセッションはすぐに無効化されます。'; + + @override + String get authImpactLineCloudDataDeleted => 'このアカウントに紐づく同期済みクラウドデータは、処理中に完全削除される可能性があります。'; + + @override + String get authImpactLineCannotRestore => '削除されたアカウントデータは処理後に復元できません。'; + + @override + String get authUnderstandAction => '理解しました'; + + @override + String get authPasswordPolicySuffix => '英字キーボードの文字と数字(A-Z、a-z、0-9)を使用してください。'; + + @override + String get authAccountConnected => 'アカウント接続済み'; + + @override + String get authSignedInReadySubtitle => 'サインイン済みでクラウド同期の準備ができています'; + + @override + String get authActiveStatus => '有効'; + + @override + String get authSessionDetailsTitle => 'セッション詳細'; + + @override + String get authUserIdLabel => 'ユーザーID'; + + @override + String get authDeviceIdLabel => 'デバイスID'; + + @override + String get authUnknownValue => '不明'; + + @override + String get authDeletionNoticeTitle => 'アカウント削除に関する注意'; + + @override + String get authDeletionNoticeSubtitle => '削除リクエストは処理されると元に戻せません。'; + + @override + String get authDeletionNoticeLineProfileSessions => 'アカウントプロフィールと有効なセッションはクラウドアクセスから削除されます。'; + + @override + String get authDeletionNoticeLineSyncedData => '同期済みのコレクション、アイテム、タグ、貸出データは完全に削除される可能性があります。'; + + @override + String get authRequestDeletionAction => 'アカウント削除をリクエスト'; + + @override + String get authSignOutAction => 'サインアウト'; + + @override + String get authDoneAction => '完了'; + + @override + String get authHeaderCreateTitle => 'アカウントを作成'; + + @override + String get authHeaderWelcomeTitle => 'おかえりなさい'; + + @override + String get authHeaderCreateSubtitle => 'アカウントは任意ですが、クラウド同期と複数端末アクセスには必要です。'; + + @override + String get authHeaderSignInSubtitle => 'サインインしてクラウド同期とアカウント機能を利用しましょう。'; + + @override + String get authUnavailableTitle => '認証を利用できません'; } diff --git a/apps/mobile/lib/l10n/gen/app_localizations_ko.dart b/apps/mobile/lib/l10n/gen/app_localizations_ko.dart index e48c373..403e6de 100644 --- a/apps/mobile/lib/l10n/gen/app_localizations_ko.dart +++ b/apps/mobile/lib/l10n/gen/app_localizations_ko.dart @@ -12,7 +12,7 @@ class AppLocalizationsKo extends AppLocalizations { AppLocalizationsKo([String locale = 'ko']) : super(locale); @override - String get appTitle => '컬렉션 트래커'; + String get appTitle => 'Collectra'; @override String get navHome => '홈'; @@ -113,6 +113,12 @@ class AppLocalizationsKo extends AppLocalizations { @override String get settingsManageTagsSubtitle => '태그 이름 변경, 병합, 삭제'; + @override + String get settingsLoanTrackingTitle => '대여 추적'; + + @override + String get settingsLoanTrackingSubtitle => '대여한 항목과 반납 예정일을 추적합니다'; + @override String get settingsVersionTitle => '버전'; @@ -174,7 +180,7 @@ class AppLocalizationsKo extends AppLocalizations { String get settingsAnalyticsConsentDeclined => '분석 동의가 거부되었습니다.'; @override - String get analyticsConsentDialogTitle => 'Collection Tracker 개선에 도움을 주세요'; + String get analyticsConsentDialogTitle => 'Collectra 개선에 도움을 주세요'; @override String get analyticsConsentDialogMessage => '앱 품질과 기능 개선을 위해 익명 사용 분석을 수집해도 될까요? 이 설정은 언제든지 설정에서 변경할 수 있습니다.'; @@ -214,6 +220,57 @@ class AppLocalizationsKo extends AppLocalizations { @override String get settingsFirebaseRuntimeConfigSubtitle => '런타임 기능 플래그 확인 및 새로고침'; + @override + String get settingsMetadataTitle => '메타데이터 및 자동완성'; + + @override + String get settingsMetadataSummaryEnabled => '자동 바코드 조회로 사용 중'; + + @override + String get settingsMetadataSummaryManual => '수동 조회로 사용 중'; + + @override + String get settingsMetadataSummaryDisabled => '사용 안 함'; + + @override + String get settingsMetadataSummaryFeatureDisabled => '런타임 기능 플래그로 비활성화됨'; + + @override + String get settingsMetadataEnableToggleTitle => '메타데이터 보조 사용'; + + @override + String get settingsMetadataEnableToggleSubtitle => '아이템 폼에서 메타데이터 검색과 바코드 자동완성을 허용합니다.'; + + @override + String get settingsMetadataAutoFetchToggleTitle => '바코드 스캔 시 자동 조회'; + + @override + String get settingsMetadataAutoFetchToggleSubtitle => '바코드를 스캔한 뒤 메타데이터를 자동으로 가져옵니다.'; + + @override + String get settingsMetadataFillEmptyOnlyToggleTitle => '빈 필드만 채우기'; + + @override + String get settingsMetadataFillEmptyOnlyToggleSubtitle => '메타데이터를 찾았을 때 기존 제목이나 설명을 덮어쓰지 않습니다.'; + + @override + String get settingsMetadataSourcesSectionTitle => '소스'; + + @override + String get settingsMetadataSourceAvailable => '사용 가능'; + + @override + String get settingsMetadataSourceNotConfigured => '미설정'; + + @override + String get settingsMetadataSourceManualOnly => '수동만'; + + @override + String get settingsMetadataManualCollectionsLabel => '코믹, 음악 및 사용자 지정'; + + @override + String get settingsMetadataFeatureDisabledMessage => '메타데이터 보조가 런타임 구성으로 비활성화되었습니다.'; + @override String get settingsFirebaseRuntimeConfigSheetTitle => 'Firebase 런타임 구성'; @@ -290,7 +347,7 @@ class AppLocalizationsKo extends AppLocalizations { String get settingsImportDataTitle => '데이터 가져오기'; @override - String get settingsImportDataMessage => 'JSON 파일에서 컬렉션과 항목을 가져옵니다. 기존 데이터는 삭제되지 않습니다.\\n\\n계속할까요?'; + String get settingsImportDataMessage => 'JSON 파일에서 컬렉션과 항목을 가져옵니다. 기존 데이터는 삭제되지 않습니다.\n\n계속할까요?'; @override String get settingsImportingData => '데이터 가져오는 중...'; @@ -745,6 +802,17 @@ class AppLocalizationsKo extends AppLocalizations { @override String get metadataSearchSuggestionMessage => '메타데이터를 찾으려면 입력하세요.'; + @override + String get metadataSearchDisabledHint => '이 컬렉션 유형에서는 메타데이터 검색을 사용할 수 없거나 현재 비활성화되어 있습니다.'; + + @override + String get metadataNoMatchForBarcode => '이 바코드와 일치하는 메타데이터를 찾지 못했습니다.'; + + @override + String metadataSearchUnavailableForType(String collectionType) { + return '$collectionType에는 메타데이터 검색을 사용할 수 없습니다.'; + } + @override String tagItemsTitle(String tag) { return '태그: $tag'; @@ -878,7 +946,7 @@ class AppLocalizationsKo extends AppLocalizations { @override String tagManagementDeleteSelectedMessage(int count) { - return '모든 항목에서 선택한 태그 $count개를 삭제할까요?\\n\\n이 작업은 되돌릴 수 없습니다.'; + return '모든 항목에서 선택한 태그 $count개를 삭제할까요?\n\n이 작업은 되돌릴 수 없습니다.'; } @override @@ -899,7 +967,7 @@ class AppLocalizationsKo extends AppLocalizations { @override String tagManagementDeleteMessage(String tagName) { - return '모든 항목에서 \"$tagName\"을(를) 삭제할까요?\\n\\n이 작업은 되돌릴 수 없습니다.'; + return '모든 항목에서 \"$tagName\"을(를) 삭제할까요?\n\n이 작업은 되돌릴 수 없습니다.'; } @override @@ -1064,4 +1132,350 @@ class AppLocalizationsKo extends AppLocalizations { @override String get collectionTypeCustom => '사용자 지정'; + + @override + String get loanTrackingTitle => '대여 추적'; + + @override + String get loanTrackingNewLoan => '새 대여'; + + @override + String get loanTrackingFilterActive => '활성'; + + @override + String get loanTrackingFilterHistory => '기록'; + + @override + String get loanTrackingEmptyHistoryTitle => '아직 반납된 대여가 없습니다'; + + @override + String get loanTrackingEmptyHistoryMessage => '반납된 항목이 여기에 표시됩니다.'; + + @override + String get loanTrackingEmptyActiveTitle => '활성 대여가 없습니다'; + + @override + String get loanTrackingEmptyActiveMessage => '대여를 생성해 빌려준 항목을 추적해 보세요.'; + + @override + String get loanTrackingLoadingLoans => '대여 불러오는 중...'; + + @override + String loanTrackingLoadFailed(String error) { + return '대여를 불러오지 못했습니다: $error'; + } + + @override + String get loanTrackingMarkReturnedConfirmTitle => '반납으로 표시할까요?'; + + @override + String loanTrackingMarkReturnedConfirmMessage(String itemTitle) { + return '\"$itemTitle\" 반납을 확인합니다.'; + } + + @override + String get loanTrackingMarkReturnedAction => '반납 처리'; + + @override + String get loanTrackingMarkedReturnedSuccess => '대여가 반납 처리되었습니다.'; + + @override + String loanTrackingMarkReturnedFailed(String error) { + return '반납 처리에 실패했습니다: $error'; + } + + @override + String get loanTrackingDeleteConfirmTitle => '대여 기록을 삭제할까요?'; + + @override + String loanTrackingDeleteConfirmMessage(String itemTitle) { + return '\"$itemTitle\" 대여 기록을 삭제합니다.'; + } + + @override + String get loanTrackingDeleteSuccess => '대여가 삭제되었습니다.'; + + @override + String loanTrackingDeleteFailed(String error) { + return '대여 삭제에 실패했습니다: $error'; + } + + @override + String get loanTrackingSummaryActiveLabel => '활성 대여'; + + @override + String get loanTrackingSummaryOverdueLabel => '연체'; + + @override + String get loanTrackingSummaryLoadFailed => '대여 요약을 불러올 수 없습니다.'; + + @override + String get loanTrackingFieldBorrower => '대여자'; + + @override + String get loanTrackingFieldContact => '연락처'; + + @override + String get loanTrackingFieldLoaned => '대여일'; + + @override + String get loanTrackingFieldDue => '반납 예정일'; + + @override + String get loanTrackingFieldReturned => '반납일'; + + @override + String get loanTrackingStatusReturned => '반납됨'; + + @override + String get loanTrackingStatusOverdue => '연체'; + + @override + String get loanTrackingStatusActive => '활성'; + + @override + String get loanTrackingCreateTitle => '대여 생성'; + + @override + String get loanTrackingCreateDescription => '누가 항목을 빌렸는지와 반납 예정일을 추적합니다.'; + + @override + String get loanTrackingCreateNoItemsTitle => '사용 가능한 항목이 없습니다'; + + @override + String get loanTrackingCreateNoItemsMessage => '모든 항목이 이미 대여 중이거나 아직 항목이 없습니다.'; + + @override + String get loanTrackingCreateItemLabel => '항목'; + + @override + String get loanTrackingCreateBorrowerLabel => '대여자 이름'; + + @override + String get loanTrackingCreateBorrowerHint => '예: 홍길동'; + + @override + String get loanTrackingCreateContactLabel => '연락처 (선택)'; + + @override + String get loanTrackingCreateContactHint => '전화번호, 이메일 또는 @username'; + + @override + String get loanTrackingCreateNotesLabel => '메모 (선택)'; + + @override + String get loanTrackingCreateNotesHint => '이 대여에 대한 추가 정보'; + + @override + String get loanTrackingCreateSubmitting => '생성 중...'; + + @override + String get loanTrackingCreateAction => '대여 생성'; + + @override + String get loanTrackingLoadingItems => '항목 불러오는 중...'; + + @override + String loanTrackingLoadItemsFailed(String error) { + return '항목을 불러오지 못했습니다: $error'; + } + + @override + String get loanTrackingBorrowerRequired => '대여자 이름은 필수입니다.'; + + @override + String get loanTrackingCreateSuccess => '대여가 생성되었습니다.'; + + @override + String loanTrackingCreateFailed(String error) { + return '대여 생성에 실패했습니다: $error'; + } + + @override + String get loanTrackingNoDueDate => '반납 예정일 없음'; + + @override + String get loanTrackingPickDateAction => '선택'; + + @override + String get loanTrackingClearDateAction => '지우기'; + + @override + String get loanTrackingDueDateLabel => '반납 예정일'; + + @override + String get authTitleAccount => '계정'; + + @override + String get authCreateAccountHeading => '계정 만들기'; + + @override + String get authSignInHeading => '로그인'; + + @override + String get authCreateAccountDescription => '계정을 만들어 컬렉션을 여러 기기에서 동기화하세요.'; + + @override + String get authSignInDescription => '로그인하면 클라우드 동기화와 계정 기능을 사용할 수 있습니다.'; + + @override + String get authSignInChoice => '로그인'; + + @override + String get authRegisterChoice => '회원가입'; + + @override + String get authEmailLabel => '이메일'; + + @override + String get authEmailHint => 'you@example.com'; + + @override + String get authEmailRequiredError => '이메일은 필수입니다.'; + + @override + String get authEmailInvalidError => '유효한 이메일을 입력하세요.'; + + @override + String get authPasswordLabel => '비밀번호'; + + @override + String get authPasswordHint => '최소 8자, A-Z, a-z, 0-9'; + + @override + String get authPasswordRequiredError => '비밀번호는 필수입니다.'; + + @override + String get authPasswordLengthError => '비밀번호는 최소 8자 이상이어야 합니다.'; + + @override + String get authPasswordPolicyError => '비밀번호에는 대문자, 소문자, 숫자가 포함되어야 합니다.'; + + @override + String get authDisplayNameLabel => '표시 이름 (선택)'; + + @override + String get authDisplayNameHint => '어떻게 불러드릴까요?'; + + @override + String get authCreateAccountAction => '계정 만들기'; + + @override + String get authNotNowAction => '나중에'; + + @override + String get authUnavailableMessage => '현재 인증을 사용할 수 없습니다.'; + + @override + String get authRegisterSuccess => '계정이 생성되었고 로그인되었습니다.'; + + @override + String get authSignInSuccess => '로그인되었습니다.'; + + @override + String authSignInFailed(String error) { + return '로그인 실패: $error'; + } + + @override + String get authSignedOut => '로그아웃되었습니다.'; + + @override + String get authFinalConfirmationTitle => '최종 확인'; + + @override + String get authFinalConfirmationMessage => '지금 계정 삭제 요청을 제출할까요? 이 기기에서 즉시 로그아웃됩니다.'; + + @override + String get authBackAction => '뒤로'; + + @override + String get authSubmitRequestAction => '요청 제출'; + + @override + String get authDeletionRequestSubmitted => '계정 삭제 요청이 제출되었습니다. 로그아웃되었습니다.'; + + @override + String get authDeletionEndpointMissing => '계정 삭제 요청 엔드포인트가 아직 백엔드에 구성되지 않았습니다.'; + + @override + String get authDeletionImpactDialogTitle => '계정 삭제를 요청하기 전에'; + + @override + String get authDeletionImpactReviewPrompt => '영향을 신중히 확인해 주세요.'; + + @override + String get authIrreversibleRequestTitle => '되돌릴 수 없는 요청'; + + @override + String get authImpactLineSessionRevoked => '요청 즉시 계정 세션이 취소됩니다.'; + + @override + String get authImpactLineCloudDataDeleted => '이 계정과 연결된 동기화 클라우드 데이터는 처리 중 영구 삭제될 수 있습니다.'; + + @override + String get authImpactLineCannotRestore => '삭제된 계정 데이터는 처리 후 복구할 수 없습니다.'; + + @override + String get authUnderstandAction => '이해했습니다'; + + @override + String get authPasswordPolicySuffix => '영문 키보드 문자와 숫자(A-Z, a-z, 0-9)를 사용하세요.'; + + @override + String get authAccountConnected => '계정 연결됨'; + + @override + String get authSignedInReadySubtitle => '로그인되어 클라우드 동기화 준비 완료'; + + @override + String get authActiveStatus => '활성'; + + @override + String get authSessionDetailsTitle => '세션 정보'; + + @override + String get authUserIdLabel => '사용자 ID'; + + @override + String get authDeviceIdLabel => '기기 ID'; + + @override + String get authUnknownValue => '알 수 없음'; + + @override + String get authDeletionNoticeTitle => '계정 삭제 안내'; + + @override + String get authDeletionNoticeSubtitle => '삭제 요청은 처리되면 되돌릴 수 없습니다.'; + + @override + String get authDeletionNoticeLineProfileSessions => '계정 프로필과 활성 세션은 클라우드 접근에서 제거됩니다.'; + + @override + String get authDeletionNoticeLineSyncedData => '동기화된 컬렉션, 항목, 태그, 대여 데이터가 영구 삭제될 수 있습니다.'; + + @override + String get authRequestDeletionAction => '계정 삭제 요청'; + + @override + String get authSignOutAction => '로그아웃'; + + @override + String get authDoneAction => '완료'; + + @override + String get authHeaderCreateTitle => '계정을 만들어 보세요'; + + @override + String get authHeaderWelcomeTitle => '다시 오신 것을 환영합니다'; + + @override + String get authHeaderCreateSubtitle => '계정은 선택 사항이지만 클라우드 동기화와 다중 기기 접근에 필요합니다.'; + + @override + String get authHeaderSignInSubtitle => '로그인하여 클라우드 동기화와 계정 기능을 사용하세요.'; + + @override + String get authUnavailableTitle => '인증을 사용할 수 없음'; } diff --git a/apps/mobile/lib/l10n/gen/app_localizations_my.dart b/apps/mobile/lib/l10n/gen/app_localizations_my.dart index 728fdac..9bddc80 100644 --- a/apps/mobile/lib/l10n/gen/app_localizations_my.dart +++ b/apps/mobile/lib/l10n/gen/app_localizations_my.dart @@ -12,7 +12,7 @@ class AppLocalizationsMy extends AppLocalizations { AppLocalizationsMy([String locale = 'my']) : super(locale); @override - String get appTitle => 'Collection Tracker'; + String get appTitle => 'Collectra'; @override String get navHome => 'ပင်မ'; @@ -113,6 +113,12 @@ class AppLocalizationsMy extends AppLocalizations { @override String get settingsManageTagsSubtitle => 'Tag အမည်ပြောင်း၊ ပေါင်းစည်း၊ ဖျက်နိုင်သည်'; + @override + String get settingsLoanTrackingTitle => 'ငှားရမ်းမှု ခြေရာခံ'; + + @override + String get settingsLoanTrackingSubtitle => 'ငှားထားသော ပစ္စည်းများနှင့် ပြန်အပ်ရမည့်ရက်ကို ခြေရာခံပါ'; + @override String get settingsVersionTitle => 'ဗားရှင်း'; @@ -174,7 +180,7 @@ class AppLocalizationsMy extends AppLocalizations { String get settingsAnalyticsConsentDeclined => 'Analytics သဘောတူညီချက် ငြင်းဆိုထားသည်။'; @override - String get analyticsConsentDialogTitle => 'Collection Tracker ကို တိုးတက်စေရန် ကူညီပါ'; + String get analyticsConsentDialogTitle => 'Collectra ကို တိုးတက်စေရန် ကူညီပါ'; @override String get analyticsConsentDialogMessage => 'အက်ပ်အရည်အသွေးနှင့် အင်္ဂါရပ်များ တိုးတက်စေရန် အမည်မဖော်ထားသော အသုံးပြုမှု analytics ကို စုဆောင်းခွင့်ပြုမလား? ဤဆက်တင်ကို Settings တွင် အချိန်မရွေး ပြောင်းလဲနိုင်သည်။'; @@ -214,6 +220,57 @@ class AppLocalizationsMy extends AppLocalizations { @override String get settingsFirebaseRuntimeConfigSubtitle => 'runtime feature flags ကို စစ်ဆေးပြီး refresh လုပ်ပါ'; + @override + String get settingsMetadataTitle => 'Metadata နှင့် Auto-fill'; + + @override + String get settingsMetadataSummaryEnabled => 'Barcode အလိုအလျောက်ရှာဖွေမှုဖြင့် ဖွင့်ထားသည်'; + + @override + String get settingsMetadataSummaryManual => 'လက်ဖြင့်ရှာဖွေမှုဖြင့် ဖွင့်ထားသည်'; + + @override + String get settingsMetadataSummaryDisabled => 'ပိတ်ထားသည်'; + + @override + String get settingsMetadataSummaryFeatureDisabled => 'Runtime feature flag ကြောင့် ပိတ်ထားသည်'; + + @override + String get settingsMetadataEnableToggleTitle => 'Metadata အကူအညီ ဖွင့်မည်'; + + @override + String get settingsMetadataEnableToggleSubtitle => 'Item form များတွင် metadata ရှာဖွေမှုနှင့် barcode auto-fill ကို ခွင့်ပြုပါသည်။'; + + @override + String get settingsMetadataAutoFetchToggleTitle => 'Barcode scan ပြီးနောက် အလိုအလျောက် ရယူမည်'; + + @override + String get settingsMetadataAutoFetchToggleSubtitle => 'Barcode scan ပြီးနောက် metadata ကို အလိုအလျောက် ရယူပါသည်။'; + + @override + String get settingsMetadataFillEmptyOnlyToggleTitle => 'လွတ်နေသော fields များကိုသာ ဖြည့်မည်'; + + @override + String get settingsMetadataFillEmptyOnlyToggleSubtitle => 'Metadata တွေ့ရှိသောအခါ ရှိပြီးသား title သို့မဟုတ် description ကို မရေးကျော်ပါ။'; + + @override + String get settingsMetadataSourcesSectionTitle => 'Sources'; + + @override + String get settingsMetadataSourceAvailable => 'အသုံးပြုနိုင်သည်'; + + @override + String get settingsMetadataSourceNotConfigured => 'မသတ်မှတ်ရသေးပါ'; + + @override + String get settingsMetadataSourceManualOnly => 'လက်ဖြင့်သာ'; + + @override + String get settingsMetadataManualCollectionsLabel => 'Comics, Music နှင့် Custom'; + + @override + String get settingsMetadataFeatureDisabledMessage => 'Metadata အကူအညီကို runtime configuration ဖြင့် ပိတ်ထားသည်။'; + @override String get settingsFirebaseRuntimeConfigSheetTitle => 'Firebase Runtime Config'; @@ -290,7 +347,7 @@ class AppLocalizationsMy extends AppLocalizations { String get settingsImportDataTitle => 'ဒေတာ ထည့်သွင်းရန်'; @override - String get settingsImportDataMessage => 'ဤလုပ်ဆောင်မှုသည် JSON ဖိုင်မှ collection များနှင့် item များကို ထည့်သွင်းမည်ဖြစ်သည်။ ရှိပြီးသားဒေတာ မဖျက်ပါ။\\n\\nဆက်လုပ်မလား?'; + String get settingsImportDataMessage => 'ဤလုပ်ဆောင်မှုသည် JSON ဖိုင်မှ collection များနှင့် item များကို ထည့်သွင်းမည်ဖြစ်သည်။ ရှိပြီးသားဒေတာ မဖျက်ပါ။\n\nဆက်လုပ်မလား?'; @override String get settingsImportingData => 'ဒေတာ ထည့်သွင်းနေသည်...'; @@ -745,6 +802,17 @@ class AppLocalizationsMy extends AppLocalizations { @override String get metadataSearchSuggestionMessage => 'Start typing to look up metadata.'; + @override + String get metadataSearchDisabledHint => 'ဤ collection type အတွက် metadata ရှာဖွေမှု မရနိုင်ပါ သို့မဟုတ် လက်ရှိပိတ်ထားသည်။'; + + @override + String get metadataNoMatchForBarcode => 'ဤ barcode အတွက် ကိုက်ညီသော metadata မတွေ့ပါ။'; + + @override + String metadataSearchUnavailableForType(String collectionType) { + return '$collectionType အတွက် metadata ရှာဖွေမှု မရနိုင်ပါ။'; + } + @override String tagItemsTitle(String tag) { return 'Tag: $tag'; @@ -878,7 +946,7 @@ class AppLocalizationsMy extends AppLocalizations { @override String tagManagementDeleteSelectedMessage(int count) { - return 'Delete $count selected tags from all items?\\n\\nThis cannot be undone.'; + return 'Delete $count selected tags from all items?\n\nThis cannot be undone.'; } @override @@ -899,7 +967,7 @@ class AppLocalizationsMy extends AppLocalizations { @override String tagManagementDeleteMessage(String tagName) { - return 'Delete \"$tagName\" from all items?\\n\\nThis cannot be undone.'; + return 'Delete \"$tagName\" from all items?\n\nThis cannot be undone.'; } @override @@ -1064,4 +1132,350 @@ class AppLocalizationsMy extends AppLocalizations { @override String get collectionTypeCustom => 'စိတ်ကြိုက်'; + + @override + String get loanTrackingTitle => 'ငှားရမ်းမှု ခြေရာခံ'; + + @override + String get loanTrackingNewLoan => 'ငှားရမ်းမှု အသစ်'; + + @override + String get loanTrackingFilterActive => 'လက်ရှိ'; + + @override + String get loanTrackingFilterHistory => 'မှတ်တမ်း'; + + @override + String get loanTrackingEmptyHistoryTitle => 'ပြန်အပ်ပြီး ငှားရမ်းမှု မရှိသေးပါ'; + + @override + String get loanTrackingEmptyHistoryMessage => 'ပြန်အပ်ပြီး ပစ္စည်းများကို ဒီနေရာတွင် ပြပါမည်။'; + + @override + String get loanTrackingEmptyActiveTitle => 'လက်ရှိ ငှားရမ်းမှု မရှိပါ'; + + @override + String get loanTrackingEmptyActiveMessage => 'ငှားသွားသော ပစ္စည်းများကို ခြေရာခံရန် ငှားရမ်းမှု အသစ် ဖန်တီးပါ။'; + + @override + String get loanTrackingLoadingLoans => 'ငှားရမ်းမှုများ တင်နေသည်...'; + + @override + String loanTrackingLoadFailed(String error) { + return 'ငှားရမ်းမှုများ တင်မရပါ: $error'; + } + + @override + String get loanTrackingMarkReturnedConfirmTitle => 'ပြန်အပ်ပြီးဟု မှတ်မလား?'; + + @override + String loanTrackingMarkReturnedConfirmMessage(String itemTitle) { + return '\"$itemTitle\" ကို ပြန်အပ်ပြီးဟု အတည်ပြုမည်။'; + } + + @override + String get loanTrackingMarkReturnedAction => 'ပြန်အပ်ပြီး မှတ်မည်'; + + @override + String get loanTrackingMarkedReturnedSuccess => 'ငှားရမ်းမှုကို ပြန်အပ်ပြီးအဖြစ် မှတ်သားပြီးပါပြီ။'; + + @override + String loanTrackingMarkReturnedFailed(String error) { + return 'ပြန်အပ်ပြီး မှတ်မရပါ: $error'; + } + + @override + String get loanTrackingDeleteConfirmTitle => 'ငှားရမ်းမှု မှတ်တမ်း ဖျက်မလား?'; + + @override + String loanTrackingDeleteConfirmMessage(String itemTitle) { + return '\"$itemTitle\" အတွက် ငှားရမ်းမှု မှတ်တမ်းကို ဖျက်မည်။'; + } + + @override + String get loanTrackingDeleteSuccess => 'ငှားရမ်းမှုကို ဖျက်ပြီးပါပြီ။'; + + @override + String loanTrackingDeleteFailed(String error) { + return 'ငှားရမ်းမှု ဖျက်မရပါ: $error'; + } + + @override + String get loanTrackingSummaryActiveLabel => 'လက်ရှိ ငှားရမ်းမှု'; + + @override + String get loanTrackingSummaryOverdueLabel => 'ကျော်လွန်'; + + @override + String get loanTrackingSummaryLoadFailed => 'ငှားရမ်းမှု အနှစ်ချုပ် တင်မရပါ။'; + + @override + String get loanTrackingFieldBorrower => 'ငှားယူသူ'; + + @override + String get loanTrackingFieldContact => 'ဆက်သွယ်ရန်'; + + @override + String get loanTrackingFieldLoaned => 'ငှားသည့်နေ့'; + + @override + String get loanTrackingFieldDue => 'ပြန်အပ်ရမည့်နေ့'; + + @override + String get loanTrackingFieldReturned => 'ပြန်အပ်သည့်နေ့'; + + @override + String get loanTrackingStatusReturned => 'ပြန်အပ်ပြီး'; + + @override + String get loanTrackingStatusOverdue => 'ကျော်လွန်'; + + @override + String get loanTrackingStatusActive => 'လက်ရှိ'; + + @override + String get loanTrackingCreateTitle => 'ငှားရမ်းမှု ဖန်တီး'; + + @override + String get loanTrackingCreateDescription => 'ဘယ်သူငှားယူသွားသည်နှင့် ဘယ်နေ့ ပြန်အပ်ရမည်ကို မှတ်တမ်းတင်ပါ။'; + + @override + String get loanTrackingCreateNoItemsTitle => 'အသုံးပြုနိုင်သော ပစ္စည်း မရှိပါ'; + + @override + String get loanTrackingCreateNoItemsMessage => 'ပစ္စည်းအားလုံး ငှားထားပြီး သို့မဟုတ် ပစ္စည်းမရှိသေးပါ။'; + + @override + String get loanTrackingCreateItemLabel => 'ပစ္စည်း'; + + @override + String get loanTrackingCreateBorrowerLabel => 'ငှားယူသူ အမည်'; + + @override + String get loanTrackingCreateBorrowerHint => 'ဥပမာ - Aung Aung'; + + @override + String get loanTrackingCreateContactLabel => 'ဆက်သွယ်ရန် (မဖြည့်လည်းရ)'; + + @override + String get loanTrackingCreateContactHint => 'ဖုန်း၊ အီးမေးလ် သို့မဟုတ် @username'; + + @override + String get loanTrackingCreateNotesLabel => 'မှတ်စု (မဖြည့်လည်းရ)'; + + @override + String get loanTrackingCreateNotesHint => 'ဒီငှားရမ်းမှုအတွက် အသေးစိတ်'; + + @override + String get loanTrackingCreateSubmitting => 'ဖန်တီးနေသည်...'; + + @override + String get loanTrackingCreateAction => 'ငှားရမ်းမှု ဖန်တီး'; + + @override + String get loanTrackingLoadingItems => 'ပစ္စည်းများ တင်နေသည်...'; + + @override + String loanTrackingLoadItemsFailed(String error) { + return 'ပစ္စည်းများ တင်မရပါ: $error'; + } + + @override + String get loanTrackingBorrowerRequired => 'ငှားယူသူ အမည် မဖြစ်မနေလိုအပ်သည်။'; + + @override + String get loanTrackingCreateSuccess => 'ငှားရမ်းမှု အောင်မြင်စွာ ဖန်တီးပြီးပါပြီ။'; + + @override + String loanTrackingCreateFailed(String error) { + return 'ငှားရမ်းမှု ဖန်တီးမရပါ: $error'; + } + + @override + String get loanTrackingNoDueDate => 'ပြန်အပ်ရမည့်နေ့ မသတ်မှတ်ထားပါ'; + + @override + String get loanTrackingPickDateAction => 'ရွေးမည်'; + + @override + String get loanTrackingClearDateAction => 'ရှင်းမည်'; + + @override + String get loanTrackingDueDateLabel => 'ပြန်အပ်ရမည့်နေ့'; + + @override + String get authTitleAccount => 'အကောင့်'; + + @override + String get authCreateAccountHeading => 'အကောင့် ဖန်တီးရန်'; + + @override + String get authSignInHeading => 'ဝင်မည်'; + + @override + String get authCreateAccountDescription => 'စက်များအကြား စုစည်းမှုများကို sync လုပ်ရန် အကောင့်တစ်ခု ဖန်တီးပါ။'; + + @override + String get authSignInDescription => 'Cloud sync နှင့် အကောင့်ဆိုင်ရာ လုပ်ဆောင်ချက်များအသုံးပြုရန် ဝင်ပါ။'; + + @override + String get authSignInChoice => 'ဝင်မည်'; + + @override + String get authRegisterChoice => 'မှတ်ပုံတင်မည်'; + + @override + String get authEmailLabel => 'အီးမေးလ်'; + + @override + String get authEmailHint => 'you@example.com'; + + @override + String get authEmailRequiredError => 'အီးမေးလ် မဖြစ်မနေလိုအပ်သည်။'; + + @override + String get authEmailInvalidError => 'မှန်ကန်သော အီးမေးလ် ထည့်ပါ။'; + + @override + String get authPasswordLabel => 'လျှို့ဝှက်နံပါတ်'; + + @override + String get authPasswordHint => 'အနည်းဆုံး ၈ လုံး၊ A-Z, a-z, 0-9'; + + @override + String get authPasswordRequiredError => 'လျှို့ဝှက်နံပါတ် မဖြစ်မနေလိုအပ်သည်။'; + + @override + String get authPasswordLengthError => 'လျှို့ဝှက်နံပါတ်မှာ အနည်းဆုံး ၈ လုံး ရှိရမည်။'; + + @override + String get authPasswordPolicyError => 'လျှို့ဝှက်နံပါတ်တွင် စာလုံးကြီး၊ စာလုံးသေးနှင့် ဂဏန်း ပါဝင်ရမည်။'; + + @override + String get authDisplayNameLabel => 'ပြသမည့်အမည် (မဖြည့်လည်းရ)'; + + @override + String get authDisplayNameHint => 'သင့်ကို ဘယ်လိုခေါ်မလဲ?'; + + @override + String get authCreateAccountAction => 'အကောင့်ဖန်တီးမည်'; + + @override + String get authNotNowAction => 'အခုမလုပ်တော့'; + + @override + String get authUnavailableMessage => 'ယခု အတည်ပြုဝင်ရောက်မှု မရနိုင်သေးပါ။'; + + @override + String get authRegisterSuccess => 'အကောင့် ဖန်တီးပြီး ဝင်ရောက်ပြီးပါပြီ။'; + + @override + String get authSignInSuccess => 'ဝင်ရောက်မှု အောင်မြင်သည်။'; + + @override + String authSignInFailed(String error) { + return 'ဝင်ရောက်မှု မအောင်မြင်ပါ: $error'; + } + + @override + String get authSignedOut => 'ထွက်ပြီးပါပြီ။'; + + @override + String get authFinalConfirmationTitle => 'နောက်ဆုံးအတည်ပြုချက်'; + + @override + String get authFinalConfirmationMessage => 'အကောင့်ဖျက်ရန် တောင်းဆိုချက်ကို ယခု ပို့မလား? ဒီစက်တွင် ချက်ချင်း ထွက်သွားပါမည်။'; + + @override + String get authBackAction => 'နောက်သို့'; + + @override + String get authSubmitRequestAction => 'တောင်းဆိုချက် ပို့မည်'; + + @override + String get authDeletionRequestSubmitted => 'အကောင့်ဖျက်ရန် တောင်းဆိုချက် ပို့ပြီးပါပြီ။ သင့်ကို ထွက်ပေးလိုက်ပါပြီ။'; + + @override + String get authDeletionEndpointMissing => 'ဖျက်ရန် တောင်းဆို endpoint ကို backend တွင် မသတ်မှတ်ရသေးပါ။'; + + @override + String get authDeletionImpactDialogTitle => 'အကောင့်ဖျက်ရန် တောင်းဆိုမီ'; + + @override + String get authDeletionImpactReviewPrompt => 'သက်ရောက်မှုကို သေချာစွာ စစ်ဆေးပါ။'; + + @override + String get authIrreversibleRequestTitle => 'ပြန်မယူနိုင်သော တောင်းဆိုချက်'; + + @override + String get authImpactLineSessionRevoked => 'တောင်းဆိုပြီးချင်း သင့်အကောင့် session ကို ပိတ်ပါမည်။'; + + @override + String get authImpactLineCloudDataDeleted => 'ဒီအကောင့်နှင့်ချိတ်ဆက်ထားသော sync cloud ဒေတာများကို လုပ်ဆောင်နေစဉ် အပြီးအပိုင် ဖျက်နိုင်ပါသည်။'; + + @override + String get authImpactLineCannotRestore => 'ဖျက်ပြီးသော အကောင့်ဒေတာကို လုပ်ဆောင်ပြီးနောက် ပြန်မရနိုင်ပါ။'; + + @override + String get authUnderstandAction => 'နားလည်ပါသည်'; + + @override + String get authPasswordPolicySuffix => 'အင်္ဂလိပ်ကီးဘုတ် စာလုံးများနှင့် ဂဏန်းများ (A-Z, a-z, 0-9) ကို အသုံးပြုပါ။'; + + @override + String get authAccountConnected => 'အကောင့် ချိတ်ဆက်ပြီး'; + + @override + String get authSignedInReadySubtitle => 'ဝင်ထားပြီး Cloud sync အတွက် အဆင်သင့်'; + + @override + String get authActiveStatus => 'အသုံးပြုနေ'; + + @override + String get authSessionDetailsTitle => 'Session အသေးစိတ်'; + + @override + String get authUserIdLabel => 'User ID'; + + @override + String get authDeviceIdLabel => 'Device ID'; + + @override + String get authUnknownValue => 'မသိ'; + + @override + String get authDeletionNoticeTitle => 'အကောင့်ဖျက်ရန် အသိပေးချက်'; + + @override + String get authDeletionNoticeSubtitle => 'ဖျက်ရန် တောင်းဆိုချက်သည် လုပ်ဆောင်ပြီးပါက ပြန်မရနိုင်ပါ။'; + + @override + String get authDeletionNoticeLineProfileSessions => 'အကောင့်ပရိုဖိုင်နှင့် active session များကို cloud access မှ ဖယ်ရှားပါမည်။'; + + @override + String get authDeletionNoticeLineSyncedData => 'Sync လုပ်ထားသော collections, items, tags နှင့် loans များကို အပြီးအပိုင် ဖျက်နိုင်ပါသည်။'; + + @override + String get authRequestDeletionAction => 'အကောင့်ဖျက်ရန် တောင်းဆို'; + + @override + String get authSignOutAction => 'ထွက်မည်'; + + @override + String get authDoneAction => 'ပြီးပါပြီ'; + + @override + String get authHeaderCreateTitle => 'သင့်အကောင့် ဖန်တီးပါ'; + + @override + String get authHeaderWelcomeTitle => 'ပြန်လည်ကြိုဆိုပါသည်'; + + @override + String get authHeaderCreateSubtitle => 'အကောင့်မဖွင့်လည်း ရပါသည်၊ သို့သော် cloud sync နှင့် စက်အများအပြား အသုံးပြုရန် လိုအပ်ပါသည်။'; + + @override + String get authHeaderSignInSubtitle => 'Cloud sync နှင့် အကောင့်အခြေပြု လုပ်ဆောင်ချက်များ အသုံးပြုရန် ဝင်ပါ။'; + + @override + String get authUnavailableTitle => 'အတည်ပြုဝင်ရောက်မှု မရနိုင်ပါ'; } diff --git a/apps/mobile/lib/l10n/gen/app_localizations_zh.dart b/apps/mobile/lib/l10n/gen/app_localizations_zh.dart index 76701d8..7545d28 100644 --- a/apps/mobile/lib/l10n/gen/app_localizations_zh.dart +++ b/apps/mobile/lib/l10n/gen/app_localizations_zh.dart @@ -12,7 +12,7 @@ class AppLocalizationsZh extends AppLocalizations { AppLocalizationsZh([String locale = 'zh']) : super(locale); @override - String get appTitle => 'Collection Tracker'; + String get appTitle => 'Collectra'; @override String get navHome => '首页'; @@ -113,6 +113,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settingsManageTagsSubtitle => '重命名、合并和删除标签'; + @override + String get settingsLoanTrackingTitle => '借出追踪'; + + @override + String get settingsLoanTrackingSubtitle => '跟踪借出物品和归还日期'; + @override String get settingsVersionTitle => '版本'; @@ -174,7 +180,7 @@ class AppLocalizationsZh extends AppLocalizations { String get settingsAnalyticsConsentDeclined => '已拒绝分析收集。'; @override - String get analyticsConsentDialogTitle => '帮助改进 Collection Tracker'; + String get analyticsConsentDialogTitle => '帮助改进 Collectra'; @override String get analyticsConsentDialogMessage => '我们可以收集匿名使用分析以改进应用质量和功能吗?你可以随时在设置中更改。'; @@ -214,6 +220,57 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settingsFirebaseRuntimeConfigSubtitle => '查看并刷新运行时功能开关'; + @override + String get settingsMetadataTitle => '元数据与自动填充'; + + @override + String get settingsMetadataSummaryEnabled => '已启用(自动条码查询)'; + + @override + String get settingsMetadataSummaryManual => '已启用(手动查询)'; + + @override + String get settingsMetadataSummaryDisabled => '已禁用'; + + @override + String get settingsMetadataSummaryFeatureDisabled => '已被运行时功能开关禁用'; + + @override + String get settingsMetadataEnableToggleTitle => '启用元数据辅助'; + + @override + String get settingsMetadataEnableToggleSubtitle => '在条目表单中允许元数据搜索和基于条码的自动填充。'; + + @override + String get settingsMetadataAutoFetchToggleTitle => '扫描条码后自动获取'; + + @override + String get settingsMetadataAutoFetchToggleSubtitle => '扫描条码后自动获取元数据。'; + + @override + String get settingsMetadataFillEmptyOnlyToggleTitle => '仅填充空字段'; + + @override + String get settingsMetadataFillEmptyOnlyToggleSubtitle => '找到元数据时不覆盖现有标题或描述。'; + + @override + String get settingsMetadataSourcesSectionTitle => '数据源'; + + @override + String get settingsMetadataSourceAvailable => '可用'; + + @override + String get settingsMetadataSourceNotConfigured => '未配置'; + + @override + String get settingsMetadataSourceManualOnly => '仅手动'; + + @override + String get settingsMetadataManualCollectionsLabel => '漫画、音乐和自定义'; + + @override + String get settingsMetadataFeatureDisabledMessage => '元数据辅助已被运行时配置禁用。'; + @override String get settingsFirebaseRuntimeConfigSheetTitle => 'Firebase 运行时配置'; @@ -290,7 +347,7 @@ class AppLocalizationsZh extends AppLocalizations { String get settingsImportDataTitle => '导入数据'; @override - String get settingsImportDataMessage => '这将从 JSON 文件导入集合和条目。现有数据不会被删除。\\n\\n是否继续?'; + String get settingsImportDataMessage => '这将从 JSON 文件导入集合和条目。现有数据不会被删除。\n\n是否继续?'; @override String get settingsImportingData => '正在导入数据...'; @@ -745,6 +802,17 @@ class AppLocalizationsZh extends AppLocalizations { @override String get metadataSearchSuggestionMessage => '开始输入以查找元数据。'; + @override + String get metadataSearchDisabledHint => '此集合类型不支持元数据搜索,或当前已禁用。'; + + @override + String get metadataNoMatchForBarcode => '未找到与此条码匹配的元数据。'; + + @override + String metadataSearchUnavailableForType(String collectionType) { + return '$collectionType 不支持元数据搜索。'; + } + @override String tagItemsTitle(String tag) { return '标签:$tag'; @@ -878,7 +946,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String tagManagementDeleteSelectedMessage(int count) { - return '要从所有条目中删除所选的 $count 个标签吗?\\n\\n此操作无法撤销。'; + return '要从所有条目中删除所选的 $count 个标签吗?\n\n此操作无法撤销。'; } @override @@ -899,7 +967,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String tagManagementDeleteMessage(String tagName) { - return '要从所有条目中删除“$tagName”吗?\\n\\n此操作无法撤销。'; + return '要从所有条目中删除“$tagName”吗?\n\n此操作无法撤销。'; } @override @@ -1064,4 +1132,350 @@ class AppLocalizationsZh extends AppLocalizations { @override String get collectionTypeCustom => '自定义'; + + @override + String get loanTrackingTitle => '借出追踪'; + + @override + String get loanTrackingNewLoan => '新建借出'; + + @override + String get loanTrackingFilterActive => '进行中'; + + @override + String get loanTrackingFilterHistory => '历史'; + + @override + String get loanTrackingEmptyHistoryTitle => '暂无已归还借出'; + + @override + String get loanTrackingEmptyHistoryMessage => '已归还条目会显示在这里。'; + + @override + String get loanTrackingEmptyActiveTitle => '暂无进行中借出'; + + @override + String get loanTrackingEmptyActiveMessage => '创建一条借出记录来跟踪借出物品。'; + + @override + String get loanTrackingLoadingLoans => '正在加载借出记录...'; + + @override + String loanTrackingLoadFailed(String error) { + return '加载借出记录失败:$error'; + } + + @override + String get loanTrackingMarkReturnedConfirmTitle => '标记为已归还?'; + + @override + String loanTrackingMarkReturnedConfirmMessage(String itemTitle) { + return '确认「$itemTitle」已归还。'; + } + + @override + String get loanTrackingMarkReturnedAction => '标记已归还'; + + @override + String get loanTrackingMarkedReturnedSuccess => '借出已标记为归还。'; + + @override + String loanTrackingMarkReturnedFailed(String error) { + return '标记归还失败:$error'; + } + + @override + String get loanTrackingDeleteConfirmTitle => '删除借出记录?'; + + @override + String loanTrackingDeleteConfirmMessage(String itemTitle) { + return '删除「$itemTitle」的借出记录。'; + } + + @override + String get loanTrackingDeleteSuccess => '借出记录已删除。'; + + @override + String loanTrackingDeleteFailed(String error) { + return '删除借出记录失败:$error'; + } + + @override + String get loanTrackingSummaryActiveLabel => '进行中借出'; + + @override + String get loanTrackingSummaryOverdueLabel => '逾期'; + + @override + String get loanTrackingSummaryLoadFailed => '无法加载借出摘要。'; + + @override + String get loanTrackingFieldBorrower => '借用人'; + + @override + String get loanTrackingFieldContact => '联系方式'; + + @override + String get loanTrackingFieldLoaned => '借出日期'; + + @override + String get loanTrackingFieldDue => '到期日期'; + + @override + String get loanTrackingFieldReturned => '归还日期'; + + @override + String get loanTrackingStatusReturned => '已归还'; + + @override + String get loanTrackingStatusOverdue => '逾期'; + + @override + String get loanTrackingStatusActive => '进行中'; + + @override + String get loanTrackingCreateTitle => '创建借出'; + + @override + String get loanTrackingCreateDescription => '记录谁借走了物品以及应归还时间。'; + + @override + String get loanTrackingCreateNoItemsTitle => '暂无可借出条目'; + + @override + String get loanTrackingCreateNoItemsMessage => '所有条目都在借出中,或还没有条目。'; + + @override + String get loanTrackingCreateItemLabel => '条目'; + + @override + String get loanTrackingCreateBorrowerLabel => '借用人姓名'; + + @override + String get loanTrackingCreateBorrowerHint => '例如:张三'; + + @override + String get loanTrackingCreateContactLabel => '联系方式(可选)'; + + @override + String get loanTrackingCreateContactHint => '电话、邮箱或 @username'; + + @override + String get loanTrackingCreateNotesLabel => '备注(可选)'; + + @override + String get loanTrackingCreateNotesHint => '这次借出的额外说明'; + + @override + String get loanTrackingCreateSubmitting => '创建中...'; + + @override + String get loanTrackingCreateAction => '创建借出'; + + @override + String get loanTrackingLoadingItems => '正在加载条目...'; + + @override + String loanTrackingLoadItemsFailed(String error) { + return '加载条目失败:$error'; + } + + @override + String get loanTrackingBorrowerRequired => '借用人姓名不能为空。'; + + @override + String get loanTrackingCreateSuccess => '借出创建成功。'; + + @override + String loanTrackingCreateFailed(String error) { + return '创建借出失败:$error'; + } + + @override + String get loanTrackingNoDueDate => '无到期日期'; + + @override + String get loanTrackingPickDateAction => '选择'; + + @override + String get loanTrackingClearDateAction => '清除'; + + @override + String get loanTrackingDueDateLabel => '到期日期'; + + @override + String get authTitleAccount => '账户'; + + @override + String get authCreateAccountHeading => '创建账户'; + + @override + String get authSignInHeading => '登录'; + + @override + String get authCreateAccountDescription => '创建账户以在多台设备间同步你的收藏。'; + + @override + String get authSignInDescription => '登录以启用云同步和账户功能。'; + + @override + String get authSignInChoice => '登录'; + + @override + String get authRegisterChoice => '注册'; + + @override + String get authEmailLabel => '邮箱'; + + @override + String get authEmailHint => 'you@example.com'; + + @override + String get authEmailRequiredError => '邮箱为必填项。'; + + @override + String get authEmailInvalidError => '请输入有效的邮箱地址。'; + + @override + String get authPasswordLabel => '密码'; + + @override + String get authPasswordHint => '至少8位,A-Z、a-z、0-9'; + + @override + String get authPasswordRequiredError => '密码为必填项。'; + + @override + String get authPasswordLengthError => '密码至少需要8位字符。'; + + @override + String get authPasswordPolicyError => '密码必须包含大写字母、小写字母和数字。'; + + @override + String get authDisplayNameLabel => '显示名称(可选)'; + + @override + String get authDisplayNameHint => '我们应该如何称呼你?'; + + @override + String get authCreateAccountAction => '创建账户'; + + @override + String get authNotNowAction => '暂不'; + + @override + String get authUnavailableMessage => '当前无法使用身份验证。'; + + @override + String get authRegisterSuccess => '账户已创建并已登录。'; + + @override + String get authSignInSuccess => '登录成功。'; + + @override + String authSignInFailed(String error) { + return '登录失败:$error'; + } + + @override + String get authSignedOut => '已退出登录。'; + + @override + String get authFinalConfirmationTitle => '最终确认'; + + @override + String get authFinalConfirmationMessage => '现在提交账户删除请求吗?你将立即在此设备上退出登录。'; + + @override + String get authBackAction => '返回'; + + @override + String get authSubmitRequestAction => '提交请求'; + + @override + String get authDeletionRequestSubmitted => '账户删除请求已提交。你已退出登录。'; + + @override + String get authDeletionEndpointMissing => '后端尚未配置删除请求接口。'; + + @override + String get authDeletionImpactDialogTitle => '请求删除账户前'; + + @override + String get authDeletionImpactReviewPrompt => '请仔细确认影响。'; + + @override + String get authIrreversibleRequestTitle => '不可逆请求'; + + @override + String get authImpactLineSessionRevoked => '提交请求后,你的账户会话将立即失效。'; + + @override + String get authImpactLineCloudDataDeleted => '与该账户关联的云端同步数据在处理过程中可能被永久删除。'; + + @override + String get authImpactLineCannotRestore => '账户数据一旦删除并处理完成将无法恢复。'; + + @override + String get authUnderstandAction => '我已了解'; + + @override + String get authPasswordPolicySuffix => '请使用英文键盘字母和数字(A-Z、a-z、0-9)。'; + + @override + String get authAccountConnected => '账户已连接'; + + @override + String get authSignedInReadySubtitle => '已登录并可进行云同步'; + + @override + String get authActiveStatus => '已激活'; + + @override + String get authSessionDetailsTitle => '会话详情'; + + @override + String get authUserIdLabel => '用户 ID'; + + @override + String get authDeviceIdLabel => '设备 ID'; + + @override + String get authUnknownValue => '未知'; + + @override + String get authDeletionNoticeTitle => '账户删除提示'; + + @override + String get authDeletionNoticeSubtitle => '删除请求一旦处理即不可撤销。'; + + @override + String get authDeletionNoticeLineProfileSessions => '账户资料和活跃会话将从云端访问中移除。'; + + @override + String get authDeletionNoticeLineSyncedData => '已同步的收藏、条目、标签和借出记录可能被永久删除。'; + + @override + String get authRequestDeletionAction => '请求删除账户'; + + @override + String get authSignOutAction => '退出登录'; + + @override + String get authDoneAction => '完成'; + + @override + String get authHeaderCreateTitle => '创建你的账户'; + + @override + String get authHeaderWelcomeTitle => '欢迎回来'; + + @override + String get authHeaderCreateSubtitle => '账户不是必需的,但云同步和多设备访问需要登录账户。'; + + @override + String get authHeaderSignInSubtitle => '登录以使用云同步和账户相关功能。'; + + @override + String get authUnavailableTitle => '身份验证不可用'; } diff --git a/apps/mobile/lib/main.dart b/apps/mobile/lib/main.dart index be4b173..b6e7b16 100644 --- a/apps/mobile/lib/main.dart +++ b/apps/mobile/lib/main.dart @@ -1,5 +1,6 @@ import 'package:collection_tracker/app.dart'; import 'package:collection_tracker/core/bootstrap/app_bootstrap.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:collection_tracker/core/observers/riverpod_logger.dart'; @@ -21,7 +22,7 @@ void main() async { (ref) => bootstrapData.firebaseRuntimeConfig, ), ], - observers: [RiverpodLogger()], + observers: [if (kDebugMode) RiverpodLogger()], child: const CollectionTrackerApp(), ), ); diff --git a/apps/mobile/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/mobile/macos/Flutter/GeneratedPluginRegistrant.swift index 1c09eb2..8973948 100644 --- a/apps/mobile/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/mobile/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,6 +7,7 @@ import Foundation import amplitude_flutter import connectivity_plus +import device_info_plus import file_picker import file_selector_macos import firebase_analytics @@ -24,10 +25,12 @@ import share_plus import shared_preferences_foundation import sqflite_darwin import sqlite3_flutter_libs +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AmplitudeFlutterPlugin.register(with: registry.registrar(forPlugin: "AmplitudeFlutterPlugin")) ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FirebaseAnalyticsPlugin")) @@ -45,4 +48,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/apps/mobile/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apps/mobile/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..799b722 --- /dev/null +++ b/apps/mobile/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,139 @@ +{ + "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", + "version" : "11.2.0" + } + }, + { + "identity" : "csqlite", + "kind" : "remoteSourceControl", + "location" : "https://github.com/simolus3/CSQLite.git", + "state" : { + "revision" : "ae972b235e8b3c5af6d8f4e5bf18c800bdddb27e" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk", + "state" : { + "revision" : "674d9a7ee9858207181a3dd0b42c77298c6fb71b", + "version" : "12.8.0" + } + }, + { + "identity" : "flutterfire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/flutterfire", + "state" : { + "revision" : "05731e3fb091093546db363e379bff166f7286a3", + "version" : "4.4.0-firebase-core-swift" + } + }, + { + "identity" : "google-ads-on-device-conversion-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", + "state" : { + "revision" : "35b601a60fbbea2de3ea461f604deaaa4d8bbd0c", + "version" : "3.2.0" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "2ffd220823f3716904733162e9ae685545c276d1", + "version" : "12.8.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", + "version" : "10.1.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", + "version" : "8.1.0" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", + "version" : "1.69.1" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "fb7f2740b1570d2f7599c6bb9531bf4fad6974b7", + "version" : "5.0.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", + "version" : "2.30910.0" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", + "version" : "2.4.0" + } + } + ], + "version" : 2 +} diff --git a/apps/mobile/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apps/mobile/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..799b722 --- /dev/null +++ b/apps/mobile/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,139 @@ +{ + "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", + "version" : "11.2.0" + } + }, + { + "identity" : "csqlite", + "kind" : "remoteSourceControl", + "location" : "https://github.com/simolus3/CSQLite.git", + "state" : { + "revision" : "ae972b235e8b3c5af6d8f4e5bf18c800bdddb27e" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk", + "state" : { + "revision" : "674d9a7ee9858207181a3dd0b42c77298c6fb71b", + "version" : "12.8.0" + } + }, + { + "identity" : "flutterfire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/flutterfire", + "state" : { + "revision" : "05731e3fb091093546db363e379bff166f7286a3", + "version" : "4.4.0-firebase-core-swift" + } + }, + { + "identity" : "google-ads-on-device-conversion-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", + "state" : { + "revision" : "35b601a60fbbea2de3ea461f604deaaa4d8bbd0c", + "version" : "3.2.0" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "2ffd220823f3716904733162e9ae685545c276d1", + "version" : "12.8.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", + "version" : "10.1.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", + "version" : "8.1.0" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", + "version" : "1.69.1" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "fb7f2740b1570d2f7599c6bb9531bf4fad6974b7", + "version" : "5.0.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", + "version" : "2.30910.0" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", + "version" : "2.4.0" + } + } + ], + "version" : 2 +} diff --git a/apps/mobile/pubspec.yaml b/apps/mobile/pubspec.yaml index d652bb1..3eccb7b 100644 --- a/apps/mobile/pubspec.yaml +++ b/apps/mobile/pubspec.yaml @@ -1,7 +1,7 @@ name: collection_tracker description: "Collection tracking mobile app" publish_to: 'none' -version: 1.0.0+1 +version: 1.0.1+2 resolution: workspace environment: @@ -29,6 +29,9 @@ dependencies: # Utilities intl: ^0.20.2 + native_id: ^1.0.0 + package_info_plus: ^9.0.0 + url_launcher: ^6.3.2 uuid: ^4.5.2 # Firebase diff --git a/apps/mobile/test/core/sync/sync_resilience_test.dart b/apps/mobile/test/core/sync/sync_resilience_test.dart index 18df5bd..a4f77e2 100644 --- a/apps/mobile/test/core/sync/sync_resilience_test.dart +++ b/apps/mobile/test/core/sync/sync_resilience_test.dart @@ -74,6 +74,7 @@ void main() { syncedCollections: 1, syncedItems: 0, syncedTags: 0, + syncedLoans: 0, conflictsResolved: 0, ), ], @@ -225,6 +226,7 @@ class _SequenceBackendClient implements SyncBackendClient { maxBatchSize: 1000, conflictStrategy: 'last_write_wins', acceptedSchemaVersions: ['v1'], + supportedEntities: ['collection', 'item', 'tag'], ); } diff --git a/apps/mobile/tool/generate_brand_assets.swift b/apps/mobile/tool/generate_brand_assets.swift new file mode 100644 index 0000000..618d516 --- /dev/null +++ b/apps/mobile/tool/generate_brand_assets.swift @@ -0,0 +1,426 @@ +import AppKit + +struct Palette { + static let lightTop = NSColor(calibratedRed: 0.10, green: 0.57, blue: 0.90, alpha: 1.0) + static let lightBottom = NSColor(calibratedRed: 0.19, green: 0.34, blue: 0.82, alpha: 1.0) + static let darkTop = NSColor(calibratedRed: 0.08, green: 0.24, blue: 0.50, alpha: 1.0) + static let darkBottom = NSColor(calibratedRed: 0.05, green: 0.16, blue: 0.37, alpha: 1.0) + + static let orbTop = NSColor(calibratedRed: 0.54, green: 0.72, blue: 0.91, alpha: 0.18) + static let orbBottom = NSColor(calibratedRed: 0.57, green: 0.79, blue: 0.97, alpha: 0.16) + + static let frame = NSColor(calibratedRed: 0.82, green: 0.85, blue: 0.89, alpha: 1.0) + static let frameDark = NSColor(calibratedRed: 0.74, green: 0.78, blue: 0.84, alpha: 1.0) + static let detail = NSColor(calibratedRed: 0.67, green: 0.75, blue: 0.86, alpha: 1.0) + static let detailDark = NSColor(calibratedRed: 0.59, green: 0.68, blue: 0.79, alpha: 1.0) + + static let featureChip = NSColor(calibratedRed: 0.35, green: 0.54, blue: 0.83, alpha: 0.42) + static let featureText = NSColor(calibratedRed: 0.92, green: 0.96, blue: 1.0, alpha: 1.0) + static let featureSubText = NSColor(calibratedRed: 0.80, green: 0.89, blue: 0.98, alpha: 1.0) +} + +func roundedRect(_ rect: NSRect, radius: CGFloat) -> NSBezierPath { + NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius) +} + +func drawImage(width: Int, height: Int, _ draw: (_ rect: NSRect, _ ctx: CGContext) -> Void) -> NSImage { + let image = NSImage(size: NSSize(width: width, height: height)) + image.lockFocus() + defer { image.unlockFocus() } + + guard let ctx = NSGraphicsContext.current?.cgContext else { + fatalError("Unable to create graphics context.") + } + draw(NSRect(x: 0, y: 0, width: width, height: height), ctx) + return image +} + +func savePng(_ image: NSImage, to url: URL) throws { + guard + let tiff = image.tiffRepresentation, + let rep = NSBitmapImageRep(data: tiff), + let data = rep.representation(using: .png, properties: [:]) + else { + throw NSError( + domain: "BrandAssets", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "PNG encoding failed"] + ) + } + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try data.write(to: url) +} + +func drawBackground(in rect: NSRect, dark: Bool, cornerRadiusRatio: CGFloat = 0.22) { + let clip = roundedRect(rect, radius: rect.width * cornerRadiusRatio) + clip.addClip() + + let gradient = dark + ? NSGradient(colorsAndLocations: + (Palette.darkTop, 0.0), + (Palette.darkBottom, 1.0) + )! + : NSGradient(colorsAndLocations: + (Palette.lightTop, 0.0), + (Palette.lightBottom, 1.0) + )! + gradient.draw(in: clip, angle: -18) + + Palette.orbTop.setFill() + NSBezierPath( + ovalIn: NSRect( + x: rect.minX - rect.width * 0.22, + y: rect.minY + rect.height * 0.50, + width: rect.width * 0.78, + height: rect.height * 0.78 + ) + ).fill() + + Palette.orbBottom.setFill() + NSBezierPath( + ovalIn: NSRect( + x: rect.minX + rect.width * 0.62, + y: rect.minY - rect.height * 0.20, + width: rect.width * 0.56, + height: rect.height * 0.56 + ) + ).fill() +} + +func drawOpenBook( + in rect: NSRect, + stroke: NSColor, + lineWidth: CGFloat, + includeFingerDetails: Bool = true +) { + let cx = rect.midX + let topY = rect.minY + rect.height * 0.40 + let bottomY = rect.minY + rect.height * 0.74 + + let leftTop = NSPoint(x: rect.minX + rect.width * 0.28, y: rect.minY + rect.height * 0.35) + let leftOuter = NSPoint(x: rect.minX + rect.width * 0.20, y: bottomY) + let leftInnerBottom = NSPoint(x: cx - rect.width * 0.06, y: rect.minY + rect.height * 0.71) + + let rightTop = NSPoint(x: rect.maxX - rect.width * 0.28, y: rect.minY + rect.height * 0.35) + let rightOuter = NSPoint(x: rect.maxX - rect.width * 0.20, y: bottomY) + let rightInnerBottom = NSPoint(x: cx + rect.width * 0.06, y: rect.minY + rect.height * 0.71) + + let leftPage = NSBezierPath() + leftPage.move(to: NSPoint(x: cx - rect.width * 0.01, y: topY)) + leftPage.curve( + to: leftTop, + controlPoint1: NSPoint(x: cx - rect.width * 0.10, y: rect.minY + rect.height * 0.30), + controlPoint2: NSPoint(x: leftTop.x + rect.width * 0.06, y: leftTop.y) + ) + leftPage.line(to: leftOuter) + leftPage.curve( + to: leftInnerBottom, + controlPoint1: NSPoint(x: leftOuter.x + rect.width * 0.10, y: rect.minY + rect.height * 0.66), + controlPoint2: NSPoint(x: leftInnerBottom.x - rect.width * 0.06, y: leftInnerBottom.y) + ) + + let rightPage = NSBezierPath() + rightPage.move(to: NSPoint(x: cx + rect.width * 0.01, y: topY)) + rightPage.curve( + to: rightTop, + controlPoint1: NSPoint(x: cx + rect.width * 0.10, y: rect.minY + rect.height * 0.30), + controlPoint2: NSPoint(x: rightTop.x - rect.width * 0.06, y: rightTop.y) + ) + rightPage.line(to: rightOuter) + rightPage.curve( + to: rightInnerBottom, + controlPoint1: NSPoint(x: rightOuter.x - rect.width * 0.10, y: rect.minY + rect.height * 0.66), + controlPoint2: NSPoint(x: rightInnerBottom.x + rect.width * 0.06, y: rightInnerBottom.y) + ) + + let topLeftLeaf = NSBezierPath() + topLeftLeaf.move(to: NSPoint(x: rect.minX + rect.width * 0.25, y: rect.minY + rect.height * 0.30)) + topLeftLeaf.curve( + to: NSPoint(x: cx - rect.width * 0.01, y: rect.minY + rect.height * 0.39), + controlPoint1: NSPoint(x: rect.minX + rect.width * 0.36, y: rect.minY + rect.height * 0.27), + controlPoint2: NSPoint(x: cx - rect.width * 0.10, y: rect.minY + rect.height * 0.35) + ) + + let topRightLeaf = NSBezierPath() + topRightLeaf.move(to: NSPoint(x: rect.maxX - rect.width * 0.25, y: rect.minY + rect.height * 0.30)) + topRightLeaf.curve( + to: NSPoint(x: cx + rect.width * 0.01, y: rect.minY + rect.height * 0.39), + controlPoint1: NSPoint(x: rect.maxX - rect.width * 0.36, y: rect.minY + rect.height * 0.27), + controlPoint2: NSPoint(x: cx + rect.width * 0.10, y: rect.minY + rect.height * 0.35) + ) + + let spine = NSBezierPath() + spine.move(to: NSPoint(x: cx, y: rect.minY + rect.height * 0.39)) + spine.line(to: NSPoint(x: cx, y: rect.minY + rect.height * 0.75)) + + stroke.setStroke() + var paths = [leftPage, rightPage, topLeftLeaf, topRightLeaf, spine] + if includeFingerDetails { + let leftFinger = NSBezierPath() + leftFinger.move(to: NSPoint(x: rect.minX + rect.width * 0.13, y: rect.minY + rect.height * 0.48)) + leftFinger.line(to: NSPoint(x: rect.minX + rect.width * 0.22, y: rect.minY + rect.height * 0.50)) + leftFinger.move(to: NSPoint(x: rect.minX + rect.width * 0.13, y: rect.minY + rect.height * 0.52)) + leftFinger.line(to: NSPoint(x: rect.minX + rect.width * 0.22, y: rect.minY + rect.height * 0.54)) + leftFinger.move(to: NSPoint(x: rect.minX + rect.width * 0.13, y: rect.minY + rect.height * 0.56)) + leftFinger.line(to: NSPoint(x: rect.minX + rect.width * 0.22, y: rect.minY + rect.height * 0.58)) + + let rightFinger = NSBezierPath() + rightFinger.move(to: NSPoint(x: rect.maxX - rect.width * 0.13, y: rect.minY + rect.height * 0.48)) + rightFinger.line(to: NSPoint(x: rect.maxX - rect.width * 0.22, y: rect.minY + rect.height * 0.50)) + rightFinger.move(to: NSPoint(x: rect.maxX - rect.width * 0.13, y: rect.minY + rect.height * 0.52)) + rightFinger.line(to: NSPoint(x: rect.maxX - rect.width * 0.22, y: rect.minY + rect.height * 0.54)) + rightFinger.move(to: NSPoint(x: rect.maxX - rect.width * 0.13, y: rect.minY + rect.height * 0.56)) + rightFinger.line(to: NSPoint(x: rect.maxX - rect.width * 0.22, y: rect.minY + rect.height * 0.58)) + paths.append(leftFinger) + paths.append(rightFinger) + } + + for path in paths { + path.lineWidth = lineWidth + path.lineCapStyle = .round + path.lineJoinStyle = .round + path.stroke() + } +} + +func drawClock(in rect: NSRect, stroke: NSColor, lineWidth: CGFloat) { + let circle = NSBezierPath(ovalIn: rect) + circle.lineWidth = lineWidth + stroke.setStroke() + circle.stroke() + + let cx = rect.midX + let cy = rect.midY + + let hands = NSBezierPath() + hands.lineWidth = lineWidth + hands.lineCapStyle = .round + hands.move(to: NSPoint(x: cx, y: cy)) + hands.line(to: NSPoint(x: cx, y: rect.maxY - rect.height * 0.24)) + hands.move(to: NSPoint(x: cx, y: cy)) + hands.line(to: NSPoint(x: rect.maxX - rect.width * 0.28, y: cy)) + hands.stroke() +} + +func drawWingedBookTimeMark( + in rect: NSRect, + stroke: NSColor, + includeRing: Bool, + lineWidthScale: CGFloat = 1.0 +) { + let emblemRect = includeRing ? rect.insetBy(dx: rect.width * 0.08, dy: rect.height * 0.08) : rect + if includeRing { + let ring = NSBezierPath(ovalIn: emblemRect) + ring.lineWidth = emblemRect.width * 0.028 * lineWidthScale + ring.lineJoinStyle = .round + stroke.setStroke() + ring.stroke() + } + + let symbolRect = includeRing + ? NSRect( + x: emblemRect.minX + emblemRect.width * 0.12, + y: emblemRect.minY + emblemRect.height * 0.12, + width: emblemRect.width * 0.76, + height: emblemRect.height * 0.70 + ) + : NSRect( + x: emblemRect.minX + emblemRect.width * 0.08, + y: emblemRect.minY + emblemRect.height * 0.08, + width: emblemRect.width * 0.84, + height: emblemRect.height * 0.76 + ) + + drawOpenBook( + in: symbolRect, + stroke: stroke, + lineWidth: emblemRect.width * 0.014 * lineWidthScale, + includeFingerDetails: true + ) + + let clockRect = NSRect( + x: symbolRect.midX - symbolRect.width * 0.10, + y: symbolRect.minY + symbolRect.height * 0.10, + width: symbolRect.width * 0.20, + height: symbolRect.width * 0.20 + ) + drawClock( + in: clockRect, + stroke: stroke, + lineWidth: emblemRect.width * 0.012 * lineWidthScale + ) +} + +func drawBrandMark(in rect: NSRect, dark: Bool) { + let stroke = dark ? Palette.frameDark : Palette.frame + drawWingedBookTimeMark( + in: rect, + stroke: stroke, + includeRing: false + ) +} + +func drawCenteredText( + _ text: String, + in rect: NSRect, + size: CGFloat, + weight: NSFont.Weight, + color: NSColor +) { + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = .center + let attributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: size, weight: weight), + .foregroundColor: color, + .paragraphStyle: paragraph, + .kern: 0.2, + ] + NSAttributedString(string: text, attributes: attributes).draw(in: rect) +} + +func drawTextExactlyCentered( + _ text: String, + in rect: NSRect, + size: CGFloat, + weight: NSFont.Weight, + color: NSColor +) { + let attributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: size, weight: weight), + .foregroundColor: color, + .kern: 0.0, + ] + let nsText = text as NSString + let textSize = nsText.size(withAttributes: attributes) + let drawPoint = NSPoint( + x: rect.midX - textSize.width * 0.5, + y: rect.midY - textSize.height * 0.5 + ) + nsText.draw(at: drawPoint, withAttributes: attributes) +} + +func projectRoot(from scriptPath: String) -> URL { + var url = URL(fileURLWithPath: scriptPath).deletingLastPathComponent() + // tool -> mobile -> apps -> workspace root + url.deleteLastPathComponent() + url.deleteLastPathComponent() + url.deleteLastPathComponent() + return url +} + +let root = projectRoot(from: #filePath) +let appDir = root.appendingPathComponent("apps/mobile") + +let iconLightURL = appDir.appendingPathComponent("assets/icons/logo_light.png") +let iconDarkURL = appDir.appendingPathComponent("assets/icons/logo_dark.png") +let iconBackgroundURL = appDir.appendingPathComponent("assets/icons/logo_background.png") +let iconForegroundURL = appDir.appendingPathComponent("assets/icons/logo_foreground.png") +let featureURL = appDir.appendingPathComponent("assets/branding/play_store_feature_graphic.png") + +let iconLight = drawImage(width: 1024, height: 1024) { rect, _ in + let cardRect = rect + drawBackground(in: cardRect, dark: false, cornerRadiusRatio: 0.30) + drawBrandMark( + in: cardRect.insetBy(dx: rect.width * 0.12, dy: rect.height * 0.12), + dark: false + ) +} + +let iconDark = drawImage(width: 1024, height: 1024) { rect, _ in + let cardRect = rect + drawBackground(in: cardRect, dark: true, cornerRadiusRatio: 0.30) + drawBrandMark( + in: cardRect.insetBy(dx: rect.width * 0.12, dy: rect.height * 0.12), + dark: true + ) +} + +let iconForeground = drawImage(width: 432, height: 432) { rect, _ in + drawWingedBookTimeMark( + in: rect.insetBy(dx: rect.width * 0.02, dy: rect.height * 0.02), + stroke: NSColor(calibratedRed: 0.93, green: 0.95, blue: 0.98, alpha: 1.0), + includeRing: false, + lineWidthScale: 1.08 + ) +} + +let iconBackground = drawImage(width: 432, height: 432) { rect, _ in + drawBackground(in: rect, dark: false, cornerRadiusRatio: 0.0) +} + +let featureGraphic = drawImage(width: 1024, height: 500) { rect, _ in + let clip = NSBezierPath(rect: rect) + clip.addClip() + + let gradient = NSGradient(colorsAndLocations: + (Palette.darkTop, 0.0), + (NSColor(calibratedRed: 0.18, green: 0.41, blue: 0.79, alpha: 1.0), 1.0) + )! + gradient.draw(in: clip, angle: -16) + + Palette.orbTop.setFill() + NSBezierPath(ovalIn: NSRect(x: -90, y: 210, width: 360, height: 360)).fill() + Palette.orbBottom.setFill() + NSBezierPath(ovalIn: NSRect(x: 690, y: -120, width: 420, height: 420)).fill() + + let iconRect = NSRect(x: 70, y: 40, width: 340, height: 340) + NSGraphicsContext.saveGraphicsState() + let iconClip = roundedRect(iconRect, radius: 56) + iconClip.addClip() + drawBackground(in: iconRect, dark: false) + drawBrandMark(in: iconRect, dark: false) + NSGraphicsContext.restoreGraphicsState() + + drawCenteredText( + "Collectra", + in: NSRect(x: 450, y: 274, width: 520, height: 84), + size: 68, + weight: .bold, + color: Palette.featureText + ) + drawCenteredText( + "Catalog your things with clarity and confidence.", + in: NSRect(x: 450, y: 212, width: 520, height: 52), + size: 20, + weight: .semibold, + color: Palette.featureSubText + ) + + let tags = [ + ("Catalog", NSRect(x: 490, y: 118, width: 140, height: 44)), + ("Track Value", NSRect(x: 642, y: 118, width: 148, height: 44)), + ("Sync Ready", NSRect(x: 802, y: 118, width: 146, height: 44)), + ] + + for (text, frame) in tags { + Palette.featureChip.setFill() + roundedRect(frame, radius: frame.height * 0.48).fill() + drawTextExactlyCentered( + text, + in: frame, + size: 20, + weight: .semibold, + color: Palette.featureText + ) + } +} + +do { + try savePng(iconLight, to: iconLightURL) + try savePng(iconDark, to: iconDarkURL) + try savePng(iconBackground, to: iconBackgroundURL) + try savePng(iconForeground, to: iconForegroundURL) + try savePng(featureGraphic, to: featureURL) + print("Generated brand assets:") + print("- \(iconLightURL.path)") + print("- \(iconDarkURL.path)") + print("- \(iconBackgroundURL.path)") + print("- \(iconForegroundURL.path)") + print("- \(featureURL.path)") +} catch { + fputs("Failed to generate assets: \(error)\n", stderr) + exit(1) +} diff --git a/apps/mobile/web/index.html b/apps/mobile/web/index.html index fc5fbeb..e0a8896 100644 --- a/apps/mobile/web/index.html +++ b/apps/mobile/web/index.html @@ -18,18 +18,18 @@ - + - + - collection_tracker + Collectra diff --git a/apps/mobile/web/manifest.json b/apps/mobile/web/manifest.json index 71694cf..e11b528 100644 --- a/apps/mobile/web/manifest.json +++ b/apps/mobile/web/manifest.json @@ -1,11 +1,11 @@ { - "name": "collection_tracker", - "short_name": "collection_tracker", + "name": "Collectra", + "short_name": "Collectra", "start_url": ".", "display": "standalone", "background_color": "#0175C2", "theme_color": "#0175C2", - "description": "A new Flutter project.", + "description": "Collectra collection tracking app.", "orientation": "portrait-primary", "prefer_related_applications": false, "icons": [ diff --git a/documentation/FIREBASE_AND_FLAGS.md b/documentation/FIREBASE_AND_FLAGS.md index 7cbe176..e8a8904 100644 --- a/documentation/FIREBASE_AND_FLAGS.md +++ b/documentation/FIREBASE_AND_FLAGS.md @@ -31,6 +31,19 @@ Runtime flags are read in `FirebaseServicesBootstrap`. | `app_backend_integration_enabled` | `false` | Gates backend-auth integration paths | | `app_auth_feature_enabled` | `true` | Gates account authentication UI/service availability | | `app_sync_feature_enabled` | `false` | Gates sync transport and sync UI readiness | +| `app_update_feature_enabled` | `true` | Enables/disables app update checks in runtime | +| `app_update_use_backend` | `true` | Prefer backend update policy endpoint before Remote Config fallback | +| `app_update_check_interval_hours` | `12` | Auto-check interval used by startup/resume checks | +| `app_update_snooze_hours` | `24` | Default snooze duration for optional update prompts | +| `app_update_force_mode` | `false` | Treat latest-version updates as required updates | +| `app_update_min_supported_android` | `""` | Minimum supported Android semantic version | +| `app_update_min_supported_ios` | `""` | Minimum supported iOS semantic version | +| `app_update_latest_android` | `""` | Latest recommended Android semantic version | +| `app_update_latest_ios` | `""` | Latest recommended iOS semantic version | +| `app_update_store_url_android` | `""` | Android store URL opened by update action | +| `app_update_store_url_ios` | `""` | iOS store URL opened by update action | +| `app_update_title` | `""` | Optional update prompt title | +| `app_update_message` | `""` | Optional update prompt message | ## 3. Why Cloud Sync Can Still Look Disabled diff --git a/documentation/PLAY_STORE_LISTING.md b/documentation/PLAY_STORE_LISTING.md new file mode 100644 index 0000000..4b16d34 --- /dev/null +++ b/documentation/PLAY_STORE_LISTING.md @@ -0,0 +1,159 @@ +# Play Store Listing Pack (Collectra) + +Ready-to-paste Google Play assets for launch and post-launch ASO updates. + +## 1) Core Store Metadata + +- App name (title, <= 30 chars): `Collectra: Collection Tracker` +- Title variant A/B test: `Collectra: Item Catalog` +- Short description (<= 80 chars): `Catalog collections, track value, tags, loans, and backups in one app.` +- Short description variant: `A clean collection tracker with tags, price history, and import/export.` +- Category (recommended): `Productivity` +- Secondary category option: `Lifestyle` +- App type now: `Free` +- Monetization roadmap: `Free + optional in-app subscriptions later` + +Google Play note: free apps cannot be converted to paid later. If subscriptions are planned, shipping as free is the correct path. + +## 2) Long Description (SEO-Optimized, Play Console Ready) + +Collectra is a modern collection tracker that helps you catalog what you own, organize it with structure, and monitor value over time. + +Use Collectra for books, movies, games, cards, figures, gadgets, and custom collections. Add detailed item records, apply tags, track prices, and keep your data portable with export/import. + +### Organize your collections + +- Create multiple collections with custom categories +- Add item details such as notes, condition, quantity, and location +- Organize with tags, favorites, and wishlist flows +- Browse using polished list and grid views + +### Track value and history + +- Store purchase price and current value +- Track price history and monitor changes +- View statistics and totals across your collection +- Keep better visibility on what your collection is worth + +### Manage real-life movement + +- Track loaned items and return status +- Keep ownership records clear and searchable +- Reduce loss and confusion in shared collections + +### Backup and portability + +- Export and import data using JSON and CSV +- Keep local backups for peace of mind +- Prepare data for future sync workflows + +### Built for daily use + +- Fast item entry, including barcode-assisted workflows +- Responsive UI, smooth motion, and clean navigation +- Multi-language support +- Optional account and sync features (feature-flag controlled) + +If you want a personal inventory app, item catalog, collection manager, and value tracker in one place, Collectra gives you a clean and professional workflow without unnecessary complexity. + +## 3) ASO Keyword Strategy + +Primary keywords: + +- collection tracker +- item catalog +- collection manager +- personal inventory +- inventory organizer + +Secondary keywords: + +- value tracker +- price history +- loan tracker +- barcode scanner +- backup export import + +Usage guidance: + +- Put primary keywords in title + short description. +- Reuse secondary keywords naturally in long description and release notes. +- Avoid keyword stuffing and avoid competitor brand names. + +## 4) Screenshot Copy Set + +Suggested subtitle/callout sequence: + +1. `All your collections, one organized home` +2. `Capture item details in seconds` +3. `Tag, filter, and find anything fast` +4. `Track purchase price and current value` +5. `Monitor loaned items and returns` +6. `See collection stats at a glance` +7. `Switch between list and grid views` +8. `Export and import your data anytime` + +## 5) Feature Graphic Copy + +- Primary: `Collectra` +- Secondary: `Catalog, track value, and stay organized.` + +Variant: + +- Primary: `Collectra` +- Secondary: `Your clean collection manager.` + +## 6) Play Console Setup Checklist + +- App category: Productivity +- Pricing: Free +- Content rating questionnaire: submit after final creatives are ready +- Data safety form: disclose analytics, crash diagnostics, performance telemetry, optional auth/sync +- Privacy policy URL: required before production rollout +- Support email and website: required +- App access instructions: provide test account only if reviewer needs gated features + +## 7) Release Notes Templates + +### Launch + +```text +Welcome to Collectra. + +What you can do: +- Organize collections and items +- Use tags, wishlist, and favorites +- Track price/value history +- Track loaned items +- Export/import JSON and CSV backups + +More sync and account enhancements are planned in upcoming updates. +``` + +### Ongoing update + +```text +What’s new in this update: +- Performance and stability improvements +- UI/UX polish across core screens +- Bug fixes and localization refinements +``` + +## 8) Subscription Positioning (Future) + +When subscription goes live, keep store positioning simple: + +- Free tier: local collection management + core tracking +- Premium tier: advanced cloud/sync/automation features + +Only mention premium features in Play listing after they are publicly available in production. + +## 9) Website SEO Metadata (Optional) + +Use this on your landing page and docs site: + +- SEO title: `Collectra - Collection Tracker and Value Manager` +- Meta description: `Collectra helps you catalog collections, track value, manage tags and loans, and keep backups with export/import.` +- OG title: `Collectra | Collection Tracker` +- OG description: `Organize your collections, track value history, and keep your data portable.` +- Suggested URL slug: `/collectra` diff --git a/documentation/PRIVACY_POLICY.md b/documentation/PRIVACY_POLICY.md new file mode 100644 index 0000000..7bf1954 --- /dev/null +++ b/documentation/PRIVACY_POLICY.md @@ -0,0 +1,158 @@ +--- +title: Privacy Policy +description: Privacy Policy for Collectra (Collection Tracker) +lastUpdated: 2026-02-23 +--- + +# Privacy Policy + +This Privacy Policy explains how **Collectra** ("the App") collects, uses, and protects information. + +Replace placeholder values before publishing: + +- `{{LEGAL_ENTITY_NAME}}` +- `{{SUPPORT_EMAIL}}` +- `{{WEBSITE_URL}}` +- `{{JURISDICTION}}` + +If you need legal certainty for your region and business model, have this policy reviewed by a qualified lawyer. + +## 1. Who We Are + +Collectra is provided by **{{LEGAL_ENTITY_NAME}}** ("we", "us", "our"). + +- Website: `{{WEBSITE_URL}}` +- Contact: `{{SUPPORT_EMAIL}}` + +## 2. Information We Collect + +### 2.1 Information you provide in the app + +- Collection and item data you create (for example: names, notes, tags, prices, loan records, metadata fields, and images you attach). +- Account information when you choose to sign in (for example: email and profile fields supported by backend auth). +- Data export/import files you choose to create or import. + +### 2.2 Information collected automatically + +Depending on settings and feature flags, the app may process: + +- Device and app diagnostics (Crashlytics crash data and device/app technical metadata). +- App performance telemetry (Firebase Performance traces). +- Usage analytics events (Firebase Analytics), only when enabled by consent/settings. +- Push messaging tokens and delivery metadata (Firebase Cloud Messaging), only when notifications are enabled. +- Runtime configuration state (Firebase Remote Config values used to enable/disable app features). + +### 2.3 Information not collected by default + +- The app can be used offline without mandatory account registration. +- Cloud sync and backend-connected features are optional and can be disabled by runtime feature flags. + +## 3. How We Use Information + +We use data to: + +- Provide core app features (collection management, value tracking, tag management, loan tracking, import/export). +- Keep app functionality reliable and secure. +- Diagnose crashes and improve app performance. +- Understand feature usage (if analytics is enabled). +- Deliver push notifications that you opt into. +- Support optional authenticated features such as cloud sync. + +## 4. Legal Bases (where applicable) + +Depending on your location, we rely on one or more legal bases: + +- Consent (for analytics and notifications where required). +- Performance of a contract (to provide app functionality you request). +- Legitimate interests (service reliability, fraud prevention, and product improvement). +- Legal obligations (if disclosure is required by law). + +## 5. Third-Party Services and Processors + +Collectra may use: + +- **Firebase Analytics** +- **Firebase Crashlytics** +- **Firebase Performance Monitoring** +- **Firebase Cloud Messaging** +- **Firebase Remote Config** +- Optional custom backend APIs for authentication and cloud sync + +These providers may process data according to their own policies and data processing terms. + +## 6. Data Sharing + +We do not sell personal data. + +We may share data only: + +- With service providers/processors that operate app infrastructure. +- When required by law, regulation, subpoena, or legal process. +- To protect rights, safety, security, and prevent abuse. +- As part of a business transfer (for example merger/acquisition), with appropriate safeguards. + +## 7. Data Storage and Retention + +- Local app data is stored on your device until deleted by you or removed by uninstall. +- Account/session data may be stored in secure storage on device. +- Diagnostics/analytics/performance data retention depends on provider settings (for example Firebase retention policies). +- Backend-synced data (if enabled) is retained according to backend policies. + +## 8. Your Choices and Controls + +You can: + +- Use the app without signing in for non-auth features. +- Enable or disable analytics collection in app settings. +- Enable or disable notification permissions at OS/app level. +- Export your data for backup. +- Request account-related data deletion where account features are used. + +## 9. Account Deletion and Data Deletion Requests + +If you use account features, you can submit an account deletion request from the app (if implemented) or by contacting **{{SUPPORT_EMAIL}}**. + +Deletion request notes: + +- Some data may be retained temporarily for legal, fraud-prevention, or operational reasons. +- Backups may persist for a limited retention window. +- After deletion is complete, account-linked cloud data generally cannot be restored. + +## 10. International Data Transfers + +Your data may be processed in countries other than your own. Where required, we apply safeguards such as contractual protections for cross-border transfer. + +## 11. Children’s Privacy + +Collectra is not directed to children under 13 (or the minimum age in your jurisdiction). If you believe a child submitted personal data, contact **{{SUPPORT_EMAIL}}** to request removal. + +## 12. Security + +We use reasonable technical and organizational measures to protect data. No method of storage or transmission is 100% secure, and absolute security cannot be guaranteed. + +## 13. Your Privacy Rights + +Depending on applicable law (for example GDPR/CCPA equivalents), you may have rights to: + +- Access data +- Correct inaccurate data +- Delete data +- Restrict or object to processing +- Data portability +- Withdraw consent (where processing is consent-based) + +To exercise rights, contact **{{SUPPORT_EMAIL}}**. + +## 14. Changes to This Policy + +We may update this Privacy Policy from time to time. We will update the "lastUpdated" date and may provide additional notice where required. + +## 15. Contact + +For privacy questions or requests: + +- Email: **{{SUPPORT_EMAIL}}** +- Website: **{{WEBSITE_URL}}** +- Entity: **{{LEGAL_ENTITY_NAME}}** +- Jurisdiction: **{{JURISDICTION}}** + diff --git a/documentation/README.md b/documentation/README.md index c9dc4b6..ca9c3b9 100644 --- a/documentation/README.md +++ b/documentation/README.md @@ -21,3 +21,12 @@ This folder contains up-to-date technical documentation for the current state of - [documentation/LOCALIZATION.md](LOCALIZATION.md) Supported languages, localization workflow, and remaining gaps. + +- [documentation/PLAY_STORE_LISTING.md](PLAY_STORE_LISTING.md) + Play Store metadata pack, ASO copy, launch checklist, and release-note templates. + +- [documentation/RELEASE_CHECKLIST.md](RELEASE_CHECKLIST.md) + Tailored Play Store release checklist based on current repository state and deployment flow. + +- [documentation/PRIVACY_POLICY.md](PRIVACY_POLICY.md) + MDX-ready privacy policy template aligned with current app integrations and feature flags. diff --git a/documentation/RELEASE_CHECKLIST.md b/documentation/RELEASE_CHECKLIST.md new file mode 100644 index 0000000..528b3e2 --- /dev/null +++ b/documentation/RELEASE_CHECKLIST.md @@ -0,0 +1,98 @@ +# Play Store Release Checklist (Tailored) + +Last updated: 2026-02-23 + +## Current App Snapshot (From Repo) + +- App name: `Collectra` +- Android applicationId / package: `dev.mixin27.collection_tracker` +- Current app version: `1.0.1+2` +- Android icon entry: `@mipmap/launcher_icon` +- Android adaptive icon background: image asset (`@drawable/ic_launcher_background`) +- Firebase plugins enabled: Google Services + Crashlytics +- Release signing config: present in Android Gradle (uses `android/key.properties`) +- Privacy policy draft: present at `documentation/PRIVACY_POLICY.md` (placeholders still need replacement) +- Store listing draft: present at `documentation/PLAY_STORE_LISTING.md` + +## Release Readiness Checklist + +- [x] App ID is stable: `dev.mixin27.collection_tracker`. +- [x] Release signing config is wired in `apps/mobile/android/app/build.gradle.kts`. +- [x] Keystore files exist locally (`apps/mobile/android/collection-tracker-keystore.jks`, `apps/mobile/android/key.properties`). +- [x] App icon + adaptive icon assets are generated and committed from brand pipeline. +- [x] Feature graphic exists at `apps/mobile/assets/branding/play_store_feature_graphic.png`. +- [x] Crashlytics plugin is integrated in Android build. +- [x] Privacy policy markdown exists. +- [x] Play Store metadata draft exists. +- [x] Account deletion request flow exists in auth UI/backend service. +- Replace placeholders in `documentation/PRIVACY_POLICY.md`: +- [ ] `{{LEGAL_ENTITY_NAME}}` +- [ ] `{{SUPPORT_EMAIL}}` +- [ ] `{{WEBSITE_URL}}` +- [ ] `{{JURISDICTION}}` +- [ ] Publish privacy policy to a public HTTPS URL and add it in Play Console. +- [ ] Confirm support email + website are set in Play Console listing. +- [ ] Complete Data safety form to match actual behavior and permissions. +- [ ] Complete Content rating questionnaire. +- [ ] Confirm app category and tags in Play Console (recommended: Productivity). +- [ ] Upload screenshots and verify they match latest UI. +- [ ] Run pre-release QA on physical Android devices (small + large screens). +- [ ] Build and upload signed `.aab` to Internal testing first. +- [ ] Review Pre-launch report issues and resolve blockers. +- [ ] Roll out to production using staged rollout. + +## Policy-Focused Checks + +- [ ] Verify target SDK requirement for current Play deadline (new uploads/updates). +- [ ] Verify native dependency compatibility with Android 16 KB page size requirement. +- Confirm account deletion request path is available to reviewers: +- [ ] In-app navigation path +- [ ] Alternate support contact path +- [ ] Ensure privacy policy text matches real Firebase/runtime-feature-flag behavior. + +## Android Permissions Review (Manifest) + +Current manifest declares: + +- `android.permission.CAMERA` +- `android.permission.READ_EXTERNAL_STORAGE` (maxSdkVersion 32) +- `android.permission.WRITE_EXTERNAL_STORAGE` (maxSdkVersion 32) +- `android.permission.READ_MEDIA_IMAGES` +- `android.permission.INTERNET` +- `android.permission.POST_NOTIFICATIONS` + +Before production: + +- [ ] Ensure each permission has a user-facing feature justification in store listing/privacy text. +- [ ] Ensure permission prompts are contextual and not requested unnecessarily. + +## Build Commands + +No `--dart-define` is required for a release build, unless you want runtime overrides. + +```bash +cd apps/mobile +flutter clean +flutter pub get +flutter build appbundle --release +``` + +Output artifact: + +- `apps/mobile/build/app/outputs/bundle/release/app-release.aab` + +Optional example with quoted `--dart-define`: + +```bash +flutter build appbundle --release \ + --dart-define='APP_UPDATE_STORE_URL_ANDROID=https://play.google.com/store/apps/details?id=dev.mixin27.collection_tracker' +``` + +## Release Gate (Go/No-Go) + +- [ ] Crash-free smoke test completed on release build. +- [ ] Sync/auth/notifications/import-export basic paths validated. +- [ ] Privacy policy URL live and reachable. +- [ ] Data safety + content rating completed. +- [ ] Internal testing upload successful. +- [ ] Production staged rollout plan approved. diff --git a/packages/common/ui/lib/src/widgets/app_sheet.dart b/packages/common/ui/lib/src/widgets/app_sheet.dart index f5fa622..986f222 100644 --- a/packages/common/ui/lib/src/widgets/app_sheet.dart +++ b/packages/common/ui/lib/src/widgets/app_sheet.dart @@ -8,7 +8,7 @@ import 'glass_surface.dart'; Future showAppSheet({ required BuildContext context, required WidgetBuilder builder, - bool isScrollControlled = false, + bool isScrollControlled = true, bool useSafeArea = true, }) { return showModalBottomSheet( diff --git a/packages/common/ui/lib/src/widgets/glass_segmented_navigation_bar.dart b/packages/common/ui/lib/src/widgets/glass_segmented_navigation_bar.dart index 6c14715..1f83fad 100644 --- a/packages/common/ui/lib/src/widgets/glass_segmented_navigation_bar.dart +++ b/packages/common/ui/lib/src/widgets/glass_segmented_navigation_bar.dart @@ -40,7 +40,7 @@ class GlassSegmentedNavigationBar extends StatelessWidget { final textScale = MediaQuery.textScalerOf(context).scale(1.0); final effectiveHeight = (height ?? tokens.navBarHeight) + - (textScale > 1 ? (textScale - 1) * 18 : 0); + (textScale > 1 ? (textScale - 1) * 24 : 0); return SafeArea( top: false, @@ -120,7 +120,9 @@ class _GlassNavItem extends StatelessWidget { scale: selected ? 1 : 0.97, child: LayoutBuilder( builder: (context, constraints) { - final isTight = constraints.maxHeight < 52; + final isTight = + constraints.maxHeight < 52 || constraints.maxWidth < 76; + final showLabel = !isTight || textScale <= 1.02; return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -139,26 +141,28 @@ class _GlassNavItem extends StatelessWidget { : theme.colorScheme.onSurfaceVariant, ), ), - SizedBox(height: isTight ? 2 : 3), - Flexible( - child: Text( - destination.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - textScaler: TextScaler.linear(maxTextScale), - style: theme.textTheme.labelSmall?.copyWith( - fontSize: labelSize, - height: 1.0, - fontWeight: selected - ? FontWeight.w700 - : FontWeight.w500, - color: selected - ? theme.colorScheme.primary - : theme.colorScheme.onSurfaceVariant, + if (showLabel) ...[ + SizedBox(height: isTight ? 2 : 3), + Flexible( + child: Text( + destination.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + textScaler: TextScaler.linear(maxTextScale), + style: theme.textTheme.labelSmall?.copyWith( + fontSize: labelSize, + height: 1.0, + fontWeight: selected + ? FontWeight.w700 + : FontWeight.w500, + color: selected + ? theme.colorScheme.primary + : theme.colorScheme.onSurfaceVariant, + ), ), ), - ), + ], ], ); }, diff --git a/packages/core/data/lib/data.dart b/packages/core/data/lib/data.dart index bc7c481..e8dce24 100644 --- a/packages/core/data/lib/data.dart +++ b/packages/core/data/lib/data.dart @@ -3,3 +3,4 @@ export 'src/models/item_model.dart'; export 'src/repositories/collection_repository_impl.dart'; export 'src/repositories/item_repository_impl.dart'; +export 'src/repositories/loan_repository_impl.dart'; diff --git a/packages/core/data/lib/src/repositories/loan_repository_impl.dart b/packages/core/data/lib/src/repositories/loan_repository_impl.dart new file mode 100644 index 0000000..4e4d0d8 --- /dev/null +++ b/packages/core/data/lib/src/repositories/loan_repository_impl.dart @@ -0,0 +1,295 @@ +import 'dart:math'; + +import 'package:database/database.dart'; +import 'package:domain/domain.dart'; +import 'package:fpdart/fpdart.dart'; + +import '../sync/outbox_sync_writer.dart'; + +class LoanRepositoryImpl implements LoanRepository { + LoanRepositoryImpl(this._dao, {SyncDao? syncDao}) + : _syncOutboxWriter = syncDao != null ? SyncOutboxWriter(syncDao) : null; + + final LoanDao _dao; + final Random _random = Random(); + final SyncOutboxWriter? _syncOutboxWriter; + + @override + Stream> watchActiveLoans() { + return _dao.watchActiveLoans().map( + (rows) => rows.map(_mapToEntity).toList(growable: false), + ); + } + + @override + Stream> watchLoanHistory({int limit = 200}) { + return _dao + .watchLoanHistory(limit: limit) + .map((rows) => rows.map(_mapToEntity).toList(growable: false)); + } + + @override + Stream watchActiveLoanForItem(String itemId) { + return _dao + .watchActiveLoanForItem(itemId) + .map((row) => row == null ? null : _mapToEntity(row)); + } + + @override + Future> createLoan({ + required String itemId, + required String borrowerName, + String? borrowerContact, + String? notes, + DateTime? dueAt, + }) async { + final normalizedBorrower = borrowerName.trim(); + if (normalizedBorrower.isEmpty) { + return const Left( + AppException.validation(message: 'Borrower name is required.'), + ); + } + + try { + final existingActive = await _dao.getActiveLoanByItemId(itemId); + if (existingActive != null) { + return const Left( + AppException.business( + message: 'This item is already on loan.', + code: 'item_already_loaned', + ), + ); + } + + final now = DateTime.now(); + if (dueAt != null && dueAt.isBefore(now)) { + return const Left( + AppException.validation( + message: 'Due date must be in the future.', + fieldErrors: {'dueAt': 'Due date must be in the future.'}, + ), + ); + } + + final id = _generateLoanId(itemId); + await _dao.insertLoan( + ItemLoansCompanion.insert( + id: id, + itemId: itemId, + borrowerName: normalizedBorrower, + borrowerContact: Value(_normalizeNullableText(borrowerContact)), + notes: Value(_normalizeNullableText(notes)), + loanedAt: now, + dueAt: Value(dueAt), + createdAt: now, + updatedAt: now, + ), + ); + + final created = await _dao.getLoanWithItemById(id); + if (created == null) { + return const Left( + AppException.notFound( + message: 'Loan was created but could not be loaded.', + resourceType: 'Loan', + ), + ); + } + + final createdEntity = _mapToEntity(created); + await _queueLoanUpsert(createdEntity); + return Right(createdEntity); + } catch (error, stackTrace) { + return Left( + AppException.database( + message: 'Failed to create loan', + stackTrace: stackTrace, + ), + ); + } + } + + @override + Future> markLoanReturned({ + required String loanId, + DateTime? returnedAt, + }) async { + try { + final existing = await _dao.getLoanWithItemById(loanId); + if (existing == null) { + return const Left( + AppException.notFound( + message: 'Loan not found', + resourceType: 'Loan', + ), + ); + } + + if (existing.loan.returnedAt != null) { + return Right(_mapToEntity(existing)); + } + + final effectiveReturnedAt = returnedAt ?? DateTime.now(); + final rowsAffected = await _dao.markReturned( + loanId: loanId, + returnedAt: effectiveReturnedAt, + ); + if (rowsAffected < 1) { + return const Left( + AppException.notFound( + message: 'Loan not found', + resourceType: 'Loan', + ), + ); + } + + final updated = await _dao.getLoanWithItemById(loanId); + if (updated == null) { + return const Left( + AppException.notFound( + message: 'Loan not found', + resourceType: 'Loan', + ), + ); + } + + final updatedEntity = _mapToEntity(updated); + await _queueLoanUpsert(updatedEntity); + return Right(updatedEntity); + } catch (error, stackTrace) { + return Left( + AppException.database( + message: 'Failed to update loan status', + stackTrace: stackTrace, + ), + ); + } + } + + @override + Future> deleteLoan(String loanId) async { + try { + final existing = await _dao.getLoanWithItemById(loanId); + final rowsAffected = await _dao.deleteLoan(loanId); + if (rowsAffected < 1) { + return const Left( + AppException.notFound( + message: 'Loan not found', + resourceType: 'Loan', + ), + ); + } + + if (existing != null) { + await _queueLoanDelete(_mapToEntity(existing)); + } + + return const Right(null); + } catch (error, stackTrace) { + return Left( + AppException.database( + message: 'Failed to delete loan', + stackTrace: stackTrace, + ), + ); + } + } + + LoanRecord _mapToEntity(ItemLoanWithItemData row) { + return LoanRecord( + id: row.loan.id, + itemId: row.item.id, + itemTitle: row.item.title, + collectionId: row.item.collectionId, + collectionName: row.collection.name, + itemCoverImageUrl: row.item.coverImageUrl, + itemCoverImagePath: row.item.coverImagePath, + borrowerName: row.loan.borrowerName, + borrowerContact: row.loan.borrowerContact, + notes: row.loan.notes, + loanedAt: row.loan.loanedAt, + dueAt: row.loan.dueAt, + returnedAt: row.loan.returnedAt, + createdAt: row.loan.createdAt, + updatedAt: row.loan.updatedAt, + ); + } + + String _generateLoanId(String itemId) { + final timestamp = DateTime.now().microsecondsSinceEpoch; + final randomPart = _random.nextInt(1 << 32).toRadixString(16); + return 'loan_${itemId}_$timestamp$randomPart'; + } + + String? _normalizeNullableText(String? value) { + final normalized = value?.trim(); + if (normalized == null || normalized.isEmpty) { + return null; + } + return normalized; + } + + Future _queueLoanUpsert(LoanRecord loan) async { + final writer = _syncOutboxWriter; + if (writer == null) { + return; + } + + try { + await writer.queueUpsert( + entityType: 'loan', + entityId: loan.id, + payload: _loanSyncPayload(loan: loan), + ); + } catch (_) { + // Keep local write successful even if sync queue persistence fails. + } + } + + Future _queueLoanDelete(LoanRecord loan) async { + final writer = _syncOutboxWriter; + if (writer == null) { + return; + } + + final deletedAt = DateTime.now(); + try { + await writer.queueDelete( + entityType: 'loan', + entityId: loan.id, + payload: _loanSyncPayload( + loan: loan, + isDeleted: true, + deletedAt: deletedAt, + updatedAt: deletedAt, + ), + ); + } catch (_) { + // Keep local write successful even if sync queue persistence fails. + } + } + + Map _loanSyncPayload({ + required LoanRecord loan, + bool isDeleted = false, + DateTime? deletedAt, + DateTime? updatedAt, + }) { + final effectiveUpdatedAt = updatedAt ?? loan.updatedAt; + return { + 'id': loan.id, + 'itemId': loan.itemId, + 'borrowerName': loan.borrowerName, + 'borrowerContact': loan.borrowerContact, + 'notes': loan.notes, + 'loanedAt': loan.loanedAt.toUtc().toIso8601String(), + 'dueAt': loan.dueAt?.toUtc().toIso8601String(), + 'returnedAt': loan.returnedAt?.toUtc().toIso8601String(), + 'version': 1, + 'isDeleted': isDeleted, + if (deletedAt != null) 'deletedAt': deletedAt.toUtc().toIso8601String(), + 'createdAt': loan.createdAt.toUtc().toIso8601String(), + 'updatedAt': effectiveUpdatedAt.toUtc().toIso8601String(), + }; + } +} diff --git a/packages/core/domain/lib/domain.dart b/packages/core/domain/lib/domain.dart index aa13020..14b56a9 100644 --- a/packages/core/domain/lib/domain.dart +++ b/packages/core/domain/lib/domain.dart @@ -1,10 +1,12 @@ export 'src/entities/collection.dart'; export 'src/entities/item.dart'; +export 'src/entities/loan_record.dart'; export 'src/failures/app_exception.dart'; export 'src/repositories/collection_repository.dart'; export 'src/repositories/item_repository.dart'; +export 'src/repositories/loan_repository.dart'; export 'src/usecases/base_usecase.dart'; export 'src/usecases/collection/create_collection_usecase.dart'; diff --git a/packages/core/domain/lib/src/entities/loan_record.dart b/packages/core/domain/lib/src/entities/loan_record.dart new file mode 100644 index 0000000..5b8f208 --- /dev/null +++ b/packages/core/domain/lib/src/entities/loan_record.dart @@ -0,0 +1,81 @@ +class LoanRecord { + const LoanRecord({ + required this.id, + required this.itemId, + required this.itemTitle, + required this.collectionId, + required this.collectionName, + this.itemCoverImageUrl, + this.itemCoverImagePath, + required this.borrowerName, + this.borrowerContact, + this.notes, + required this.loanedAt, + this.dueAt, + this.returnedAt, + required this.createdAt, + required this.updatedAt, + }); + + final String id; + final String itemId; + final String itemTitle; + final String collectionId; + final String collectionName; + final String? itemCoverImageUrl; + final String? itemCoverImagePath; + final String borrowerName; + final String? borrowerContact; + final String? notes; + final DateTime loanedAt; + final DateTime? dueAt; + final DateTime? returnedAt; + final DateTime createdAt; + final DateTime updatedAt; + + bool get isReturned => returnedAt != null; + bool get isActive => !isReturned; + + bool get isOverdue { + if (isReturned || dueAt == null) { + return false; + } + return dueAt!.isBefore(DateTime.now()); + } + + LoanRecord copyWith({ + String? id, + String? itemId, + String? itemTitle, + String? collectionId, + String? collectionName, + String? itemCoverImageUrl, + String? itemCoverImagePath, + String? borrowerName, + String? borrowerContact, + String? notes, + DateTime? loanedAt, + DateTime? dueAt, + DateTime? returnedAt, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return LoanRecord( + id: id ?? this.id, + itemId: itemId ?? this.itemId, + itemTitle: itemTitle ?? this.itemTitle, + collectionId: collectionId ?? this.collectionId, + collectionName: collectionName ?? this.collectionName, + itemCoverImageUrl: itemCoverImageUrl ?? this.itemCoverImageUrl, + itemCoverImagePath: itemCoverImagePath ?? this.itemCoverImagePath, + borrowerName: borrowerName ?? this.borrowerName, + borrowerContact: borrowerContact ?? this.borrowerContact, + notes: notes ?? this.notes, + loanedAt: loanedAt ?? this.loanedAt, + dueAt: dueAt ?? this.dueAt, + returnedAt: returnedAt ?? this.returnedAt, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } +} diff --git a/packages/core/domain/lib/src/repositories/loan_repository.dart b/packages/core/domain/lib/src/repositories/loan_repository.dart new file mode 100644 index 0000000..d939819 --- /dev/null +++ b/packages/core/domain/lib/src/repositories/loan_repository.dart @@ -0,0 +1,24 @@ +import 'package:domain/src/entities/loan_record.dart'; +import 'package:domain/src/failures/app_exception.dart'; +import 'package:fpdart/fpdart.dart'; + +abstract class LoanRepository { + Stream> watchActiveLoans(); + Stream> watchLoanHistory({int limit = 200}); + Stream watchActiveLoanForItem(String itemId); + + Future> createLoan({ + required String itemId, + required String borrowerName, + String? borrowerContact, + String? notes, + DateTime? dueAt, + }); + + Future> markLoanReturned({ + required String loanId, + DateTime? returnedAt, + }); + + Future> deleteLoan(String loanId); +} diff --git a/packages/integrations/backend_api/README.md b/packages/integrations/backend_api/README.md index b02581d..5a9b9f0 100644 --- a/packages/integrations/backend_api/README.md +++ b/packages/integrations/backend_api/README.md @@ -4,5 +4,6 @@ Backend API integration package for Collection Tracker. Provides: - auth REST client (register/login/refresh/logout/profile) +- account deletion request client helper - request/response models - backend exception model diff --git a/packages/integrations/backend_api/lib/backend_api.dart b/packages/integrations/backend_api/lib/backend_api.dart index 2b1f085..70976f9 100644 --- a/packages/integrations/backend_api/lib/backend_api.dart +++ b/packages/integrations/backend_api/lib/backend_api.dart @@ -1,3 +1,5 @@ export 'src/clients/backend_auth_client.dart'; +export 'src/clients/backend_app_update_client.dart'; export 'src/exceptions/backend_api_exception.dart'; export 'src/models/backend_auth_models.dart'; +export 'src/models/backend_app_update_models.dart'; diff --git a/packages/integrations/backend_api/lib/src/clients/backend_app_update_client.dart b/packages/integrations/backend_api/lib/src/clients/backend_app_update_client.dart new file mode 100644 index 0000000..5194cb6 --- /dev/null +++ b/packages/integrations/backend_api/lib/src/clients/backend_app_update_client.dart @@ -0,0 +1,162 @@ +import 'package:dio/dio.dart'; + +import '../exceptions/backend_api_exception.dart'; +import '../models/backend_app_update_models.dart'; + +class BackendAppUpdateClient { + BackendAppUpdateClient({ + required Dio dio, + required String apiBaseUrl, + String appUpdatePath = '/app-update/check', + }) : _dio = dio, + _apiBaseUrl = _normalizeBaseUrl(apiBaseUrl), + _appUpdatePath = appUpdatePath; + + final Dio _dio; + final String _apiBaseUrl; + final String _appUpdatePath; + + Future check( + BackendAppUpdateCheckRequest request, + ) async { + final map = await _post(path: _appUpdatePath, data: request.toJson()); + return BackendAppUpdateCheckResponse.fromJson(map); + } + + Future> _post({ + required String path, + required Map data, + }) async { + try { + final response = await _dio.post>( + '$_apiBaseUrl$path', + data: data, + ); + return _unwrapToMap(response.data); + } on DioException catch (error) { + throw _mapDioError(error); + } + } + + BackendApiException _mapDioError(DioException error) { + final statusCode = error.response?.statusCode; + final responseData = error.response?.data; + final fallbackMessage = error.message ?? 'Backend API request failed'; + + if (responseData is Map) { + final map = responseData.cast(); + final message = _extractMessage(map) ?? fallbackMessage; + final code = _extractCode(map); + return BackendApiException( + message: message, + statusCode: statusCode, + code: code, + raw: map, + ); + } + + return BackendApiException( + message: _normalizeMessage(responseData) ?? fallbackMessage, + statusCode: statusCode, + raw: responseData, + ); + } + + String? _extractMessage(Map map) { + final direct = + _normalizeMessage(map['message']) ?? _normalizeMessage(map['error']); + if (direct != null) { + return direct; + } + + final data = map['data']; + if (data is Map) { + return _normalizeMessage(data['message']) ?? + _normalizeMessage(data['error']); + } + + return null; + } + + String? _extractCode(Map map) { + final directCode = _normalizeCode(map['code']); + if (directCode != null) { + return directCode; + } + + final data = map['data']; + if (data is Map) { + return _normalizeCode(data['code']); + } + + return null; + } + + String? _normalizeCode(Object? value) { + if (value == null) { + return null; + } + + final text = value.toString().trim(); + return text.isEmpty ? null : text; + } + + String? _normalizeMessage(Object? value) { + if (value == null) { + return null; + } + + if (value is String) { + final text = value.trim(); + return text.isEmpty ? null : text; + } + + if (value is List) { + final parts = value + .map(_normalizeMessage) + .whereType() + .where((text) => text.isNotEmpty) + .toList(); + if (parts.isEmpty) { + return null; + } + return parts.join(', '); + } + + if (value is Map) { + final nested = + _normalizeMessage(value['message']) ?? + _normalizeMessage(value['error']); + if (nested != null) { + return nested; + } + } + + final text = value.toString().trim(); + return text.isEmpty ? null : text; + } + + Map _unwrapToMap(Object? raw) { + if (raw is! Map) { + return const {}; + } + + final map = raw.cast(); + final nested = map['data']; + if (nested is Map) { + return nested.cast(); + } + + return map; + } + + static String _normalizeBaseUrl(String value) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + return ''; + } + return trimmed.endsWith('/') + ? trimmed.substring(0, trimmed.length - 1) + : trimmed; + } +} diff --git a/packages/integrations/backend_api/lib/src/clients/backend_auth_client.dart b/packages/integrations/backend_api/lib/src/clients/backend_auth_client.dart index 2b3963e..dc714f8 100644 --- a/packages/integrations/backend_api/lib/src/clients/backend_auth_client.dart +++ b/packages/integrations/backend_api/lib/src/clients/backend_auth_client.dart @@ -64,6 +64,45 @@ class BackendAuthClient { return BackendProfileResponse.fromJson(map); } + Future requestAccountDeletion({ + required String accessToken, + BackendAccountDeletionRequest request = + const BackendAccountDeletionRequest(), + }) async { + final candidatePaths = [ + '$_authPathPrefix/account-deletion-request', + '$_authPathPrefix/request-account-deletion', + '$_authPathPrefix/delete-account-request', + '$_authPathPrefix/request-deletion', + ]; + + BackendApiException? lastException; + for (final path in candidatePaths) { + try { + await _post( + path: path, + data: request.toJson(), + accessToken: accessToken, + ); + return; + } on BackendApiException catch (error) { + if (error.statusCode == 404) { + lastException = error; + continue; + } + rethrow; + } + } + + throw BackendApiException( + message: + 'Account deletion request endpoint is not available on the backend.', + statusCode: lastException?.statusCode ?? 404, + code: 'ACCOUNT_DELETION_ENDPOINT_NOT_FOUND', + raw: lastException?.raw, + ); + } + Future> _post({ required String path, required Map data, diff --git a/packages/integrations/backend_api/lib/src/models/backend_app_update_models.dart b/packages/integrations/backend_api/lib/src/models/backend_app_update_models.dart new file mode 100644 index 0000000..8a6467a --- /dev/null +++ b/packages/integrations/backend_api/lib/src/models/backend_app_update_models.dart @@ -0,0 +1,94 @@ +class BackendAppUpdateCheckRequest { + const BackendAppUpdateCheckRequest({ + required this.platform, + this.currentVersion, + this.buildNumber, + this.channel, + this.locale, + }); + + final String platform; + final String? currentVersion; + final String? buildNumber; + final String? channel; + final String? locale; + + Map toJson() { + return { + 'platform': platform, + 'currentVersion': currentVersion, + 'buildNumber': buildNumber, + 'channel': channel, + 'locale': locale, + }; + } +} + +class BackendAppUpdateCheckResponse { + const BackendAppUpdateCheckResponse({ + required this.status, + this.latestVersion, + this.minSupportedVersion, + this.title, + this.message, + this.storeUrl, + this.snoozeHours, + this.forceAfter, + }); + + final String status; + final String? latestVersion; + final String? minSupportedVersion; + final String? title; + final String? message; + final String? storeUrl; + final int? snoozeHours; + final DateTime? forceAfter; + + factory BackendAppUpdateCheckResponse.fromJson(Map json) { + String? stringValue(String key) { + final value = json[key]; + if (value == null) { + return null; + } + final text = value.toString().trim(); + return text.isEmpty ? null : text; + } + + int? intValue(String key) { + final value = json[key]; + if (value == null) { + return null; + } + if (value is int) { + return value; + } + if (value is double) { + return value.round(); + } + return int.tryParse(value.toString().trim()); + } + + DateTime? dateValue(String key) { + final text = stringValue(key); + if (text == null) { + return null; + } + return DateTime.tryParse(text)?.toUtc(); + } + + return BackendAppUpdateCheckResponse( + status: stringValue('status') ?? 'none', + latestVersion: + stringValue('latestVersion') ?? stringValue('latest_version'), + minSupportedVersion: + stringValue('minSupportedVersion') ?? + stringValue('min_supported_version'), + title: stringValue('title'), + message: stringValue('message'), + storeUrl: stringValue('storeUrl') ?? stringValue('store_url'), + snoozeHours: intValue('snoozeHours') ?? intValue('snooze_hours'), + forceAfter: dateValue('forceAfter') ?? dateValue('force_after'), + ); + } +} diff --git a/packages/integrations/backend_api/lib/src/models/backend_auth_models.dart b/packages/integrations/backend_api/lib/src/models/backend_auth_models.dart index 58c80f7..de98da6 100644 --- a/packages/integrations/backend_api/lib/src/models/backend_auth_models.dart +++ b/packages/integrations/backend_api/lib/src/models/backend_auth_models.dart @@ -181,3 +181,17 @@ class BackendRefreshTokenRequest { return {'refreshToken': refreshToken, 'deviceId': deviceId}; } } + +class BackendAccountDeletionRequest { + const BackendAccountDeletionRequest({ + this.reason, + this.source = 'mobile_app', + }); + + final String? reason; + final String source; + + Map toJson() { + return {'reason': reason, 'source': source}; + } +} diff --git a/packages/integrations/database/lib/src/app_database.dart b/packages/integrations/database/lib/src/app_database.dart index 13b615e..79e38ec 100644 --- a/packages/integrations/database/lib/src/app_database.dart +++ b/packages/integrations/database/lib/src/app_database.dart @@ -13,16 +13,17 @@ part 'app_database.g.dart'; Tags, ItemTags, ItemPriceHistory, + ItemLoans, SyncOutbox, SyncState, ], - daos: [CollectionDao, ItemDao, SyncDao], + daos: [CollectionDao, ItemDao, LoanDao, SyncDao], ) class AppDatabase extends _$AppDatabase { AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); @override - int get schemaVersion => 7; + int get schemaVersion => 8; @override MigrationStrategy get migration { @@ -39,6 +40,13 @@ class AppDatabase extends _$AppDatabase { 'CREATE INDEX idx_item_price_history_item_time ' 'ON item_price_history(item_id, recorded_at DESC);', ); + await customStatement( + 'CREATE INDEX idx_item_loans_item_active ' + 'ON item_loans(item_id, returned_at);', + ); + await customStatement( + 'CREATE INDEX idx_item_loans_due_at ON item_loans(due_at);', + ); await customStatement( 'CREATE INDEX idx_sync_outbox_created_at ON sync_outbox(created_at);', ); @@ -84,6 +92,17 @@ class AppDatabase extends _$AppDatabase { if (from < 7) { await m.addColumn(syncState, syncState.nextRetryAt); } + + if (from < 8) { + await m.createTable(itemLoans); + await customStatement( + 'CREATE INDEX idx_item_loans_item_active ' + 'ON item_loans(item_id, returned_at);', + ); + await customStatement( + 'CREATE INDEX idx_item_loans_due_at ON item_loans(due_at);', + ); + } }, beforeOpen: (details) async { await customStatement('PRAGMA foreign_keys = ON'); diff --git a/packages/integrations/database/lib/src/daos/daos.dart b/packages/integrations/database/lib/src/daos/daos.dart index 57b697c..b99f9f7 100644 --- a/packages/integrations/database/lib/src/daos/daos.dart +++ b/packages/integrations/database/lib/src/daos/daos.dart @@ -1,3 +1,4 @@ export 'collection_dao.dart'; export 'item_dao.dart'; +export 'loan_dao.dart'; export 'sync_dao.dart'; diff --git a/packages/integrations/database/lib/src/daos/loan_dao.dart b/packages/integrations/database/lib/src/daos/loan_dao.dart new file mode 100644 index 0000000..e2777fe --- /dev/null +++ b/packages/integrations/database/lib/src/daos/loan_dao.dart @@ -0,0 +1,184 @@ +import 'package:database/src/app_database.dart'; +import 'package:database/src/tables/tables.dart'; +import 'package:drift/drift.dart'; + +part 'loan_dao.g.dart'; + +class ItemLoanWithItemData { + const ItemLoanWithItemData({ + required this.loan, + required this.item, + required this.collection, + }); + + final ItemLoanData loan; + final ItemData item; + final CollectionData collection; +} + +@DriftAccessor(tables: [ItemLoans, Items, Collections]) +class LoanDao extends DatabaseAccessor with _$LoanDaoMixin { + LoanDao(super.db); + + Future> getAllLoans() { + return (select( + itemLoans, + )..orderBy([(tbl) => OrderingTerm.desc(tbl.loanedAt)])).get(); + } + + Stream> watchActiveLoans() { + final query = + select(itemLoans).join([ + innerJoin(items, items.id.equalsExp(itemLoans.itemId)), + innerJoin( + collections, + collections.id.equalsExp(items.collectionId), + ), + ]) + ..where(itemLoans.returnedAt.isNull()) + ..orderBy([ + OrderingTerm.asc(itemLoans.dueAt), + OrderingTerm.desc(itemLoans.loanedAt), + ]); + + return query.watch().map(_mapJoinedRows); + } + + Stream> watchLoanHistory({int limit = 200}) { + final query = + select(itemLoans).join([ + innerJoin(items, items.id.equalsExp(itemLoans.itemId)), + innerJoin( + collections, + collections.id.equalsExp(items.collectionId), + ), + ]) + ..where(itemLoans.returnedAt.isNotNull()) + ..orderBy([ + OrderingTerm.desc(itemLoans.returnedAt), + OrderingTerm.desc(itemLoans.loanedAt), + ]) + ..limit(limit); + + return query.watch().map(_mapJoinedRows); + } + + Stream watchActiveLoanForItem(String itemId) { + final query = + select(itemLoans).join([ + innerJoin(items, items.id.equalsExp(itemLoans.itemId)), + innerJoin( + collections, + collections.id.equalsExp(items.collectionId), + ), + ]) + ..where( + itemLoans.itemId.equals(itemId) & itemLoans.returnedAt.isNull(), + ) + ..orderBy([OrderingTerm.desc(itemLoans.loanedAt)]) + ..limit(1); + + return query.watch().map((rows) { + if (rows.isEmpty) { + return null; + } + return _mapJoinedRow(rows.first); + }); + } + + Future getLoanById(String id) { + return (select( + itemLoans, + )..where((tbl) => tbl.id.equals(id))).getSingleOrNull(); + } + + Future getLoanWithItemById(String loanId) async { + final query = + select(itemLoans).join([ + innerJoin(items, items.id.equalsExp(itemLoans.itemId)), + innerJoin( + collections, + collections.id.equalsExp(items.collectionId), + ), + ]) + ..where(itemLoans.id.equals(loanId)) + ..limit(1); + + final rows = await query.get(); + if (rows.isEmpty) { + return null; + } + + return _mapJoinedRow(rows.first); + } + + Future getActiveLoanByItemId(String itemId) { + return (select(itemLoans) + ..where((tbl) => tbl.itemId.equals(itemId) & tbl.returnedAt.isNull()) + ..orderBy([(tbl) => OrderingTerm.desc(tbl.loanedAt)]) + ..limit(1)) + .getSingleOrNull(); + } + + Future getActiveLoanWithItemByItemId( + String itemId, + ) async { + final query = + select(itemLoans).join([ + innerJoin(items, items.id.equalsExp(itemLoans.itemId)), + innerJoin( + collections, + collections.id.equalsExp(items.collectionId), + ), + ]) + ..where( + itemLoans.itemId.equals(itemId) & itemLoans.returnedAt.isNull(), + ) + ..orderBy([OrderingTerm.desc(itemLoans.loanedAt)]) + ..limit(1); + + final rows = await query.get(); + if (rows.isEmpty) { + return null; + } + return _mapJoinedRow(rows.first); + } + + Future insertLoan(ItemLoansCompanion loan) { + return into(itemLoans).insert(loan, mode: InsertMode.insertOrReplace); + } + + Future updateLoan(ItemLoansCompanion loan) { + return (update( + itemLoans, + )..where((tbl) => tbl.id.equals(loan.id.value))).write(loan); + } + + Future markReturned({ + required String loanId, + required DateTime returnedAt, + }) { + return (update(itemLoans)..where((tbl) => tbl.id.equals(loanId))).write( + ItemLoansCompanion( + returnedAt: Value(returnedAt), + updatedAt: Value(returnedAt), + ), + ); + } + + Future deleteLoan(String loanId) { + return (delete(itemLoans)..where((tbl) => tbl.id.equals(loanId))).go(); + } + + List _mapJoinedRows(List rows) { + return rows.map(_mapJoinedRow).toList(growable: false); + } + + ItemLoanWithItemData _mapJoinedRow(TypedResult row) { + return ItemLoanWithItemData( + loan: row.readTable(itemLoans), + item: row.readTable(items), + collection: row.readTable(collections), + ); + } +} diff --git a/packages/integrations/database/lib/src/tables/item_loans_table.dart b/packages/integrations/database/lib/src/tables/item_loans_table.dart new file mode 100644 index 0000000..02bf551 --- /dev/null +++ b/packages/integrations/database/lib/src/tables/item_loans_table.dart @@ -0,0 +1,21 @@ +import 'package:drift/drift.dart'; + +import 'items_table.dart'; + +@DataClassName('ItemLoanData') +class ItemLoans extends Table { + TextColumn get id => text()(); + TextColumn get itemId => + text().references(Items, #id, onDelete: KeyAction.cascade)(); + TextColumn get borrowerName => text().withLength(min: 1, max: 120)(); + TextColumn get borrowerContact => text().nullable()(); + TextColumn get notes => text().nullable()(); + DateTimeColumn get loanedAt => dateTime()(); + DateTimeColumn get dueAt => dateTime().nullable()(); + DateTimeColumn get returnedAt => dateTime().nullable()(); + DateTimeColumn get createdAt => dateTime()(); + DateTimeColumn get updatedAt => dateTime()(); + + @override + Set get primaryKey => {id}; +} diff --git a/packages/integrations/database/lib/src/tables/tables.dart b/packages/integrations/database/lib/src/tables/tables.dart index e158a85..33a2e1e 100644 --- a/packages/integrations/database/lib/src/tables/tables.dart +++ b/packages/integrations/database/lib/src/tables/tables.dart @@ -3,5 +3,6 @@ export 'items_table.dart'; export 'tags_table.dart'; export 'item_tags_table.dart'; export 'item_price_history_table.dart'; +export 'item_loans_table.dart'; export 'sync_outbox_table.dart'; export 'sync_state_table.dart'; diff --git a/packages/integrations/storage/lib/src/exceptions/storage_exception.dart b/packages/integrations/storage/lib/src/exceptions/storage_exception.dart index 869d359..1a18383 100644 --- a/packages/integrations/storage/lib/src/exceptions/storage_exception.dart +++ b/packages/integrations/storage/lib/src/exceptions/storage_exception.dart @@ -35,3 +35,8 @@ class TypeMismatchException extends StorageException { class SerializationException extends StorageException { SerializationException(super.message, {super.key, super.originalError}); } + +/// Exception for user-cancelled storage operations, such as picker dialogs. +class UserCancelledStorageOperationException extends StorageException { + UserCancelledStorageOperationException(super.message); +} diff --git a/packages/integrations/storage/lib/src/export_import_service.dart b/packages/integrations/storage/lib/src/export_import_service.dart index 8b7bd94..eb45a4d 100644 --- a/packages/integrations/storage/lib/src/export_import_service.dart +++ b/packages/integrations/storage/lib/src/export_import_service.dart @@ -1,89 +1,90 @@ import 'dart:convert'; -import 'dart:io'; +import 'dart:typed_data'; import 'package:file_picker/file_picker.dart' as fp; -import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; +import 'package:storage/src/exceptions/storage_exception.dart'; -class ExportImportService { - // Export data to JSON - Future exportToJson(Map data) async { - try { - final jsonString = const JsonEncoder.withIndent(' ').convert(data); +class PickedJsonImport { + const PickedJsonImport({required this.fileName, required this.data}); - final timestamp = DateTime.now().millisecondsSinceEpoch; - final file = await _getExportFile( - 'collection_tracker_export_$timestamp.json', - ); - await file.writeAsString(jsonString); + final String fileName; + final Map data; +} - return file.path; - } catch (e) { - throw Exception('Failed to export to JSON: $e'); - } +class ExportImportService { + Future exportToJson(Map data) async { + final jsonString = const JsonEncoder.withIndent(' ').convert(data); + final bytes = Uint8List.fromList(utf8.encode(jsonString)); + final timestamp = DateTime.now().millisecondsSinceEpoch; + final fileName = 'collection_tracker_export_$timestamp.json'; + return _saveToUserSelectedLocation( + bytes: bytes, + fileName: fileName, + allowedExtensions: const ['json'], + dialogTitle: 'Save JSON export', + ); } - // Export data to CSV Future exportToCsv(List> items) async { - try { - if (items.isEmpty) { - throw Exception('No data to export'); - } - - final headers = items.first.keys.toList(); - final csvLines = []; - - // Add header row - csvLines.add(headers.map((h) => _escapeCsvValue(h)).join(',')); - - // Add data rows - for (final item in items) { - final row = headers - .map((header) { - final value = item[header]?.toString() ?? ''; - return _escapeCsvValue(value); - }) - .join(','); - csvLines.add(row); - } + if (items.isEmpty) { + throw StorageException('No data to export as CSV.'); + } - final csvString = csvLines.join('\n'); + final headers = items.first.keys.toList(); + final csvLines = []; - final timestamp = DateTime.now().millisecondsSinceEpoch; - final file = await _getExportFile( - 'collection_tracker_export_$timestamp.csv', - ); - await file.writeAsString(csvString); - - return file.path; - } catch (e) { - throw Exception('Failed to export to CSV: $e'); + csvLines.add(headers.map((h) => _escapeCsvValue(h)).join(',')); + for (final item in items) { + final row = headers + .map((header) => _escapeCsvValue(item[header]?.toString() ?? '')) + .join(','); + csvLines.add(row); } + + final csvString = csvLines.join('\n'); + final bytes = Uint8List.fromList(utf8.encode(csvString)); + final timestamp = DateTime.now().millisecondsSinceEpoch; + final fileName = 'collection_tracker_export_$timestamp.csv'; + + return _saveToUserSelectedLocation( + bytes: bytes, + fileName: fileName, + allowedExtensions: const ['csv'], + dialogTitle: 'Save CSV export', + ); } - Future _getExportFile(String fileName) async { - Directory? directory; - if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) { - directory = await getDownloadsDirectory(); - } else if (Platform.isAndroid) { - // Try to get the public downloads directory on Android - final externalDirs = await getExternalStorageDirectories( - type: StorageDirectory.downloads, + Future _saveToUserSelectedLocation({ + required Uint8List bytes, + required String fileName, + required List allowedExtensions, + required String dialogTitle, + }) async { + try { + final savedPath = await fp.FilePicker.platform.saveFile( + dialogTitle: dialogTitle, + fileName: fileName, + type: fp.FileType.custom, + allowedExtensions: allowedExtensions, + bytes: bytes, ); - if (externalDirs != null && externalDirs.isNotEmpty) { - directory = externalDirs.first; - } else { - directory = await getExternalStorageDirectory(); - } - } else if (Platform.isIOS) { - // getApplicationDocumentsDirectory is public if UIFileSharingEnabled is set in Info.plist - directory = await getApplicationDocumentsDirectory(); - } - directory ??= await getApplicationDocumentsDirectory(); + if (savedPath == null || savedPath.trim().isEmpty) { + throw UserCancelledStorageOperationException( + 'File save was cancelled by user.', + ); + } - final filePath = '${directory.path}/$fileName'; - return File(filePath); + return savedPath; + } on UserCancelledStorageOperationException { + rethrow; + } catch (error) { + throw StorageException( + 'Failed to save export file.', + originalError: error, + ); + } } String _escapeCsvValue(String value) { @@ -93,7 +94,6 @@ class ExportImportService { return value; } - // Share exported file Future shareFile(String filePath, String fileName) async { try { final file = XFile(filePath); @@ -104,83 +104,128 @@ class ExportImportService { text: 'My collection data from Collection Tracker', ), ); - } catch (e) { - throw Exception('Failed to share file: $e'); + } catch (error) { + throw StorageException('Failed to share file.', originalError: error); } } - // Import from JSON - Future> importFromJson() async { + Future pickJsonImportFile() async { try { final result = await fp.FilePicker.platform.pickFiles( type: fp.FileType.custom, - allowedExtensions: ['json'], + allowedExtensions: const ['json'], + withData: true, ); if (result == null || result.files.isEmpty) { - throw Exception('No file selected'); + throw UserCancelledStorageOperationException( + 'Import cancelled by user.', + ); } - final file = File(result.files.first.path!); - final jsonString = await file.readAsString(); - final data = jsonDecode(jsonString) as Map; + final selected = result.files.first; + final bytes = selected.bytes; + final path = selected.path; - return data; - } catch (e) { - throw Exception('Failed to import from JSON: $e'); + String jsonString; + if (bytes != null) { + jsonString = utf8.decode(bytes); + } else if (path != null && path.trim().isNotEmpty) { + jsonString = await XFile(path).readAsString(); + } else { + throw StorageException('Unable to read selected file.'); + } + + final decoded = jsonDecode(jsonString); + if (decoded is! Map) { + throw StorageException('Invalid JSON format: expected an object root.'); + } + final fileName = selected.name.trim().isEmpty + ? 'backup.json' + : selected.name; + return PickedJsonImport(fileName: fileName, data: decoded); + } on UserCancelledStorageOperationException { + rethrow; + } catch (error) { + throw StorageException( + 'Failed to import JSON file.', + originalError: error, + ); } } - // Import from CSV + Future> importFromJson() async { + final picked = await pickJsonImportFile(); + return picked.data; + } + Future>> importFromCsv() async { try { final result = await fp.FilePicker.platform.pickFiles( type: fp.FileType.custom, - allowedExtensions: ['csv'], + allowedExtensions: const ['csv'], + withData: true, ); if (result == null || result.files.isEmpty) { - throw Exception('No file selected'); + throw UserCancelledStorageOperationException( + 'Import cancelled by user.', + ); } - final file = File(result.files.first.path!); - final csvString = await file.readAsString(); + final selected = result.files.first; + final bytes = selected.bytes; + final path = selected.path; + + String csvString; + if (bytes != null) { + csvString = utf8.decode(bytes); + } else if (path != null && path.trim().isNotEmpty) { + csvString = await XFile(path).readAsString(); + } else { + throw StorageException('Unable to read selected file.'); + } final lines = csvString.split('\n'); if (lines.isEmpty) { - throw Exception('Empty CSV file'); + throw StorageException('CSV file is empty.'); } - final headers = _parseCsvLine(lines[0]); + final headers = _parseCsvLine(lines.first); final data = >[]; - for (int i = 1; i < lines.length; i++) { - if (lines[i].trim().isEmpty) continue; + for (var index = 1; index < lines.length; index++) { + final line = lines[index].trimRight(); + if (line.isEmpty) { + continue; + } - final values = _parseCsvLine(lines[i]); + final values = _parseCsvLine(line); final row = {}; - - for (int j = 0; j < headers.length && j < values.length; j++) { - row[headers[j]] = values[j]; + for (var i = 0; i < headers.length && i < values.length; i++) { + row[headers[i]] = values[i]; } - data.add(row); } return data; - } catch (e) { - throw Exception('Failed to import from CSV: $e'); + } on UserCancelledStorageOperationException { + rethrow; + } catch (error) { + throw StorageException( + 'Failed to import CSV file.', + originalError: error, + ); } } List _parseCsvLine(String line) { final values = []; final buffer = StringBuffer(); - bool inQuotes = false; + var inQuotes = false; - for (int i = 0; i < line.length; i++) { + for (var i = 0; i < line.length; i++) { final char = line[i]; - if (char == '"') { if (inQuotes && i + 1 < line.length && line[i + 1] == '"') { buffer.write('"'); diff --git a/packages/integrations/sync_api/lib/src/models/sync_contract.dart b/packages/integrations/sync_api/lib/src/models/sync_contract.dart index 4a6dfad..2982776 100644 --- a/packages/integrations/sync_api/lib/src/models/sync_contract.dart +++ b/packages/integrations/sync_api/lib/src/models/sync_contract.dart @@ -3,18 +3,27 @@ class SyncChangesPayload { this.collections = const [], this.items = const [], this.tags = const [], + this.loans = const [], }); final List> collections; final List> items; final List> tags; + final List> loans; - bool get isEmpty => collections.isEmpty && items.isEmpty && tags.isEmpty; + bool get isEmpty => + collections.isEmpty && items.isEmpty && tags.isEmpty && loans.isEmpty; - int get totalCount => collections.length + items.length + tags.length; + int get totalCount => + collections.length + items.length + tags.length + loans.length; Map toJson() { - return {'collections': collections, 'items': items, 'tags': tags}; + return { + 'collections': collections, + 'items': items, + 'tags': tags, + 'loans': loans, + }; } } @@ -58,6 +67,7 @@ class SyncCapabilities { required this.maxBatchSize, required this.conflictStrategy, required this.acceptedSchemaVersions, + required this.supportedEntities, }); final String apiVersion; @@ -65,6 +75,7 @@ class SyncCapabilities { final int maxBatchSize; final String conflictStrategy; final List acceptedSchemaVersions; + final List supportedEntities; factory SyncCapabilities.fromJson(Map json) { return SyncCapabilities( @@ -74,6 +85,7 @@ class SyncCapabilities { conflictStrategy: json['conflictStrategy'] as String? ?? 'last_write_wins', acceptedSchemaVersions: _toStringList(json['acceptedSchemaVersions']), + supportedEntities: _toStringList(json['supportedEntities']), ); } @@ -91,6 +103,7 @@ class SyncResponsePayload { required this.syncedCollections, required this.syncedItems, required this.syncedTags, + required this.syncedLoans, required this.conflictsResolved, }); @@ -100,6 +113,7 @@ class SyncResponsePayload { final int syncedCollections; final int syncedItems; final int syncedTags; + final int syncedLoans; final int conflictsResolved; factory SyncResponsePayload.fromJson(Map json) { @@ -114,11 +128,13 @@ class SyncResponsePayload { collections: _toJsonMapList(serverChangesJson['collections']), items: _toJsonMapList(serverChangesJson['items']), tags: _toJsonMapList(serverChangesJson['tags']), + loans: _toJsonMapList(serverChangesJson['loans']), ), conflicts: _toJsonMapList(json['conflicts']), syncedCollections: (json['syncedCollections'] as num?)?.toInt() ?? 0, syncedItems: (json['syncedItems'] as num?)?.toInt() ?? 0, syncedTags: (json['syncedTags'] as num?)?.toInt() ?? 0, + syncedLoans: (json['syncedLoans'] as num?)?.toInt() ?? 0, conflictsResolved: (json['conflictsResolved'] as num?)?.toInt() ?? 0, ); } diff --git a/pubspec.yaml b/pubspec.yaml index 5c156ed..2ef8fb4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: collection_tracker_workspace description: A workspace for the Collection Tracker app -version: 1.0.0 +version: 1.0.1 publish_to: 'none' environment: diff --git a/scripts/generate_brand_assets.sh b/scripts/generate_brand_assets.sh new file mode 100755 index 0000000..7213f78 --- /dev/null +++ b/scripts/generate_brand_assets.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +APP_DIR="$ROOT_DIR/apps/mobile" + +mkdir -p /tmp/clang_module_cache + +echo "Generating icon and Play Store assets..." +CLANG_MODULE_CACHE_PATH=/tmp/clang_module_cache \ + swift "$APP_DIR/tool/generate_brand_assets.swift" + +echo "Updating launcher icons for Android and iOS..." +( + cd "$APP_DIR" + flutter pub run flutter_launcher_icons -f flutter_launcher_icons.yaml +) + +echo "Syncing Android adaptive foreground densities from source asset..." +FOREGROUND_SRC="$APP_DIR/assets/icons/logo_foreground.png" +for entry in "mdpi:108" "hdpi:162" "xhdpi:216" "xxhdpi:324" "xxxhdpi:432"; do + density="${entry%%:*}" + size="${entry##*:}" + target="$APP_DIR/android/app/src/main/res/drawable-${density}/ic_launcher_foreground.png" + sips -s format png -z "$size" "$size" "$FOREGROUND_SRC" --out "$target" >/dev/null +done + +echo "Brand assets updated."