diff --git a/android/app/src/main/assets/airbridge.json b/android/app/src/main/assets/airbridge.json index a8fa64e1..3036d349 100644 --- a/android/app/src/main/assets/airbridge.json +++ b/android/app/src/main/assets/airbridge.json @@ -1,8 +1,8 @@ { - "sdkEnabled": true, - "logLevel": "info", - "autoStartTrackingEnabled": true, - "sessionTimeoutInSecond": 300, - "hashUserInformationEnabled": true, - "trackAirbridgeDeeplinkOnlyEnabled": true + "sdkEnabled": true, + "logLevel": "info", + "autoStartTrackingEnabled": true, + "sessionTimeoutInSecond": 300, + "hashUserInformationEnabled": true, + "trackAirbridgeDeeplinkOnlyEnabled": true } diff --git a/lib/core/config/router/app_router.dart b/lib/core/config/router/app_router.dart index 798fae76..2e8d2296 100644 --- a/lib/core/config/router/app_router.dart +++ b/lib/core/config/router/app_router.dart @@ -86,6 +86,10 @@ final appRouterProvider = Provider((ref) { return GoRouter( navigatorKey: rootNavigatorKey, initialLocation: AppRoutes.home, + // App links are consumed explicitly by AppLinksDeepLinkService. If + // go_router also uses the platform launch URL during cold start, the same + // deep link can be inserted twice and leave a stale page under Back. + overridePlatformDefaultLocation: true, debugLogDiagnostics: true, observers: [ref.read(analyticsServiceProvider).routeObserver], diff --git a/lib/core/constants/app_assets.dart b/lib/core/constants/app_assets.dart index 78653f42..ee944d38 100644 --- a/lib/core/constants/app_assets.dart +++ b/lib/core/constants/app_assets.dart @@ -70,9 +70,11 @@ class AppAssets { // ========== COMMON UI ICONS ========== static const IconData caretRight = PhosphorIconsRegular.caretRight; + static const IconData caretRight2 = PhosphorIconsBold.caretRight; static const IconData caretDown = PhosphorIconsRegular.caretDown; static const IconData caretUp = PhosphorIconsRegular.caretUp; static const IconData caretLeft = PhosphorIconsRegular.caretLeft; + static const IconData caretLeft2 = PhosphorIconsBold.caretLeft; static const IconData arrowSquareOut = PhosphorIconsRegular.arrowSquareOut; static const IconData arrowLeft = PhosphorIconsRegular.arrowLeft; static const IconData lock = PhosphorIconsRegular.lock; @@ -81,7 +83,9 @@ class AppAssets { // ========== ACTION ICONS ========== static const IconData plus = PhosphorIconsRegular.plus; + static const IconData plusCircle = PhosphorIconsRegular.plusCircle; static const IconData minus = PhosphorIconsRegular.minus; + static const IconData minusCircle = PhosphorIconsRegular.minusCircle; static const IconData play = PhosphorIconsFill.play; static const IconData pause = PhosphorIconsFill.pause; static const IconData list = PhosphorIconsRegular.list; diff --git a/lib/core/deep_linking/app_links_deep_link_service.dart b/lib/core/deep_linking/app_links_deep_link_service.dart index 824cf811..ba921b15 100644 --- a/lib/core/deep_linking/app_links_deep_link_service.dart +++ b/lib/core/deep_linking/app_links_deep_link_service.dart @@ -17,7 +17,12 @@ class AppLinksDeepLinkService { StreamSubscription? _subscription; GoRouter? _router; Uri? _pendingUri; + Uri? _lastDispatchedUri; + DateTime? _lastDispatchedAt; bool _initialized = false; + void Function(int tabIndex)? _tabSetter; + + static const Duration _duplicateDispatchWindow = Duration(seconds: 5); Future initialize() async { if (_initialized) return; @@ -50,6 +55,10 @@ class AppLinksDeepLinkService { _router = router; } + void setTabSetter(void Function(int tabIndex) setter) { + _tabSetter = setter; + } + bool drainPendingLink() { if (_router == null) return false; @@ -73,6 +82,16 @@ class AppLinksDeepLinkService { return; } + if (_pendingUri == uri) { + _logger.debug('Ignoring duplicate pending app link: $uri'); + return; + } + + if (_wasRecentlyDispatched(uri)) { + _logger.debug('Ignoring recently dispatched app link: $uri'); + return; + } + if (_router == null) { _pendingUri = uri; _logger.info('Router not ready, stored app link: $uri'); @@ -82,15 +101,34 @@ class AppLinksDeepLinkService { _dispatch(uri); } - void _dispatch(Uri uri, {String? baseLocation}) { + bool _dispatch(Uri uri, {String? baseLocation}) { final router = _router; - if (router == null) return; + if (router == null) return false; + + if (_wasRecentlyDispatched(uri)) { + _logger.debug('Skipping duplicate app link dispatch: $uri'); + return false; + } - DeepLinkRouter.route( + final routed = DeepLinkRouter.route( uri, router, source: 'app_links', baseLocation: baseLocation, + tabSetter: _tabSetter, ); + if (routed) { + _lastDispatchedUri = uri; + _lastDispatchedAt = DateTime.now(); + } + return routed; + } + + bool _wasRecentlyDispatched(Uri uri) { + final lastUri = _lastDispatchedUri; + final lastAt = _lastDispatchedAt; + if (lastUri != uri || lastAt == null) return false; + + return DateTime.now().difference(lastAt) < _duplicateDispatchWindow; } } diff --git a/lib/core/deep_linking/deep_link_router.dart b/lib/core/deep_linking/deep_link_router.dart index 9ff47728..1ef31d05 100644 --- a/lib/core/deep_linking/deep_link_router.dart +++ b/lib/core/deep_linking/deep_link_router.dart @@ -14,6 +14,7 @@ class DeepLinkRouter { GoRouter router, { required String source, String? baseLocation, + void Function(int tabIndex)? tabSetter, }) { try { final destination = _resolveRoute(uri); @@ -33,6 +34,14 @@ class DeepLinkRouter { } else { router.go(destination.location, extra: destination.extra); } + + final tabIndex = destination.tabIndex; + if (tabIndex != null && tabSetter != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + tabSetter(tabIndex); + }); + } + return true; } catch (e, stackTrace) { _logger.error( @@ -67,9 +76,7 @@ class DeepLinkRouter { } if (uri.scheme.toLowerCase() == 'webuddhist') { - return _DeepLinkDestination( - _routeForWebuddhistHost(uri.host.toLowerCase()), - ); + return _resolveWebuddhistSchemeLink(uri.host.toLowerCase()); } if (isAirbridgeLink(uri)) { @@ -127,39 +134,84 @@ class DeepLinkRouter { } } + if (segments.length >= 2 && + segments[0] == 'open' && + segments[1] == 'more') { + return const _DeepLinkDestination(AppRoutes.home, tabIndex: _meTabIndex); + } + + if (segments.length >= 3 && + segments[0] == 'open' && + segments[1] == 'group') { + final groupId = segments[2]; + return _DeepLinkDestination( + '/home/group/$groupId', + opensOnTop: true, + ); + } + + if (segments.length >= 3 && + segments[0] == 'open' && + segments[1] == 'mala') { + final presetId = segments[2]; + return _DeepLinkDestination( + AppRoutes.mala, + extra: {'presetId': presetId}, + opensOnTop: true, + ); + } + + if (segments.length >= 3 && + segments[0] == 'open' && + segments[1] == 'timer') { + return const _DeepLinkDestination( + '/home/timers', + opensOnTop: true, + ); + } + return const _DeepLinkDestination(AppRoutes.home); } - static String _routeForWebuddhistHost(String host) { + static _DeepLinkDestination _resolveWebuddhistSchemeLink(String host) { switch (host) { case 'open': case 'home': - return AppRoutes.home; + return const _DeepLinkDestination(AppRoutes.home); case 'practice': - return AppRoutes.practice; + return const _DeepLinkDestination(AppRoutes.practice); case 'texts': - return AppRoutes.texts; + return const _DeepLinkDestination(AppRoutes.texts); case 'more': - return AppRoutes.more; + return const _DeepLinkDestination(AppRoutes.home, tabIndex: _meTabIndex); case 'profile': - return AppRoutes.profile; + return const _DeepLinkDestination(AppRoutes.profile); case 'notifications': - return AppRoutes.notifications; + return const _DeepLinkDestination(AppRoutes.notifications); default: _logger.warning('Unknown webuddhist deep link host: $host'); - return AppRoutes.home; + return const _DeepLinkDestination(AppRoutes.home); } } } +/// Bottom nav tab index for the Me screen (matches MainTab.me.index == 3). +const int _meTabIndex = 3; + class _DeepLinkDestination { final String location; final Object? extra; final bool opensOnTop; + /// When non-null, the deep-link handler should switch the bottom nav bar to + /// this tab index after navigating. Used for tab-based screens that share + /// the `/home` route (e.g. the Me tab). + final int? tabIndex; + const _DeepLinkDestination( this.location, { this.extra, this.opensOnTop = false, + this.tabIndex, }); } diff --git a/lib/core/deep_linking/deep_link_url_builder.dart b/lib/core/deep_linking/deep_link_url_builder.dart index b9f1a81f..aad35e26 100644 --- a/lib/core/deep_linking/deep_link_url_builder.dart +++ b/lib/core/deep_linking/deep_link_url_builder.dart @@ -42,4 +42,32 @@ class DeepLinkUrlBuilder { queryParameters: queryParameters, ); } + + static Uri moreLink() { + return Uri(scheme: 'https', host: _host, pathSegments: ['open', 'more']); + } + + static Uri groupLink({required String groupId}) { + return Uri( + scheme: 'https', + host: _host, + pathSegments: ['open', 'group', groupId], + ); + } + + static Uri malaLink({required String presetId}) { + return Uri( + scheme: 'https', + host: _host, + pathSegments: ['open', 'mala', presetId], + ); + } + + static Uri timerLink({required String timerId}) { + return Uri( + scheme: 'https', + host: _host, + pathSegments: ['open', 'timer', timerId], + ); + } } diff --git a/lib/core/l10n/app_bo.arb b/lib/core/l10n/app_bo.arb index e7a037e8..73805056 100644 --- a/lib/core/l10n/app_bo.arb +++ b/lib/core/l10n/app_bo.arb @@ -56,7 +56,14 @@ "home_shortcut_plans": "ཉམས་ལེན།", "home_chants": "ཞལ་འདོན།", "home_mala": "ཕྲེང་བ།", + "session_mala": "ཕྲེང་བ་རྣམས།", + "bookmark_mala": "ཕྲེང་བ་རྣམས།", + "bookmark_timers": "སྒོམ་ཐུན་རྣམས།", + "bookmark_texts": "གསུང་རབ།", "mala_add_to_practice": "ངའི་ཉམས་ལེན་ནང་སྣོན།", + "mala_add_mala_round": "Add mala round", + "mala_add_rounds_title": "Add mala rounds:", + "mala_add_rounds_message": "Add the number of mala rounds you did outside this app.", "mala_add_to_bookmark": "དཔེ་རྟགས།", "mala_sound": "སྒྲ།", "mala_vibration": "འདར་འགུལ།", @@ -710,10 +717,29 @@ "group_links_title": "འབྲེལ་ཐག", "group_and_more_links": "དང་འབྲེལ་ཐག་གཞན་ {count}", "group_practice_with_us": "ང་ཚོ་དང་མཉམ་དུ་སྒོམ་གྱི།", + "group_join_to_contribute": "བསགས་གྲངས་སྐྱེལ་ཆེད་ཞུགས།", + "group_accumulator_join_error": "བསགས་གྲངས་ལ་ཞུགས་མ་ཐུབ། ཡང་བསྐྱར་ཚོད་ལྟ་གནང་རོགས།", + "group_accumulator_participants": "མཉམ་ཞུགས་པ་ {count}", + "group_accumulator_leaderboard": "རིམ་པའི་ཐོ།", + "group_accumulator_my_contributions": "ངའི་སྐྱེལ་མ།", + "group_accumulator_recited": "བཟླས་བརྗོད།", + "group_accumulator_total": "ཁྱོན་བསྡོམས།", + "group_accumulator_contributions_empty": "ཁྱེད་ཀྱི་སྐྱེལ་མ་ལ་རྗེས་འདེད་བྱེད་ཆེད་བསགས་གྲངས་འདིར་ཞུགས་རོགས།", + "group_accumulator_recite_now": "ད་ལྟ་བཟླས་བརྗོད་གནང་།", "share_this_quote": "གསུང་ཚིག་འདི་སྤེལ།", "shared_from": "སྤེལ་ཁུངས།", "verse_share_error": "གསུང་ཚིག་སྤེལ་མ་ཐུབ། བསྐྱར་དུ་ཚོད་ལྟ་བྱོས།", + "share_app_message": "ངས་ཉིན་རེའི་ནང་ཆོས་ཉམས་ལེན་གྱི་སྤྱོད་ལམ་སྦྱོང་བར་འཕྲིན་ཆས་འདི་བཀོལ་སྤྱོད་བྱེད་ཀྱི་ཡོད། ཁྱོད་ཀྱིས་ཀྱང་ལས་དེ་དགའ་བར་ངས་བསམ་གྱི་ཡོད།", + "share_streak_message": "ངས་ཉིན་རེའི་ཉམས་ལེན་གྱི་དུས་ཚོད་གསབ་སྐྱོང་བྱེད་མཁན་ཞིག་ཡིན་པ་ཁྱོད་དང་མཉམ་དུ་བགྲོ་གླེང་བྱེད་འདོད། གྲོགས་པོ་མཉམ་ཞུགས་ཀྱིས་ཉམས་ལེན་སྤེལ་ཐབས་སྟབས་བདེ་ཆེར་འགྲོ། WeBuddhist ཐོག་ང་མཉམ་ཞུགས་མཛོད།", + "share_chant_message": "ངས་མགུར་འདི་ཁྱོད་དང་མཉམ་བགྲོ་བར་འདོད། ཁྱོད་ཀྱིས་དེ་ཉམས་ལེན་བྱེད་ཐུབ་ཅིང་ WeBuddhist ཐོག་མགུར་གཞུང་ཆེན་པོའི་མཛོད་དུ་རྒྱུ་ཐུབ།", + "share_quote_message": "WeBuddhist ཐོག་གི་གསུང་ཚིག་འདི་ངར་དགར་སོང་བས་ཁྱོད་དང་མཉམ་བགྲོ་བར་འདོད། WeBuddhist ཉེར་སྤྱོད་ཐོག་ལྟ་ཀློག་ཕྱི་བཀྲམ་གྱི་ཚིག་རྒྱུག་མང་པོ་དཔྱད།", + "share_mala_message": "ངས་ WeBuddhist ཐོག་གི་གློག་རྡུལ་ཕྲེང་བ་འདི་བཀོལ་སྤྱོད་བྱེད་ཀྱི་ཡོད་ཅིང་ཁྱོད་དང་མཉམ་བགྲོ་བར་འདོད། གང་དུ་ཡིན་ཡང་ཉམས་ལེན་བྱེད་ལ་ཐབས་སྟབས་བདེ་བ་ཞིག་ཡིན།", + "share_passage_message": "ངས་ཚིགས་མ་འདི་དགར་སོང་བས་ཁྱོད་དང་མཉམ་བགྲོ་བར་འདོད། ཁྱོད་ཀྱིས་ WeBuddhist ཐོག་གི་ཚིག་རྒྱུག་མཐར་ཐུག་གི་ལྟ་ཀློག་བྱེད་ཐུབ།", + "share_timer_message": "ངས་ WeBuddhist ཐོག་གི་བསམ་གཏན་དུས་ཚོད་རྩིས་འདི་ཁྱོད་དང་མཉམ་བགྲོ་བར་འདོད། བསམ་གཏན་གྱི་ཉམས་ལེན་ཡར་སྤེལ་བར་ཐབས་སྟབས་བདེ།", + "share_plan_message": "ངས་ཆོས་ཉམས་ལེན་གྱི་འཆར་གཞི་འདི་རྗེས་སུ་འབྲང་ཞིང་ཁྱོད་དང་མཉམ་བགྲོ་བར་འདོད། ཁྱོད་ཀྱིས་ WeBuddhist ཐོག་གིས་རིན་མེད་ང་མཉམ་ཞུགས་བྱེད་ཐུབ།", + "share_plan_subject": "WeBuddhist ཐོག་ང་མཉམ་ཞུགས་མཛོད།", + "share_group_invite_message": "ངའི་སྡེ་ཚན་དུ་ཁྱོད་ཞུགས་ན་སྙིང་གི་དགའ་བ། WeBuddhist ཐོག་མཉམ་དུ་ཉམས་ལེན་བྱོས།", "connect_subtitle": "ཁྱེད་ཀྱི་ཚོགས་པ་བཙལ་ཏེ་མཉམ་དུ་ཉམས་ལེན་བྱོས།", "discover_groups": "ཚོགས་པ་འཚོལ།", "my_groups": "ངའི་ཚོགས་པ།", diff --git a/lib/core/l10n/app_en.arb b/lib/core/l10n/app_en.arb index 7367f197..93dc6f12 100644 --- a/lib/core/l10n/app_en.arb +++ b/lib/core/l10n/app_en.arb @@ -56,7 +56,14 @@ "home_shortcut_plans": "Plans", "home_chants": "Chants", "home_mala": "Mala", + "session_mala": "Malas", + "bookmark_mala": "Malas", + "bookmark_timers": "Timers", + "bookmark_texts": "Texts", "mala_add_to_practice": "Add to my practices", + "mala_add_mala_round": "Add mala round", + "mala_add_rounds_title": "Add mala rounds:", + "mala_add_rounds_message": "Add the number of mala rounds you did outside this app.", "mala_add_to_bookmark": "Bookmark", "mala_sound": "Sound", "mala_vibration": "Vibration", @@ -804,6 +811,7 @@ "group_accumulator_recited": "Recited", "group_accumulator_total": "Total", "group_accumulator_contributions_empty": "Join this accumulation to track your contributions.", + "group_accumulator_recite_now": "Recite now", "@group_and_more_links": { "placeholders": { "count": { @@ -815,6 +823,16 @@ "share_this_quote": "Share this quote", "shared_from": "Shared from", "verse_share_error": "Unable to share quote. Please try again", + "share_app_message": "I've been using this app to build a daily Buddhist practice, and thought you'd love it.", + "share_streak_message": "I've been building a daily practice habit and wanted to share it with you. It's easier to keep it up with a friend. Join me on WeBuddhist.", + "share_chant_message": "I wanted to share this chant with you. You can practice it and find a whole library of other chants and texts on WeBuddhist.", + "share_quote_message": "I liked this quote from WeBuddhist and wanted to share it with you. Read more such insightful quotes on the WeBuddhist App", + "share_mala_message": "I've been using this digital mala on WeBuddhist and wanted to share it with you. It's an easy way to practice wherever you go.", + "share_passage_message": "I liked this passage and wanted to share it with you. You can read the whole quote in context on WeBuddhist.", + "share_timer_message": "I wanted to share this meditation timer from WeBuddhist with you. It makes it easy to build a meditation practice.", + "share_plan_message": "I'm following this Buddhist practice plan and wanted to share it with you. You can join me for free on WeBuddhist.", + "share_plan_subject": "Join me on WeBuddhist", + "share_group_invite_message": "I'd love for you to join our group. Let's practice together on WeBuddhist.", "weekday_monday": "MON", "weekday_tuesday": "TUE", "weekday_wednesday": "WED", diff --git a/lib/core/l10n/app_hi.arb b/lib/core/l10n/app_hi.arb index 1c2e2798..547dce5b 100644 --- a/lib/core/l10n/app_hi.arb +++ b/lib/core/l10n/app_hi.arb @@ -56,7 +56,14 @@ "home_shortcut_plans": "योजनाएँ", "home_chants": "जप", "home_mala": "माला", + "session_mala": "मालाएँ", + "bookmark_mala": "मालाएँ", + "bookmark_timers": "टाइमर", + "bookmark_texts": "ग्रंथ", "mala_add_to_practice": "मेरे अभ्यास में जोड़ें", + "mala_add_mala_round": "Add mala round", + "mala_add_rounds_title": "Add mala rounds:", + "mala_add_rounds_message": "Add the number of mala rounds you did outside this app.", "mala_add_to_bookmark": "बुकमार्क", "mala_sound": "ध्वनि", "mala_vibration": "कंपन", @@ -776,10 +783,29 @@ "group_links_title": "लिंक", "group_and_more_links": "और {count} और लिंक", "group_practice_with_us": "हमारे साथ अभ्यास करें", + "group_join_to_contribute": "योगदान के लिए शामिल हों", + "group_accumulator_join_error": "संचय में शामिल होने में असमर्थ। कृपया पुनः प्रयास करें।", + "group_accumulator_participants": "{count} प्रतिभागी", + "group_accumulator_leaderboard": "लीडरबोर्ड", + "group_accumulator_my_contributions": "मेरा योगदान", + "group_accumulator_recited": "जाप", + "group_accumulator_total": "कुल", + "group_accumulator_contributions_empty": "अपने योगदान को ट्रैक करने के लिए इस संचय में शामिल हों।", + "group_accumulator_recite_now": "अभी जाप करें", "share_this_quote": "यह उद्धरण शेयर करें", "shared_from": "यहाँ से शेयर किया गया", "verse_share_error": "उद्धरण शेयर नहीं हो सका। कृपया फिर से प्रयास करें", + "share_app_message": "मैं इस ऐप का उपयोग दैनिक बौद्ध अभ्यास बनाने के लिए कर रहा हूँ, और मुझे लगा कि आपको यह पसंद आएगा।", + "share_streak_message": "मैं एक दैनिक अभ्यास की आदत बना रहा हूँ और इसे आपके साथ साझा करना चाहता था। किसी मित्र के साथ इसे जारी रखना आसान होता है। WeBuddhist पर मेरे साथ जुड़ें।", + "share_chant_message": "मैं यह मंत्र आपके साथ साझा करना चाहता था। आप इसका अभ्यास कर सकते हैं और WeBuddhist पर अन्य मंत्रों और ग्रंथों का पूरा संग्रह पा सकते हैं।", + "share_quote_message": "मुझे WeBuddhist का यह उद्धरण पसंद आया और मैं इसे आपके साथ साझा करना चाहता था। WeBuddhist ऐप पर और अधिक ऐसे ज्ञानवर्धक उद्धरण पढ़ें।", + "share_mala_message": "मैं WeBuddhist पर इस डिजिटल माला का उपयोग कर रहा हूँ और इसे आपके साथ साझा करना चाहता था। यह जहाँ भी जाएँ, अभ्यास करने का एक आसान तरीका है।", + "share_passage_message": "मुझे यह अंश पसंद आया और मैं इसे आपके साथ साझा करना चाहता था। आप WeBuddhist पर पूरा संदर्भ पढ़ सकते हैं।", + "share_timer_message": "मैं WeBuddhist का यह ध्यान टाइमर आपके साथ साझा करना चाहता था। यह ध्यान अभ्यास बनाने में आसान बनाता है।", + "share_plan_message": "मैं इस बौद्ध अभ्यास योजना का अनुसरण कर रहा हूँ और इसे आपके साथ साझा करना चाहता था। आप WeBuddhist पर मुफ़्त में मेरे साथ जुड़ सकते हैं।", + "share_plan_subject": "WeBuddhist पर मेरे साथ जुड़ें", + "share_group_invite_message": "मैं चाहता हूँ कि आप हमारे समूह में शामिल हों। WeBuddhist पर एक साथ अभ्यास करते हैं।", "connect_subtitle": "अपने समूह खोजें और साथ मिलकर अभ्यास करें", "discover_groups": "समूह खोजें", "my_groups": "मेरे समूह", diff --git a/lib/core/l10n/app_mn.arb b/lib/core/l10n/app_mn.arb index 9a2dc0ba..16dd70a8 100644 --- a/lib/core/l10n/app_mn.arb +++ b/lib/core/l10n/app_mn.arb @@ -56,7 +56,14 @@ "home_shortcut_plans": "Төлөвлөгөө", "home_chants": "Магтаал", "home_mala": "Эрх", + "session_mala": "Эрхүүд", + "bookmark_mala": "Эрхүүд", + "bookmark_timers": "Цаг хэмжигчид", + "bookmark_texts": "Бичвэрүүд", "mala_add_to_practice": "Миний дадлагад нэмэх", + "mala_add_mala_round": "Add mala round", + "mala_add_rounds_title": "Add mala rounds:", + "mala_add_rounds_message": "Add the number of mala rounds you did outside this app.", "mala_add_to_bookmark": "Хавчуурга", "mala_sound": "Дуу", "mala_vibration": "Чичиргээ", @@ -776,10 +783,29 @@ "group_links_title": "Холбоосууд", "group_and_more_links": "болон дахиад {count} холбоос", "group_practice_with_us": "Бидэнтэй хамт дадлага хий", + "group_join_to_contribute": "Хувь нэмэр оруулахын тулд нэгдэх", + "group_accumulator_join_error": "Хуримтлалд нэгдэх боломжгүй байна. Дахин оролдоно уу.", + "group_accumulator_participants": "{count} оролцогч", + "group_accumulator_leaderboard": "Тэргүүлэгчдийн самбар", + "group_accumulator_my_contributions": "Миний хувь нэмэр", + "group_accumulator_recited": "Уншсан", + "group_accumulator_total": "Нийт", + "group_accumulator_contributions_empty": "Хувь нэмрээ хянахын тулд энэ хуримтлалд нэгдэнэ үү.", + "group_accumulator_recite_now": "Одоо уншина уу", "share_this_quote": "Энэ ишлэлийг хуваалцах", "shared_from": "Хуваалцсан:", "verse_share_error": "Ишлэлийг хуваалцах боломжгүй. Дахин оролдоно уу", + "share_app_message": "Би өдөр тутмын Буддын дадлага хийхийн тулд энэ аппыг ашиглаж байна, та ч дуртай болно гэж бодлоо.", + "share_streak_message": "Би өдөр тутмын дадлагын зуршил хэлбэрийг бүрдүүлж байгаа бөгөөд үүнийгээ танд хуваалцахыг хүссэн. Найзтайгаа хамт хэвшлээ хадгалахад илүү хялбар байдаг. WeBuddhist дээр надтай нэгдээрэй.", + "share_chant_message": "Би энэ дуулалыг тантай хуваалцахыг хүссэн. Та үүнийг дадлагажуулж, WeBuddhist дээр бусад дуулал болон судруудын бүтэн номын санг олж болно.", + "share_quote_message": "Надад WeBuddhist дээрх энэ ишлэл таалагдсан тул тантай хуваалцахыг хүссэн. WeBuddhist аппликейшн дээр ийм мэдлэгтэй ишлэлүүдийг уншаарай.", + "share_mala_message": "Би WeBuddhist дээрх энэ тоолуурыг ашиглаж байгаа бөгөөд тантай хуваалцахыг хүссэн. Хаана ч байсан дадлагажуулахад хялбар арга юм.", + "share_passage_message": "Надад энэ хэсэг таалагдсан тул тантай хуваалцахыг хүссэн. WeBuddhist дээр бүтэн ишлэлийг уншиж болно.", + "share_timer_message": "Би WeBuddhist дээрх энэ бясалгалын таймерыг тантай хуваалцахыг хүссэн. Бясалгалын дадлага хийхэд хялбар болгодог.", + "share_plan_message": "Би энэ Буддын дадлагын төлөвлөгөөг дагаж байгаа бөгөөд тантай хуваалцахыг хүссэн. WeBuddhist дээр надтай үнэгүй нэгдэж болно.", + "share_plan_subject": "WeBuddhist дээр надтай нэгдээрэй", + "share_group_invite_message": "Таны манай бүлэгт нэгдсэнийг хүсч байна. WeBuddhist дээр хамтдаа дадлагажъя.", "connect_subtitle": "Бүлгээ олж, хамтдаа дадлага хийгээрэй", "discover_groups": "Бүлэг хайх", "my_groups": "Миний бүлгүүд", diff --git a/lib/core/l10n/app_ne.arb b/lib/core/l10n/app_ne.arb index d929ff6e..5e34c31d 100644 --- a/lib/core/l10n/app_ne.arb +++ b/lib/core/l10n/app_ne.arb @@ -56,7 +56,14 @@ "home_shortcut_plans": "योजनाहरू", "home_chants": "जप", "home_mala": "माला", + "session_mala": "मालाहरू", + "bookmark_mala": "मालाहरू", + "bookmark_timers": "टाइमरहरू", + "bookmark_texts": "ग्रन्थहरू", "mala_add_to_practice": "मेरा अभ्यासहरूमा थप्नुहोस्", + "mala_add_mala_round": "Add mala round", + "mala_add_rounds_title": "Add mala rounds:", + "mala_add_rounds_message": "Add the number of mala rounds you did outside this app.", "mala_add_to_bookmark": "बुकमार्क", "mala_sound": "ध्वनि", "mala_vibration": "कम्पन", @@ -776,10 +783,29 @@ "group_links_title": "लिङ्कहरू", "group_and_more_links": "र अरू {count} लिङ्कहरू", "group_practice_with_us": "हामीसँग अभ्यास गर्नुहोस्", + "group_join_to_contribute": "योगदानका लागि सामेल हुनुहोस्", + "group_accumulator_join_error": "संचयमा सामेल हुन असमर्थ। कृपया फेरि प्रयास गर्नुहोस्।", + "group_accumulator_participants": "{count} सहभागी", + "group_accumulator_leaderboard": "लिडरबोर्ड", + "group_accumulator_my_contributions": "मेरो योगदान", + "group_accumulator_recited": "जप", + "group_accumulator_total": "कुल", + "group_accumulator_contributions_empty": "आफ्नो योगदान ट्र्याक गर्न यो संचयमा सामेल हुनुहोस्।", + "group_accumulator_recite_now": "अहिले जप गर्नुहोस्", "share_this_quote": "यो उद्धरण साझा गर्नुहोस्", "shared_from": "बाट साझा गरिएको", "verse_share_error": "उद्धरण साझा गर्न सकिएन। कृपया फेरि प्रयास गर्नुहोस्", + "share_app_message": "म यो एप प्रयोग गरेर दैनिक बौद्ध अभ्यास निर्माण गर्दैछु, र मलाई लाग्यो तपाईंलाई पनि यो मन पर्नेछ।", + "share_streak_message": "म दैनिक अभ्यासको बानी बनाउँदैछु र यो तपाईंसँग साझा गर्न चाहन्थें। साथीसँग मिलेर यसलाई जारी राख्न सजिलो हुन्छ। WeBuddhist मा मसँग सामेल हुनुहोस्।", + "share_chant_message": "म यो जप तपाईंसँग साझा गर्न चाहन्थें। तपाईंले यसको अभ्यास गर्न र WeBuddhist मा अन्य जप र ग्रन्थहरूको पूर्ण पुस्तकालय फेला पार्न सक्नुहुन्छ।", + "share_quote_message": "मलाई WeBuddhist को यो उद्धरण मन पर्यो र तपाईंसँग साझा गर्न चाहन्थें। WeBuddhist एपमा यस्ता थप ज्ञानवर्धक उद्धरणहरू पढ्नुहोस्।", + "share_mala_message": "म WeBuddhist मा यो डिजिटल माला प्रयोग गर्दैछु र तपाईंसँग साझा गर्न चाहन्थें। जहाँ जानुभए पनि अभ्यास गर्न सजिलो तरिका हो।", + "share_passage_message": "मलाई यो अंश मन पर्यो र तपाईंसँग साझा गर्न चाहन्थें। तपाईं WeBuddhist मा पूरा सन्दर्भ पढ्न सक्नुहुन्छ।", + "share_timer_message": "म WeBuddhist को यो ध्यान टाइमर तपाईंसँग साझा गर्न चाहन्थें। यसले ध्यान अभ्यास निर्माण गर्न सजिलो बनाउँछ।", + "share_plan_message": "म यो बौद्ध अभ्यास योजना पछ्याउँदैछु र तपाईंसँग साझा गर्न चाहन्थें। तपाईं WeBuddhist मा मसँग निःशुल्क सामेल हुन सक्नुहुन्छ।", + "share_plan_subject": "WeBuddhist मा मसँग सामेल हुनुहोस्", + "share_group_invite_message": "म चाहन्छु कि तपाईं हाम्रो समूहमा सामेल हुनुहोस्। WeBuddhist मा सँगै अभ्यास गरौं।", "connect_subtitle": "आफ्ना समूहहरू खोज्नुहोस् र सँगै अभ्यास गर्नुहोस्", "discover_groups": "समूहहरू खोज्नुहोस्", "my_groups": "मेरा समूहहरू", diff --git a/lib/core/l10n/app_zh.arb b/lib/core/l10n/app_zh.arb index eeab5025..635dd0e4 100644 --- a/lib/core/l10n/app_zh.arb +++ b/lib/core/l10n/app_zh.arb @@ -56,7 +56,14 @@ "home_shortcut_plans": "計劃", "home_chants": "持誦", "home_mala": "念珠", + "session_mala": "念珠", + "bookmark_mala": "念珠", + "bookmark_timers": "計時", + "bookmark_texts": "經文", "mala_add_to_practice": "加入我的修持", + "mala_add_mala_round": "Add mala round", + "mala_add_rounds_title": "Add mala rounds:", + "mala_add_rounds_message": "Add the number of mala rounds you did outside this app.", "mala_add_to_bookmark": "書籤", "mala_sound": "聲音", "mala_vibration": "震動", @@ -710,10 +717,29 @@ "group_links_title": "連結", "group_and_more_links": "及另外 {count} 個連結", "group_practice_with_us": "與我們一起修行", + "group_join_to_contribute": "加入以貢獻", + "group_accumulator_join_error": "無法加入累積,請再試一次。", + "group_accumulator_participants": "{count} 位參與者", + "group_accumulator_leaderboard": "排行榜", + "group_accumulator_my_contributions": "我的貢獻", + "group_accumulator_recited": "已誦讀", + "group_accumulator_total": "總數", + "group_accumulator_contributions_empty": "加入此累積以追蹤您的貢獻。", + "group_accumulator_recite_now": "立即誦念", "share_this_quote": "分享这句话", "shared_from": "分享自", "verse_share_error": "无法分享引文,请重试", + "share_app_message": "我一直在用这款应用培养每日佛法修行的习惯,觉得你也会喜欢。", + "share_streak_message": "我一直在养成每日修行的好习惯,想和你分享。有朋友一起坚持会更容易。来WeBuddhist和我一起吧。", + "share_chant_message": "我想和你分享这段诵文。你可以在WeBuddhist上练习它,还能找到整个诵文和经典库。", + "share_quote_message": "我喜欢WeBuddhist上的这段引语,想和你分享。在WeBuddhist应用上阅读更多深刻的引语。", + "share_mala_message": "我一直在WeBuddhist上使用这串电子念珠,想和你分享。这是一个随时随地修行的便捷方式。", + "share_passage_message": "我喜欢这段经文,想和你分享。你可以在WeBuddhist上阅读完整的上下文。", + "share_timer_message": "我想和你分享WeBuddhist上的这个禅修计时器。它让建立禅修习惯变得更轻松。", + "share_plan_message": "我正在跟随这个佛法修行计划,想和你分享。你可以在WeBuddhist上免费和我一起修行。", + "share_plan_subject": "在WeBuddhist上和我一起吧", + "share_group_invite_message": "我希望你能加入我们的群组。让我们在WeBuddhist上一起修行吧。", "connect_subtitle": "尋找你的社群,一起修持", "discover_groups": "探索社群", "my_groups": "我的社群", diff --git a/lib/core/l10n/generated/app_localizations.dart b/lib/core/l10n/generated/app_localizations.dart index 956f5e55..41f3cf21 100644 --- a/lib/core/l10n/generated/app_localizations.dart +++ b/lib/core/l10n/generated/app_localizations.dart @@ -358,12 +358,54 @@ abstract class AppLocalizations { /// **'Mala'** String get home_mala; + /// No description provided for @session_mala. + /// + /// In en, this message translates to: + /// **'Malas'** + String get session_mala; + + /// No description provided for @bookmark_mala. + /// + /// In en, this message translates to: + /// **'Malas'** + String get bookmark_mala; + + /// No description provided for @bookmark_timers. + /// + /// In en, this message translates to: + /// **'Timers'** + String get bookmark_timers; + + /// No description provided for @bookmark_texts. + /// + /// In en, this message translates to: + /// **'Texts'** + String get bookmark_texts; + /// No description provided for @mala_add_to_practice. /// /// In en, this message translates to: /// **'Add to my practices'** String get mala_add_to_practice; + /// No description provided for @mala_add_mala_round. + /// + /// In en, this message translates to: + /// **'Add mala round'** + String get mala_add_mala_round; + + /// No description provided for @mala_add_rounds_title. + /// + /// In en, this message translates to: + /// **'Add mala rounds:'** + String get mala_add_rounds_title; + + /// No description provided for @mala_add_rounds_message. + /// + /// In en, this message translates to: + /// **'Add the number of mala rounds you did outside this app.'** + String get mala_add_rounds_message; + /// No description provided for @mala_add_to_bookmark. /// /// In en, this message translates to: @@ -3028,6 +3070,12 @@ abstract class AppLocalizations { /// **'Join this accumulation to track your contributions.'** String get group_accumulator_contributions_empty; + /// No description provided for @group_accumulator_recite_now. + /// + /// In en, this message translates to: + /// **'Recite now'** + String get group_accumulator_recite_now; + /// No description provided for @share_this_quote. /// /// In en, this message translates to: @@ -3046,6 +3094,66 @@ abstract class AppLocalizations { /// **'Unable to share quote. Please try again'** String get verse_share_error; + /// No description provided for @share_app_message. + /// + /// In en, this message translates to: + /// **'I\'ve been using this app to build a daily Buddhist practice, and thought you\'d love it.'** + String get share_app_message; + + /// No description provided for @share_streak_message. + /// + /// In en, this message translates to: + /// **'I\'ve been building a daily practice habit and wanted to share it with you. It\'s easier to keep it up with a friend. Join me on WeBuddhist.'** + String get share_streak_message; + + /// No description provided for @share_chant_message. + /// + /// In en, this message translates to: + /// **'I wanted to share this chant with you. You can practice it and find a whole library of other chants and texts on WeBuddhist.'** + String get share_chant_message; + + /// No description provided for @share_quote_message. + /// + /// In en, this message translates to: + /// **'I liked this quote from WeBuddhist and wanted to share it with you. Read more such insightful quotes on the WeBuddhist App'** + String get share_quote_message; + + /// No description provided for @share_mala_message. + /// + /// In en, this message translates to: + /// **'I\'ve been using this digital mala on WeBuddhist and wanted to share it with you. It\'s an easy way to practice wherever you go.'** + String get share_mala_message; + + /// No description provided for @share_passage_message. + /// + /// In en, this message translates to: + /// **'I liked this passage and wanted to share it with you. You can read the whole quote in context on WeBuddhist.'** + String get share_passage_message; + + /// No description provided for @share_timer_message. + /// + /// In en, this message translates to: + /// **'I wanted to share this meditation timer from WeBuddhist with you. It makes it easy to build a meditation practice.'** + String get share_timer_message; + + /// No description provided for @share_plan_message. + /// + /// In en, this message translates to: + /// **'I\'m following this Buddhist practice plan and wanted to share it with you. You can join me for free on WeBuddhist.'** + String get share_plan_message; + + /// No description provided for @share_plan_subject. + /// + /// In en, this message translates to: + /// **'Join me on WeBuddhist'** + String get share_plan_subject; + + /// No description provided for @share_group_invite_message. + /// + /// In en, this message translates to: + /// **'I\'d love for you to join our group. Let\'s practice together on WeBuddhist.'** + String get share_group_invite_message; + /// No description provided for @weekday_monday. /// /// In en, this message translates to: diff --git a/lib/core/l10n/generated/app_localizations_bo.dart b/lib/core/l10n/generated/app_localizations_bo.dart index 660ddf25..baa90f7e 100644 --- a/lib/core/l10n/generated/app_localizations_bo.dart +++ b/lib/core/l10n/generated/app_localizations_bo.dart @@ -148,9 +148,31 @@ class AppLocalizationsBo extends AppLocalizations { @override String get home_mala => 'ཕྲེང་བ།'; + @override + String get session_mala => 'ཕྲེང་བ་རྣམས།'; + + @override + String get bookmark_mala => 'ཕྲེང་བ་རྣམས།'; + + @override + String get bookmark_timers => 'སྒོམ་ཐུན་རྣམས།'; + + @override + String get bookmark_texts => 'གསུང་རབ།'; + @override String get mala_add_to_practice => 'ངའི་ཉམས་ལེན་ནང་སྣོན།'; + @override + String get mala_add_mala_round => 'Add mala round'; + + @override + String get mala_add_rounds_title => 'Add mala rounds:'; + + @override + String get mala_add_rounds_message => + 'Add the number of mala rounds you did outside this app.'; + @override String get mala_add_to_bookmark => 'དཔེ་རྟགས།'; @@ -1646,32 +1668,35 @@ class AppLocalizationsBo extends AppLocalizations { 'You are already practicing this plan with another group. Would you like to change your practice group?'; @override - String get group_join_to_contribute => 'Join to contribute'; + String get group_join_to_contribute => 'བསགས་གྲངས་སྐྱེལ་ཆེད་ཞུགས།'; @override String get group_accumulator_join_error => - 'Unable to join accumulation. Please try again.'; + 'བསགས་གྲངས་ལ་ཞུགས་མ་ཐུབ། ཡང་བསྐྱར་ཚོད་ལྟ་གནང་རོགས།'; @override String group_accumulator_participants(int count) { - return '$count participants'; + return 'མཉམ་ཞུགས་པ་ $count'; } @override - String get group_accumulator_leaderboard => 'Leaderboard'; + String get group_accumulator_leaderboard => 'རིམ་པའི་ཐོ།'; @override - String get group_accumulator_my_contributions => 'My Contributions'; + String get group_accumulator_my_contributions => 'ངའི་སྐྱེལ་མ།'; @override - String get group_accumulator_recited => 'Recited'; + String get group_accumulator_recited => 'བཟླས་བརྗོད།'; @override - String get group_accumulator_total => 'Total'; + String get group_accumulator_total => 'ཁྱོན་བསྡོམས།'; @override String get group_accumulator_contributions_empty => - 'Join this accumulation to track your contributions.'; + 'ཁྱེད་ཀྱི་སྐྱེལ་མ་ལ་རྗེས་འདེད་བྱེད་ཆེད་བསགས་གྲངས་འདིར་ཞུགས་རོགས།'; + + @override + String get group_accumulator_recite_now => 'ད་ལྟ་བཟླས་བརྗོད་གནང་།'; @override String get share_this_quote => 'གསུང་ཚིག་འདི་སྤེལ།'; @@ -1682,6 +1707,45 @@ class AppLocalizationsBo extends AppLocalizations { @override String get verse_share_error => 'གསུང་ཚིག་སྤེལ་མ་ཐུབ། བསྐྱར་དུ་ཚོད་ལྟ་བྱོས།'; + @override + String get share_app_message => + 'ངས་ཉིན་རེའི་ནང་ཆོས་ཉམས་ལེན་གྱི་སྤྱོད་ལམ་སྦྱོང་བར་འཕྲིན་ཆས་འདི་བཀོལ་སྤྱོད་བྱེད་ཀྱི་ཡོད། ཁྱོད་ཀྱིས་ཀྱང་ལས་དེ་དགའ་བར་ངས་བསམ་གྱི་ཡོད།'; + + @override + String get share_streak_message => + 'ངས་ཉིན་རེའི་ཉམས་ལེན་གྱི་དུས་ཚོད་གསབ་སྐྱོང་བྱེད་མཁན་ཞིག་ཡིན་པ་ཁྱོད་དང་མཉམ་དུ་བགྲོ་གླེང་བྱེད་འདོད། གྲོགས་པོ་མཉམ་ཞུགས་ཀྱིས་ཉམས་ལེན་སྤེལ་ཐབས་སྟབས་བདེ་ཆེར་འགྲོ། WeBuddhist ཐོག་ང་མཉམ་ཞུགས་མཛོད།'; + + @override + String get share_chant_message => + 'ངས་མགུར་འདི་ཁྱོད་དང་མཉམ་བགྲོ་བར་འདོད། ཁྱོད་ཀྱིས་དེ་ཉམས་ལེན་བྱེད་ཐུབ་ཅིང་ WeBuddhist ཐོག་མགུར་གཞུང་ཆེན་པོའི་མཛོད་དུ་རྒྱུ་ཐུབ།'; + + @override + String get share_quote_message => + 'WeBuddhist ཐོག་གི་གསུང་ཚིག་འདི་ངར་དགར་སོང་བས་ཁྱོད་དང་མཉམ་བགྲོ་བར་འདོད། WeBuddhist ཉེར་སྤྱོད་ཐོག་ལྟ་ཀློག་ཕྱི་བཀྲམ་གྱི་ཚིག་རྒྱུག་མང་པོ་དཔྱད།'; + + @override + String get share_mala_message => + 'ངས་ WeBuddhist ཐོག་གི་གློག་རྡུལ་ཕྲེང་བ་འདི་བཀོལ་སྤྱོད་བྱེད་ཀྱི་ཡོད་ཅིང་ཁྱོད་དང་མཉམ་བགྲོ་བར་འདོད། གང་དུ་ཡིན་ཡང་ཉམས་ལེན་བྱེད་ལ་ཐབས་སྟབས་བདེ་བ་ཞིག་ཡིན།'; + + @override + String get share_passage_message => + 'ངས་ཚིགས་མ་འདི་དགར་སོང་བས་ཁྱོད་དང་མཉམ་བགྲོ་བར་འདོད། ཁྱོད་ཀྱིས་ WeBuddhist ཐོག་གི་ཚིག་རྒྱུག་མཐར་ཐུག་གི་ལྟ་ཀློག་བྱེད་ཐུབ།'; + + @override + String get share_timer_message => + 'ངས་ WeBuddhist ཐོག་གི་བསམ་གཏན་དུས་ཚོད་རྩིས་འདི་ཁྱོད་དང་མཉམ་བགྲོ་བར་འདོད། བསམ་གཏན་གྱི་ཉམས་ལེན་ཡར་སྤེལ་བར་ཐབས་སྟབས་བདེ།'; + + @override + String get share_plan_message => + 'ངས་ཆོས་ཉམས་ལེན་གྱི་འཆར་གཞི་འདི་རྗེས་སུ་འབྲང་ཞིང་ཁྱོད་དང་མཉམ་བགྲོ་བར་འདོད། ཁྱོད་ཀྱིས་ WeBuddhist ཐོག་གིས་རིན་མེད་ང་མཉམ་ཞུགས་བྱེད་ཐུབ།'; + + @override + String get share_plan_subject => 'WeBuddhist ཐོག་ང་མཉམ་ཞུགས་མཛོད།'; + + @override + String get share_group_invite_message => + 'ངའི་སྡེ་ཚན་དུ་ཁྱོད་ཞུགས་ན་སྙིང་གི་དགའ་བ། WeBuddhist ཐོག་མཉམ་དུ་ཉམས་ལེན་བྱོས།'; + @override String get weekday_monday => 'ཟླ།'; diff --git a/lib/core/l10n/generated/app_localizations_en.dart b/lib/core/l10n/generated/app_localizations_en.dart index 5e8d1048..e685448f 100644 --- a/lib/core/l10n/generated/app_localizations_en.dart +++ b/lib/core/l10n/generated/app_localizations_en.dart @@ -146,9 +146,31 @@ class AppLocalizationsEn extends AppLocalizations { @override String get home_mala => 'Mala'; + @override + String get session_mala => 'Malas'; + + @override + String get bookmark_mala => 'Malas'; + + @override + String get bookmark_timers => 'Timers'; + + @override + String get bookmark_texts => 'Texts'; + @override String get mala_add_to_practice => 'Add to my practices'; + @override + String get mala_add_mala_round => 'Add mala round'; + + @override + String get mala_add_rounds_title => 'Add mala rounds:'; + + @override + String get mala_add_rounds_message => + 'Add the number of mala rounds you did outside this app.'; + @override String get mala_add_to_bookmark => 'Bookmark'; @@ -1652,6 +1674,9 @@ class AppLocalizationsEn extends AppLocalizations { String get group_accumulator_contributions_empty => 'Join this accumulation to track your contributions.'; + @override + String get group_accumulator_recite_now => 'Recite now'; + @override String get share_this_quote => 'Share this quote'; @@ -1661,6 +1686,45 @@ class AppLocalizationsEn extends AppLocalizations { @override String get verse_share_error => 'Unable to share quote. Please try again'; + @override + String get share_app_message => + 'I\'ve been using this app to build a daily Buddhist practice, and thought you\'d love it.'; + + @override + String get share_streak_message => + 'I\'ve been building a daily practice habit and wanted to share it with you. It\'s easier to keep it up with a friend. Join me on WeBuddhist.'; + + @override + String get share_chant_message => + 'I wanted to share this chant with you. You can practice it and find a whole library of other chants and texts on WeBuddhist.'; + + @override + String get share_quote_message => + 'I liked this quote from WeBuddhist and wanted to share it with you. Read more such insightful quotes on the WeBuddhist App'; + + @override + String get share_mala_message => + 'I\'ve been using this digital mala on WeBuddhist and wanted to share it with you. It\'s an easy way to practice wherever you go.'; + + @override + String get share_passage_message => + 'I liked this passage and wanted to share it with you. You can read the whole quote in context on WeBuddhist.'; + + @override + String get share_timer_message => + 'I wanted to share this meditation timer from WeBuddhist with you. It makes it easy to build a meditation practice.'; + + @override + String get share_plan_message => + 'I\'m following this Buddhist practice plan and wanted to share it with you. You can join me for free on WeBuddhist.'; + + @override + String get share_plan_subject => 'Join me on WeBuddhist'; + + @override + String get share_group_invite_message => + 'I\'d love for you to join our group. Let\'s practice together on WeBuddhist.'; + @override String get weekday_monday => 'MON'; diff --git a/lib/core/l10n/generated/app_localizations_hi.dart b/lib/core/l10n/generated/app_localizations_hi.dart index cff2a3c2..e6aacc57 100644 --- a/lib/core/l10n/generated/app_localizations_hi.dart +++ b/lib/core/l10n/generated/app_localizations_hi.dart @@ -146,9 +146,31 @@ class AppLocalizationsHi extends AppLocalizations { @override String get home_mala => 'माला'; + @override + String get session_mala => 'मालाएँ'; + + @override + String get bookmark_mala => 'मालाएँ'; + + @override + String get bookmark_timers => 'टाइमर'; + + @override + String get bookmark_texts => 'ग्रंथ'; + @override String get mala_add_to_practice => 'मेरे अभ्यास में जोड़ें'; + @override + String get mala_add_mala_round => 'Add mala round'; + + @override + String get mala_add_rounds_title => 'Add mala rounds:'; + + @override + String get mala_add_rounds_message => + 'Add the number of mala rounds you did outside this app.'; + @override String get mala_add_to_bookmark => 'बुकमार्क'; @@ -1638,32 +1660,35 @@ class AppLocalizationsHi extends AppLocalizations { 'You are already practicing this plan with another group. Would you like to change your practice group?'; @override - String get group_join_to_contribute => 'Join to contribute'; + String get group_join_to_contribute => 'योगदान के लिए शामिल हों'; @override String get group_accumulator_join_error => - 'Unable to join accumulation. Please try again.'; + 'संचय में शामिल होने में असमर्थ। कृपया पुनः प्रयास करें।'; @override String group_accumulator_participants(int count) { - return '$count participants'; + return '$count प्रतिभागी'; } @override - String get group_accumulator_leaderboard => 'Leaderboard'; + String get group_accumulator_leaderboard => 'लीडरबोर्ड'; @override - String get group_accumulator_my_contributions => 'My Contributions'; + String get group_accumulator_my_contributions => 'मेरा योगदान'; @override - String get group_accumulator_recited => 'Recited'; + String get group_accumulator_recited => 'जाप'; @override - String get group_accumulator_total => 'Total'; + String get group_accumulator_total => 'कुल'; @override String get group_accumulator_contributions_empty => - 'Join this accumulation to track your contributions.'; + 'अपने योगदान को ट्रैक करने के लिए इस संचय में शामिल हों।'; + + @override + String get group_accumulator_recite_now => 'अभी जाप करें'; @override String get share_this_quote => 'यह उद्धरण शेयर करें'; @@ -1675,6 +1700,45 @@ class AppLocalizationsHi extends AppLocalizations { String get verse_share_error => 'उद्धरण शेयर नहीं हो सका। कृपया फिर से प्रयास करें'; + @override + String get share_app_message => + 'मैं इस ऐप का उपयोग दैनिक बौद्ध अभ्यास बनाने के लिए कर रहा हूँ, और मुझे लगा कि आपको यह पसंद आएगा।'; + + @override + String get share_streak_message => + 'मैं एक दैनिक अभ्यास की आदत बना रहा हूँ और इसे आपके साथ साझा करना चाहता था। किसी मित्र के साथ इसे जारी रखना आसान होता है। WeBuddhist पर मेरे साथ जुड़ें।'; + + @override + String get share_chant_message => + 'मैं यह मंत्र आपके साथ साझा करना चाहता था। आप इसका अभ्यास कर सकते हैं और WeBuddhist पर अन्य मंत्रों और ग्रंथों का पूरा संग्रह पा सकते हैं।'; + + @override + String get share_quote_message => + 'मुझे WeBuddhist का यह उद्धरण पसंद आया और मैं इसे आपके साथ साझा करना चाहता था। WeBuddhist ऐप पर और अधिक ऐसे ज्ञानवर्धक उद्धरण पढ़ें।'; + + @override + String get share_mala_message => + 'मैं WeBuddhist पर इस डिजिटल माला का उपयोग कर रहा हूँ और इसे आपके साथ साझा करना चाहता था। यह जहाँ भी जाएँ, अभ्यास करने का एक आसान तरीका है।'; + + @override + String get share_passage_message => + 'मुझे यह अंश पसंद आया और मैं इसे आपके साथ साझा करना चाहता था। आप WeBuddhist पर पूरा संदर्भ पढ़ सकते हैं।'; + + @override + String get share_timer_message => + 'मैं WeBuddhist का यह ध्यान टाइमर आपके साथ साझा करना चाहता था। यह ध्यान अभ्यास बनाने में आसान बनाता है।'; + + @override + String get share_plan_message => + 'मैं इस बौद्ध अभ्यास योजना का अनुसरण कर रहा हूँ और इसे आपके साथ साझा करना चाहता था। आप WeBuddhist पर मुफ़्त में मेरे साथ जुड़ सकते हैं।'; + + @override + String get share_plan_subject => 'WeBuddhist पर मेरे साथ जुड़ें'; + + @override + String get share_group_invite_message => + 'मैं चाहता हूँ कि आप हमारे समूह में शामिल हों। WeBuddhist पर एक साथ अभ्यास करते हैं।'; + @override String get weekday_monday => 'सोम'; diff --git a/lib/core/l10n/generated/app_localizations_mn.dart b/lib/core/l10n/generated/app_localizations_mn.dart index 62957a23..1c551032 100644 --- a/lib/core/l10n/generated/app_localizations_mn.dart +++ b/lib/core/l10n/generated/app_localizations_mn.dart @@ -147,9 +147,31 @@ class AppLocalizationsMn extends AppLocalizations { @override String get home_mala => 'Эрх'; + @override + String get session_mala => 'Эрхүүд'; + + @override + String get bookmark_mala => 'Эрхүүд'; + + @override + String get bookmark_timers => 'Цаг хэмжигчид'; + + @override + String get bookmark_texts => 'Бичвэрүүд'; + @override String get mala_add_to_practice => 'Миний дадлагад нэмэх'; + @override + String get mala_add_mala_round => 'Add mala round'; + + @override + String get mala_add_rounds_title => 'Add mala rounds:'; + + @override + String get mala_add_rounds_message => + 'Add the number of mala rounds you did outside this app.'; + @override String get mala_add_to_bookmark => 'Хавчуурга'; @@ -1642,32 +1664,35 @@ class AppLocalizationsMn extends AppLocalizations { 'You are already practicing this plan with another group. Would you like to change your practice group?'; @override - String get group_join_to_contribute => 'Join to contribute'; + String get group_join_to_contribute => 'Хувь нэмэр оруулахын тулд нэгдэх'; @override String get group_accumulator_join_error => - 'Unable to join accumulation. Please try again.'; + 'Хуримтлалд нэгдэх боломжгүй байна. Дахин оролдоно уу.'; @override String group_accumulator_participants(int count) { - return '$count participants'; + return '$count оролцогч'; } @override - String get group_accumulator_leaderboard => 'Leaderboard'; + String get group_accumulator_leaderboard => 'Тэргүүлэгчдийн самбар'; @override - String get group_accumulator_my_contributions => 'My Contributions'; + String get group_accumulator_my_contributions => 'Миний хувь нэмэр'; @override - String get group_accumulator_recited => 'Recited'; + String get group_accumulator_recited => 'Уншсан'; @override - String get group_accumulator_total => 'Total'; + String get group_accumulator_total => 'Нийт'; @override String get group_accumulator_contributions_empty => - 'Join this accumulation to track your contributions.'; + 'Хувь нэмрээ хянахын тулд энэ хуримтлалд нэгдэнэ үү.'; + + @override + String get group_accumulator_recite_now => 'Одоо уншина уу'; @override String get share_this_quote => 'Энэ ишлэлийг хуваалцах'; @@ -1679,6 +1704,45 @@ class AppLocalizationsMn extends AppLocalizations { String get verse_share_error => 'Ишлэлийг хуваалцах боломжгүй. Дахин оролдоно уу'; + @override + String get share_app_message => + 'Би өдөр тутмын Буддын дадлага хийхийн тулд энэ аппыг ашиглаж байна, та ч дуртай болно гэж бодлоо.'; + + @override + String get share_streak_message => + 'Би өдөр тутмын дадлагын зуршил хэлбэрийг бүрдүүлж байгаа бөгөөд үүнийгээ танд хуваалцахыг хүссэн. Найзтайгаа хамт хэвшлээ хадгалахад илүү хялбар байдаг. WeBuddhist дээр надтай нэгдээрэй.'; + + @override + String get share_chant_message => + 'Би энэ дуулалыг тантай хуваалцахыг хүссэн. Та үүнийг дадлагажуулж, WeBuddhist дээр бусад дуулал болон судруудын бүтэн номын санг олж болно.'; + + @override + String get share_quote_message => + 'Надад WeBuddhist дээрх энэ ишлэл таалагдсан тул тантай хуваалцахыг хүссэн. WeBuddhist аппликейшн дээр ийм мэдлэгтэй ишлэлүүдийг уншаарай.'; + + @override + String get share_mala_message => + 'Би WeBuddhist дээрх энэ тоолуурыг ашиглаж байгаа бөгөөд тантай хуваалцахыг хүссэн. Хаана ч байсан дадлагажуулахад хялбар арга юм.'; + + @override + String get share_passage_message => + 'Надад энэ хэсэг таалагдсан тул тантай хуваалцахыг хүссэн. WeBuddhist дээр бүтэн ишлэлийг уншиж болно.'; + + @override + String get share_timer_message => + 'Би WeBuddhist дээрх энэ бясалгалын таймерыг тантай хуваалцахыг хүссэн. Бясалгалын дадлага хийхэд хялбар болгодог.'; + + @override + String get share_plan_message => + 'Би энэ Буддын дадлагын төлөвлөгөөг дагаж байгаа бөгөөд тантай хуваалцахыг хүссэн. WeBuddhist дээр надтай үнэгүй нэгдэж болно.'; + + @override + String get share_plan_subject => 'WeBuddhist дээр надтай нэгдээрэй'; + + @override + String get share_group_invite_message => + 'Таны манай бүлэгт нэгдсэнийг хүсч байна. WeBuddhist дээр хамтдаа дадлагажъя.'; + @override String get weekday_monday => 'Дав'; diff --git a/lib/core/l10n/generated/app_localizations_ne.dart b/lib/core/l10n/generated/app_localizations_ne.dart index f9a33f20..f4e13901 100644 --- a/lib/core/l10n/generated/app_localizations_ne.dart +++ b/lib/core/l10n/generated/app_localizations_ne.dart @@ -147,9 +147,31 @@ class AppLocalizationsNe extends AppLocalizations { @override String get home_mala => 'माला'; + @override + String get session_mala => 'मालाहरू'; + + @override + String get bookmark_mala => 'मालाहरू'; + + @override + String get bookmark_timers => 'टाइमरहरू'; + + @override + String get bookmark_texts => 'ग्रन्थहरू'; + @override String get mala_add_to_practice => 'मेरा अभ्यासहरूमा थप्नुहोस्'; + @override + String get mala_add_mala_round => 'Add mala round'; + + @override + String get mala_add_rounds_title => 'Add mala rounds:'; + + @override + String get mala_add_rounds_message => + 'Add the number of mala rounds you did outside this app.'; + @override String get mala_add_to_bookmark => 'बुकमार्क'; @@ -1646,32 +1668,35 @@ class AppLocalizationsNe extends AppLocalizations { 'You are already practicing this plan with another group. Would you like to change your practice group?'; @override - String get group_join_to_contribute => 'Join to contribute'; + String get group_join_to_contribute => 'योगदानका लागि सामेल हुनुहोस्'; @override String get group_accumulator_join_error => - 'Unable to join accumulation. Please try again.'; + 'संचयमा सामेल हुन असमर्थ। कृपया फेरि प्रयास गर्नुहोस्।'; @override String group_accumulator_participants(int count) { - return '$count participants'; + return '$count सहभागी'; } @override - String get group_accumulator_leaderboard => 'Leaderboard'; + String get group_accumulator_leaderboard => 'लिडरबोर्ड'; @override - String get group_accumulator_my_contributions => 'My Contributions'; + String get group_accumulator_my_contributions => 'मेरो योगदान'; @override - String get group_accumulator_recited => 'Recited'; + String get group_accumulator_recited => 'जप'; @override - String get group_accumulator_total => 'Total'; + String get group_accumulator_total => 'कुल'; @override String get group_accumulator_contributions_empty => - 'Join this accumulation to track your contributions.'; + 'आफ्नो योगदान ट्र्याक गर्न यो संचयमा सामेल हुनुहोस्।'; + + @override + String get group_accumulator_recite_now => 'अहिले जप गर्नुहोस्'; @override String get share_this_quote => 'यो उद्धरण साझा गर्नुहोस्'; @@ -1683,6 +1708,45 @@ class AppLocalizationsNe extends AppLocalizations { String get verse_share_error => 'उद्धरण साझा गर्न सकिएन। कृपया फेरि प्रयास गर्नुहोस्'; + @override + String get share_app_message => + 'म यो एप प्रयोग गरेर दैनिक बौद्ध अभ्यास निर्माण गर्दैछु, र मलाई लाग्यो तपाईंलाई पनि यो मन पर्नेछ।'; + + @override + String get share_streak_message => + 'म दैनिक अभ्यासको बानी बनाउँदैछु र यो तपाईंसँग साझा गर्न चाहन्थें। साथीसँग मिलेर यसलाई जारी राख्न सजिलो हुन्छ। WeBuddhist मा मसँग सामेल हुनुहोस्।'; + + @override + String get share_chant_message => + 'म यो जप तपाईंसँग साझा गर्न चाहन्थें। तपाईंले यसको अभ्यास गर्न र WeBuddhist मा अन्य जप र ग्रन्थहरूको पूर्ण पुस्तकालय फेला पार्न सक्नुहुन्छ।'; + + @override + String get share_quote_message => + 'मलाई WeBuddhist को यो उद्धरण मन पर्यो र तपाईंसँग साझा गर्न चाहन्थें। WeBuddhist एपमा यस्ता थप ज्ञानवर्धक उद्धरणहरू पढ्नुहोस्।'; + + @override + String get share_mala_message => + 'म WeBuddhist मा यो डिजिटल माला प्रयोग गर्दैछु र तपाईंसँग साझा गर्न चाहन्थें। जहाँ जानुभए पनि अभ्यास गर्न सजिलो तरिका हो।'; + + @override + String get share_passage_message => + 'मलाई यो अंश मन पर्यो र तपाईंसँग साझा गर्न चाहन्थें। तपाईं WeBuddhist मा पूरा सन्दर्भ पढ्न सक्नुहुन्छ।'; + + @override + String get share_timer_message => + 'म WeBuddhist को यो ध्यान टाइमर तपाईंसँग साझा गर्न चाहन्थें। यसले ध्यान अभ्यास निर्माण गर्न सजिलो बनाउँछ।'; + + @override + String get share_plan_message => + 'म यो बौद्ध अभ्यास योजना पछ्याउँदैछु र तपाईंसँग साझा गर्न चाहन्थें। तपाईं WeBuddhist मा मसँग निःशुल्क सामेल हुन सक्नुहुन्छ।'; + + @override + String get share_plan_subject => 'WeBuddhist मा मसँग सामेल हुनुहोस्'; + + @override + String get share_group_invite_message => + 'म चाहन्छु कि तपाईं हाम्रो समूहमा सामेल हुनुहोस्। WeBuddhist मा सँगै अभ्यास गरौं।'; + @override String get weekday_monday => 'सोम'; diff --git a/lib/core/l10n/generated/app_localizations_zh.dart b/lib/core/l10n/generated/app_localizations_zh.dart index f913cd2c..535570c4 100644 --- a/lib/core/l10n/generated/app_localizations_zh.dart +++ b/lib/core/l10n/generated/app_localizations_zh.dart @@ -138,9 +138,31 @@ class AppLocalizationsZh extends AppLocalizations { @override String get home_mala => '念珠'; + @override + String get session_mala => '念珠'; + + @override + String get bookmark_mala => '念珠'; + + @override + String get bookmark_timers => '計時'; + + @override + String get bookmark_texts => '經文'; + @override String get mala_add_to_practice => '加入我的修持'; + @override + String get mala_add_mala_round => 'Add mala round'; + + @override + String get mala_add_rounds_title => 'Add mala rounds:'; + + @override + String get mala_add_rounds_message => + 'Add the number of mala rounds you did outside this app.'; + @override String get mala_add_to_bookmark => '書籤'; @@ -1551,32 +1573,33 @@ class AppLocalizationsZh extends AppLocalizations { 'You are already practicing this plan with another group. Would you like to change your practice group?'; @override - String get group_join_to_contribute => 'Join to contribute'; + String get group_join_to_contribute => '加入以貢獻'; @override - String get group_accumulator_join_error => - 'Unable to join accumulation. Please try again.'; + String get group_accumulator_join_error => '無法加入累積,請再試一次。'; @override String group_accumulator_participants(int count) { - return '$count participants'; + return '$count 位參與者'; } @override - String get group_accumulator_leaderboard => 'Leaderboard'; + String get group_accumulator_leaderboard => '排行榜'; @override - String get group_accumulator_my_contributions => 'My Contributions'; + String get group_accumulator_my_contributions => '我的貢獻'; @override - String get group_accumulator_recited => 'Recited'; + String get group_accumulator_recited => '已誦讀'; @override - String get group_accumulator_total => 'Total'; + String get group_accumulator_total => '總數'; @override - String get group_accumulator_contributions_empty => - 'Join this accumulation to track your contributions.'; + String get group_accumulator_contributions_empty => '加入此累積以追蹤您的貢獻。'; + + @override + String get group_accumulator_recite_now => '立即誦念'; @override String get share_this_quote => '分享这句话'; @@ -1587,6 +1610,41 @@ class AppLocalizationsZh extends AppLocalizations { @override String get verse_share_error => '无法分享引文,请重试'; + @override + String get share_app_message => '我一直在用这款应用培养每日佛法修行的习惯,觉得你也会喜欢。'; + + @override + String get share_streak_message => + '我一直在养成每日修行的好习惯,想和你分享。有朋友一起坚持会更容易。来WeBuddhist和我一起吧。'; + + @override + String get share_chant_message => + '我想和你分享这段诵文。你可以在WeBuddhist上练习它,还能找到整个诵文和经典库。'; + + @override + String get share_quote_message => + '我喜欢WeBuddhist上的这段引语,想和你分享。在WeBuddhist应用上阅读更多深刻的引语。'; + + @override + String get share_mala_message => + '我一直在WeBuddhist上使用这串电子念珠,想和你分享。这是一个随时随地修行的便捷方式。'; + + @override + String get share_passage_message => '我喜欢这段经文,想和你分享。你可以在WeBuddhist上阅读完整的上下文。'; + + @override + String get share_timer_message => '我想和你分享WeBuddhist上的这个禅修计时器。它让建立禅修习惯变得更轻松。'; + + @override + String get share_plan_message => + '我正在跟随这个佛法修行计划,想和你分享。你可以在WeBuddhist上免费和我一起修行。'; + + @override + String get share_plan_subject => '在WeBuddhist上和我一起吧'; + + @override + String get share_group_invite_message => '我希望你能加入我们的群组。让我们在WeBuddhist上一起修行吧。'; + @override String get weekday_monday => '週一'; diff --git a/lib/core/services/app_share/app_share_service.dart b/lib/core/services/app_share/app_share_service.dart index a8eea761..b35f49ef 100644 --- a/lib/core/services/app_share/app_share_service.dart +++ b/lib/core/services/app_share/app_share_service.dart @@ -8,24 +8,17 @@ import 'package:share_plus/share_plus.dart'; class AppShareService { final _logger = AppLogger('AppShareService'); - /// Universal link — opens the app when installed, redirects to the - /// correct store (via your hosted /open page) when not installed. - static const String _deepLinkUrl = 'https://webuddhist.com/open'; - - String generateShareMessage() { - return '''I'm using WeBuddhist to learn and practice Buddhism. Join me! - -👉 $_deepLinkUrl -'''; + String buildShareMessage(String localizedMessage) { + return '$localizedMessage\n\n${AppConfig.airbridgeTrackingLink}'; } - Future shareApp() async { + Future shareApp(String localizedMessage) async { try { _logger.info('Sharing WeBuddhist app with Airbridge tracking link'); await SharePlus.instance.share( ShareParams( - text: AppConfig.airbridgeTrackingLink, + text: buildShareMessage(localizedMessage), ), ); diff --git a/lib/core/utils/network_image_utils.dart b/lib/core/utils/network_image_utils.dart new file mode 100644 index 00000000..4f62d881 --- /dev/null +++ b/lib/core/utils/network_image_utils.dart @@ -0,0 +1,12 @@ +/// Strips the query string and fragment so presigned URLs (e.g. S3 with +/// rotating signatures) resolve to the same logical asset. +String stableNetworkImageCacheKey(String url) { + final uri = Uri.tryParse(url); + if (uri == null) return url; + return uri.replace(query: '', fragment: '').toString(); +} + +bool isSameNetworkImage(String? a, String? b) { + if (a == null || b == null) return a == b; + return stableNetworkImageCacheKey(a) == stableNetworkImageCacheKey(b); +} diff --git a/lib/core/widgets/cached_network_image_widget.dart b/lib/core/widgets/cached_network_image_widget.dart index 983cff6d..9f464acf 100644 --- a/lib/core/widgets/cached_network_image_widget.dart +++ b/lib/core/widgets/cached_network_image_widget.dart @@ -1,5 +1,6 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_pecha/core/utils/network_image_utils.dart'; bool _isAssetPath(String url) => url.trim().startsWith('assets/'); @@ -20,11 +21,7 @@ int? _toCachePx(double? logical, double dpr) { /// Strips the query string and fragment so presigned URLs (e.g. S3 with /// rotating signatures) resolve to the same cache entry across sessions. -String _stableCacheKey(String url) { - final uri = Uri.tryParse(url); - if (uri == null) return url; - return uri.replace(query: '', fragment: '').toString(); -} +String _stableCacheKey(String url) => stableNetworkImageCacheKey(url); class CachedNetworkImageWidget extends StatefulWidget { /// Remote http(s) URL, or a bundled asset path starting with `assets/`. diff --git a/lib/features/group_profile/presentation/screens/group_accumulator_screen.dart b/lib/features/group_profile/presentation/screens/group_accumulator_screen.dart index f9af1913..4327bd76 100644 --- a/lib/features/group_profile/presentation/screens/group_accumulator_screen.dart +++ b/lib/features/group_profile/presentation/screens/group_accumulator_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_pecha/core/constants/app_assets.dart'; +import 'package:flutter_pecha/core/l10n/intl_format_locale.dart'; import 'package:flutter_pecha/core/extensions/context_ext.dart'; import 'package:flutter_pecha/core/theme/app_colors.dart'; import 'package:flutter_pecha/core/widgets/cached_network_image_widget.dart'; @@ -288,8 +289,7 @@ class _MyContributionsTabState extends State<_MyContributionsTab> { final user = widget.detail.user; final secondaryColor = widget.isDark ? AppColors.textTertiaryDark : AppColors.textSecondary; - final locale = Localizations.localeOf(context).toString(); - final numberFormat = NumberFormat.decimalPattern(locale); + final numberFormat = NumberFormat.decimalPattern(intlFormatLocaleOf(context)); if (user == null) { return Center( @@ -469,8 +469,7 @@ class _LeaderboardTabState extends ConsumerState<_LeaderboardTab> { ); final secondaryColor = widget.isDark ? AppColors.textTertiaryDark : AppColors.textSecondary; - final locale = Localizations.localeOf(context).toString(); - final numberFormat = NumberFormat.decimalPattern(locale); + final numberFormat = NumberFormat.decimalPattern(intlFormatLocaleOf(context)); if (membersState.isLoading && membersState.members.isEmpty) { return const Center(child: CircularProgressIndicator()); diff --git a/lib/features/group_profile/presentation/screens/group_profile_screen.dart b/lib/features/group_profile/presentation/screens/group_profile_screen.dart index 75f7a5f4..4b61cbaf 100644 --- a/lib/features/group_profile/presentation/screens/group_profile_screen.dart +++ b/lib/features/group_profile/presentation/screens/group_profile_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_pecha/core/config/router/app_routes.dart'; import 'package:flutter_pecha/core/constants/app_assets.dart'; import 'package:flutter_pecha/core/widgets/error_state_widget.dart'; import 'package:flutter_pecha/features/group_profile/presentation/providers/group_profile_providers.dart'; @@ -62,7 +63,13 @@ class GroupProfileScreen extends ConsumerWidget { children: [ IconButton( icon: const Icon(AppAssets.arrowLeft), - onPressed: () => context.pop(), + onPressed: () { + if (context.canPop()) { + context.pop(); + } else { + context.go(AppRoutes.home); + } + }, ), const Spacer(), const SizedBox(width: 48, height: 48), diff --git a/lib/features/group_profile/presentation/widgets/group_accumulator_card.dart b/lib/features/group_profile/presentation/widgets/group_accumulator_card.dart index 20165d53..ad1f84e2 100644 --- a/lib/features/group_profile/presentation/widgets/group_accumulator_card.dart +++ b/lib/features/group_profile/presentation/widgets/group_accumulator_card.dart @@ -4,7 +4,7 @@ import 'package:flutter_pecha/core/extensions/context_ext.dart'; import 'package:flutter_pecha/core/theme/app_colors.dart'; import 'package:flutter_pecha/core/widgets/responsive_cover_image.dart'; import 'package:flutter_pecha/features/group_profile/domain/entities/group_accumulator.dart'; -import 'package:intl/intl.dart'; +import 'package:flutter_pecha/features/plans/data/utils/plan_date_format.dart'; class GroupAccumulatorCard extends StatelessWidget { final GroupAccumulator accumulator; @@ -173,10 +173,9 @@ class GroupAccumulatorCard extends StatelessWidget { } String? _formatDateRange(GroupAccumulator accumulator) { - final startDate = accumulator.startDate; - final endDate = accumulator.endDate; - if (startDate == null || endDate == null) return null; - final formatter = DateFormat('MMM d'); - return '${formatter.format(startDate.toLocal())} - ${formatter.format(endDate.toLocal())}'; + return PlanDateFormat.formatRangeOrNull( + accumulator.startDate, + accumulator.endDate, + ); } } diff --git a/lib/features/group_profile/presentation/widgets/group_accumulator_hero_card.dart b/lib/features/group_profile/presentation/widgets/group_accumulator_hero_card.dart index dafb0ddf..4529d9a0 100644 --- a/lib/features/group_profile/presentation/widgets/group_accumulator_hero_card.dart +++ b/lib/features/group_profile/presentation/widgets/group_accumulator_hero_card.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_pecha/core/constants/app_assets.dart'; +import 'package:flutter_pecha/core/l10n/intl_format_locale.dart'; import 'package:flutter_pecha/core/extensions/context_ext.dart'; import 'package:flutter_pecha/core/theme/app_colors.dart'; import 'package:flutter_pecha/core/widgets/responsive_cover_image.dart'; @@ -26,8 +27,9 @@ class GroupAccumulatorHeroCard extends StatelessWidget { @override Widget build(BuildContext context) { - final locale = Localizations.localeOf(context).toString(); - final numberFormat = NumberFormat.decimalPattern(locale); + final numberFormat = NumberFormat.decimalPattern( + intlFormatLocaleOf(context), + ); final progressText = '${numberFormat.format(detail.totalCount)} / ${numberFormat.format(detail.targetCount)}'; final showJoinButton = !hasJoined; @@ -50,7 +52,7 @@ class GroupAccumulatorHeroCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( - height: 180, + height: 130, width: double.infinity, child: detail.image != null && !detail.image!.isEmpty @@ -80,33 +82,22 @@ class GroupAccumulatorHeroCard extends StatelessWidget { detail.memberCount, ), style: TextStyle( - fontSize: 20, + fontSize: 16, fontWeight: FontWeight.w700, color: primaryTextColor, ), ), const SizedBox(height: 8), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Text( - detail.title, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: primaryTextColor, - height: 1.3, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - if (hasJoined) ...[ - const SizedBox(width: 12), - _ActionButton(isDark: isDark, onTap: onActionTap), - ], - ], + Text( + detail.title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: primaryTextColor, + height: 1.3, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), const SizedBox(height: 12), Row( @@ -142,14 +133,15 @@ class GroupAccumulatorHeroCard extends StatelessWidget { color: AppColors.primary, ), ), - if (showJoinButton) ...[ - const SizedBox(height: 16), + const SizedBox(height: 16), + if (showJoinButton) _JoinButton( isDark: isDark, isJoining: isJoining, onTap: onJoinTap, - ), - ], + ) + else + _ActionButton(isDark: isDark, onTap: onActionTap), ], ), ), @@ -167,26 +159,26 @@ class _ActionButton extends StatelessWidget { @override Widget build(BuildContext context) { - final button = Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: isDark ? AppColors.surfaceDark : AppColors.surfaceWhite, - shape: BoxShape.circle, - border: Border.all( - color: isDark ? AppColors.cardBorderDark : AppColors.grey300, + return GestureDetector( + onTap: onTap, + child: Container( + height: 44, + width: double.infinity, + alignment: Alignment.center, + decoration: BoxDecoration( + color: isDark ? AppColors.cardBorderDark : AppColors.backgroundDark, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + context.l10n.group_accumulator_recite_now, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppColors.textPrimaryDark, + ), ), - ), - child: Icon( - AppAssets.caretRight, - size: 18, - color: isDark ? AppColors.textTertiaryDark : AppColors.grey800, ), ); - - if (onTap == null) return button; - - return GestureDetector(onTap: onTap, child: button); } } diff --git a/lib/features/group_profile/presentation/widgets/group_profile_body.dart b/lib/features/group_profile/presentation/widgets/group_profile_body.dart index 30a9c724..e8ce0163 100644 --- a/lib/features/group_profile/presentation/widgets/group_profile_body.dart +++ b/lib/features/group_profile/presentation/widgets/group_profile_body.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_pecha/core/constants/app_assets.dart'; +import 'package:flutter_pecha/core/deep_linking/deep_link_url_builder.dart'; import 'package:flutter_pecha/core/extensions/context_ext.dart'; import 'package:flutter_pecha/core/theme/app_colors.dart'; import 'package:flutter_pecha/core/widgets/cached_network_image_widget.dart'; @@ -17,8 +18,10 @@ import 'package:flutter_pecha/features/home/presentation/providers/series_enroll import 'package:flutter_pecha/features/plans/presentation/widgets/plan_inline_markdown_view.dart'; import 'package:flutter_pecha/shared/utils/helper_functions.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_pecha/features/plans/data/utils/plan_date_format.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; +import 'package:share_plus/share_plus.dart'; import 'package:url_launcher/url_launcher.dart'; class GroupProfileBody extends ConsumerStatefulWidget { @@ -52,8 +55,6 @@ class _GroupProfileBodyState extends ConsumerState return count; } - int get _membersTabIndex => 1; - bool _hasBanner(GroupProfile profile) => profile.bannerUrl != null && profile.bannerUrl!.isNotEmpty; @@ -164,23 +165,19 @@ class _GroupProfileBodyState extends ConsumerState if (_isCommunityGroup(profile)) ...[ _buildTabBar(isDark, profile), Expanded( - child: AnimatedBuilder( - animation: _tabController!, - builder: (context, _) { - final tabIndex = _tabController!.index; - if (tabIndex == 0) { - return _buildPracticesTab(profile, isDark, lineHeight); - } - if (tabIndex == _membersTabIndex) { - return GroupProfileMembersTab( - groupId: profile.id, - groupType: profile.groupType, - isDark: isDark, - lineHeight: lineHeight, - ); - } - return _buildAboutTab(profile, isDark, locale.languageCode); - }, + child: TabBarView( + controller: _tabController!, + children: [ + _buildPracticesTab(profile, isDark, lineHeight), + GroupProfileMembersTab( + groupId: profile.id, + groupType: profile.groupType, + isDark: isDark, + lineHeight: lineHeight, + ), + if (_hasAboutContent(profile)) + _buildAboutTab(profile, isDark, locale.languageCode), + ], ), ), ] else @@ -761,11 +758,7 @@ class _GroupProfileBodyState extends ConsumerState } String? _formatSeriesDateRange(GroupProfileSeries series) { - final startDate = series.startDate; - final endDate = series.endDate; - if (startDate == null || endDate == null) return null; - final formatter = DateFormat('MMM d'); - return '${formatter.format(startDate.toLocal())} - ${formatter.format(endDate.toLocal())}'; + return PlanDateFormat.formatRangeOrNull(series.startDate, series.endDate); } void _navigateToSeriesDetail( @@ -862,6 +855,18 @@ class _GroupFollowButton extends ConsumerWidget { const _GroupFollowButton({required this.profile, required this.isDark}); + Future _onInvitePressed(BuildContext context) async { + final shareUrl = DeepLinkUrlBuilder.groupLink(groupId: profile.id).toString(); + final shareMessage = context.l10n.share_group_invite_message; + final sharePositionOrigin = getSharePositionOrigin(context: context); + await SharePlus.instance.share( + ShareParams( + text: '$shareMessage\n\n$shareUrl', + sharePositionOrigin: sharePositionOrigin, + ), + ); + } + @override Widget build(BuildContext context, WidgetRef ref) { final followKey = GroupFollowKey( @@ -947,14 +952,7 @@ class _GroupFollowButton extends ConsumerWidget { const SizedBox(width: 12), Expanded( child: ElevatedButton( - onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.mala_action_coming_soon), - behavior: SnackBarBehavior.floating, - ), - ); - }, + onPressed: () => _onInvitePressed(context), style: buttonStyle.copyWith( backgroundColor: WidgetStatePropertyAll( isDark diff --git a/lib/features/home/presentation/screens/home_screen.dart b/lib/features/home/presentation/screens/home_screen.dart index c27a68cb..5760112f 100644 --- a/lib/features/home/presentation/screens/home_screen.dart +++ b/lib/features/home/presentation/screens/home_screen.dart @@ -25,7 +25,6 @@ import 'package:flutter_pecha/features/home/presentation/widgets/verse_of_day_sk import 'package:flutter_pecha/features/notifications/application/notification_sync_engine.dart'; import 'package:flutter_pecha/features/plans/data/utils/plan_utils.dart'; import 'package:flutter_pecha/features/plans/presentation/providers/user_plans_provider.dart'; -import 'package:flutter_pecha/shared/utils/helper_functions.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:logging/logging.dart'; @@ -224,12 +223,6 @@ class _HomeScreenState extends ConsumerState { ]); } - /// Manual refetch/retry method that can be called from UI. - /// Reuses the same logic as pull-to-refresh for consistent behavior. - void _refetchSeries() { - _onRefresh(); - } - /// Opens a login-only feature shortcut (Mala, Timer). Guests are prompted to /// sign in via the login drawer instead of navigating, since these features /// need an authenticated account to persist progress. @@ -338,18 +331,4 @@ class _HomeScreenState extends ConsumerState { ), ); } - - Widget _buildScrollableMessage(Widget child) { - return LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: Center(child: child), - ), - ); - }, - ); - } } diff --git a/lib/features/home/presentation/widgets/featured_plan_section.dart b/lib/features/home/presentation/widgets/featured_plan_section.dart index 239ce4ce..5a4e90b0 100644 --- a/lib/features/home/presentation/widgets/featured_plan_section.dart +++ b/lib/features/home/presentation/widgets/featured_plan_section.dart @@ -1,15 +1,16 @@ import 'package:flutter/material.dart'; +import 'package:flutter_pecha/core/constants/app_assets.dart'; import 'package:flutter_pecha/core/extensions/context_ext.dart'; +import 'package:flutter_pecha/core/theme/app_colors.dart'; import 'package:flutter_pecha/core/theme/font_config.dart'; import 'package:flutter_pecha/core/widgets/responsive_cover_image.dart'; import 'package:flutter_pecha/features/home/domain/entities/series.dart'; import 'package:flutter_pecha/features/home/presentation/providers/featured_series_provider.dart'; import 'package:flutter_pecha/features/home/presentation/providers/routine_info_provider.dart'; import 'package:flutter_pecha/features/home/presentation/widgets/featured_plan_section_skeleton.dart'; -import 'package:flutter_pecha/features/plans/data/utils/plan_utils.dart'; +import 'package:flutter_pecha/features/plans/data/utils/plan_date_format.dart'; import 'package:flutter_pecha/shared/utils/helper_functions.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:intl/intl.dart'; class FeaturedPlanSection extends ConsumerWidget { const FeaturedPlanSection({super.key, required this.onSeriesTap}); @@ -143,21 +144,16 @@ class _FeaturedPlanContent extends ConsumerWidget { } String? _formatSeriesDateRange(Series series) { - final startDate = series.startDate; - final endDate = series.endDate; - if (startDate == null || endDate == null) return null; + return PlanDateFormat.formatRangeOrNull(series.startDate, series.endDate); +} - final formatter = DateFormat('MMM dd'); - final start = PlanUtils.calendarDateOnly(startDate); - final end = PlanUtils.calendarDateOnly(endDate); - return '${formatter.format(start)} - ${formatter.format(end)}'; +Color _featuredPlanBackgroundColor(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return isDark ? AppColors.cardBackgroundDark : AppColors.surfaceWhite; } -class _FeaturedPlanDateRangeLabel extends StatelessWidget { - const _FeaturedPlanDateRangeLabel({ - required this.series, - required this.fontSize, - }); +class _FeaturedPlanMetaRow extends StatelessWidget { + const _FeaturedPlanMetaRow({required this.series, required this.fontSize}); final Series series; final double fontSize; @@ -165,21 +161,38 @@ class _FeaturedPlanDateRangeLabel extends StatelessWidget { @override Widget build(BuildContext context) { final dateRange = _formatSeriesDateRange(series); - if (dateRange == null) return const SizedBox.shrink(); + if (dateRange == null && series.enrolledCount <= 0) { + return const SizedBox.shrink(); + } final isTibetan = context.isTibetanLocale; - return Text( - dateRange, - style: TextStyle( - fontSize: fontSize, - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.onSurfaceVariant, - height: isTibetan ? AppFontConfig.tibetanCompactLineHeight : 1.2, - leadingDistribution: - isTibetan ? AppFontConfig.tibetanLeadingDistribution : null, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + final secondaryColor = Theme.of(context).colorScheme.onSurfaceVariant; + final textStyle = TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.w500, + color: secondaryColor, + height: isTibetan ? AppFontConfig.tibetanCompactLineHeight : 1.2, + leadingDistribution: + isTibetan ? AppFontConfig.tibetanLeadingDistribution : null, + ); + + return Row( + children: [ + if (dateRange != null) + Expanded( + child: Text( + dateRange, + style: textStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (series.enrolledCount > 0) ...[ + Icon(AppAssets.usercard, size: fontSize + 2, color: secondaryColor), + const SizedBox(width: 4), + Text('${series.enrolledCount}', style: textStyle), + ], + ], ); } } @@ -207,6 +220,7 @@ class _FeaturedPlanHeroCard extends StatelessWidget { final dateRange = _formatSeriesDateRange(series); return Material( + color: _featuredPlanBackgroundColor(context), borderRadius: BorderRadius.circular( FeaturedPlanSection._imageBorderRadius, ), @@ -257,9 +271,9 @@ class _FeaturedPlanHeroCard extends StatelessWidget { maxLines: 2, overflow: TextOverflow.ellipsis, ), - if (dateRange != null) ...[ + if (dateRange != null || series.enrolledCount > 0) ...[ SizedBox(height: titleDateGap), - _FeaturedPlanDateRangeLabel( + _FeaturedPlanMetaRow( series: series, fontSize: dateFontSize, ), @@ -299,7 +313,7 @@ class _FeaturedPlanListItem extends StatelessWidget { final dateRange = _formatSeriesDateRange(series); return Material( - color: Colors.transparent, + color: _featuredPlanBackgroundColor(context), borderRadius: BorderRadius.circular( FeaturedPlanSection._imageBorderRadius, ), @@ -352,9 +366,9 @@ class _FeaturedPlanListItem extends StatelessWidget { maxLines: 2, overflow: TextOverflow.ellipsis, ), - if (dateRange != null) ...[ + if (dateRange != null || series.enrolledCount > 0) ...[ SizedBox(height: titleDateGap), - _FeaturedPlanDateRangeLabel( + _FeaturedPlanMetaRow( series: series, fontSize: dateFontSize, ), diff --git a/lib/features/home/presentation/widgets/home_header.dart b/lib/features/home/presentation/widgets/home_header.dart index 8b279a34..1519fc69 100644 --- a/lib/features/home/presentation/widgets/home_header.dart +++ b/lib/features/home/presentation/widgets/home_header.dart @@ -109,93 +109,60 @@ class _Greeting extends StatelessWidget { final double toolbarHeight; final bool alignToBottom; - Widget _buildLine({ - required BuildContext context, - required String text, - required TextStyle style, - required double fontSize, - int maxLines = 1, - }) { - return Text.rich( - TextSpan(text: text, style: style), - strutStyle: context.tibetanStrutStyle(fontSize, compact: true), - softWrap: true, - maxLines: maxLines, - overflow: TextOverflow.clip, + @override + Widget build(BuildContext context) { + final localizations = AppLocalizations.of(context)!; + final colorScheme = Theme.of(context).colorScheme; + final greetingFontSize = getLocalizedFontSize(AppTextSize.titleLarge); + final greetingStyle = MainTabAppBar.titleStyle( + context, + ).copyWith(color: colorScheme.onSurface); + final strutStyle = context.tibetanStrutStyle( + greetingFontSize, + compact: true, ); - } - - Widget _buildGreetingContent({ - required BuildContext context, - required TextStyle greetingStyle, - required double greetingFontSize, - required String prefix, - required String helloPrefix, - required String? displayName, - }) { - final formattedName = - displayName != null - ? withDisplayLineBreakOpportunities(displayName) - : null; + final displayName = firstName?.isNotEmpty == true ? firstName : null; + final Widget greetingContent; if (context.isTibetanLocale) { - return Column( + greetingContent = Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ - _buildLine( - context: context, - text: withTibetanLineBreakOpportunities(prefix), + Text( + localizations.home_hello_prefix.trim(), style: greetingStyle, - fontSize: greetingFontSize, + strutStyle: strutStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - if (formattedName != null) ...[ + if (displayName != null) ...[ const SizedBox(height: 2), - _buildLine( - context: context, - text: formattedName, + Text( + displayName, style: greetingStyle, - fontSize: greetingFontSize, + strutStyle: strutStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ], ], ); + } else { + final greeting = + displayName != null + ? '${localizations.home_hello_prefix}$displayName' + : localizations.home_hello_prefix.trim(); + greetingContent = Text( + greeting, + style: greetingStyle, + strutStyle: strutStyle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ); } - final greeting = - formattedName != null ? '$helloPrefix$formattedName' : prefix; - - return _buildLine( - context: context, - text: greeting, - style: greetingStyle, - fontSize: greetingFontSize, - maxLines: 2, - ); - } - - @override - Widget build(BuildContext context) { - final localizations = AppLocalizations.of(context)!; - final colorScheme = Theme.of(context).colorScheme; - final greetingFontSize = getLocalizedFontSize(AppTextSize.titleLarge); - final greetingStyle = MainTabAppBar.titleStyle( - context, - ).copyWith(color: colorScheme.onSurface); - final helloPrefix = localizations.home_hello_prefix; - final prefix = helloPrefix.trim(); - final displayName = firstName?.isNotEmpty == true ? firstName : null; - - final greetingContent = _buildGreetingContent( - context: context, - greetingStyle: greetingStyle, - greetingFontSize: greetingFontSize, - prefix: prefix, - helloPrefix: helloPrefix, - displayName: displayName, - ); - return SizedBox( width: maxWidth, height: toolbarHeight, diff --git a/lib/features/home/presentation/widgets/home_share_prompt.dart b/lib/features/home/presentation/widgets/home_share_prompt.dart index df90afce..52dcdc70 100644 --- a/lib/features/home/presentation/widgets/home_share_prompt.dart +++ b/lib/features/home/presentation/widgets/home_share_prompt.dart @@ -20,7 +20,9 @@ class HomeSharePrompt extends ConsumerWidget { const _PromptLabel(), const SizedBox(height: 12.0), _ShareButton( - onTap: () => ref.read(appShareServiceProvider).shareApp(), + onTap: () => ref + .read(appShareServiceProvider) + .shareApp(AppLocalizations.of(context)!.share_app_message), ), ], ), diff --git a/lib/features/home/presentation/widgets/plan_list_view.dart b/lib/features/home/presentation/widgets/plan_list_view.dart index f655783c..250564cb 100644 --- a/lib/features/home/presentation/widgets/plan_list_view.dart +++ b/lib/features/home/presentation/widgets/plan_list_view.dart @@ -469,6 +469,7 @@ class PlanListItem extends ConsumerWidget { final dateRange = PlanDateRange.tryCreate( startDate: plan.startDate, totalDays: plan.totalDays, + includeYear: seriesId == null, ); final canShowStatus = isPlanEnrolled && dateRange != null; diff --git a/lib/features/home/presentation/widgets/series_more_bottom_sheet.dart b/lib/features/home/presentation/widgets/series_more_bottom_sheet.dart index d02e4591..5929fbab 100644 --- a/lib/features/home/presentation/widgets/series_more_bottom_sheet.dart +++ b/lib/features/home/presentation/widgets/series_more_bottom_sheet.dart @@ -85,7 +85,7 @@ class _SeriesMoreBottomSheetState extends ConsumerState { ListTile( leading: Icon(AppAssets.plus, color: theme.colorScheme.onSurface), title: Text( - 'Add to my practices', + l10n.mala_add_to_practice, style: theme.textTheme.bodyLarge, ), onTap: () { @@ -125,7 +125,7 @@ class _SeriesMoreBottomSheetState extends ConsumerState { _SectionDivider(theme: theme), ListTile( leading: Icon(AppAssets.share, color: theme.colorScheme.onSurface), - title: Text('Share', style: theme.textTheme.bodyLarge), + title: Text(l10n.share, style: theme.textTheme.bodyLarge), onTap: () { HapticFeedback.lightImpact(); Navigator.of(context).pop(); diff --git a/lib/features/home/presentation/widgets/verse_share_sheet.dart b/lib/features/home/presentation/widgets/verse_share_sheet.dart index c397c0ea..f914fd3b 100644 --- a/lib/features/home/presentation/widgets/verse_share_sheet.dart +++ b/lib/features/home/presentation/widgets/verse_share_sheet.dart @@ -108,7 +108,7 @@ Future shareVerseOfDayQuote( context: context, globalKey: shareOriginKey, ); - final shareText = _verseOfDayShareText(); + final shareText = _verseOfDayShareText(context); await SharePlus.instance.share( ShareParams( @@ -136,12 +136,9 @@ Future shareVerseOfDayQuote( } } -String _verseOfDayShareText() { +String _verseOfDayShareText(BuildContext context) { final homeLink = DeepLinkUrlBuilder.homeLink().toString(); - - return 'I liked this quote from WeBuddhist and wanted to share it with you. ' - 'Read more such insightful quotes on the WeBuddhist App\n\n' - '$homeLink'; + return '${AppLocalizations.of(context)!.share_quote_message}\n\n$homeLink'; } Future _captureVerseShareImage( diff --git a/lib/features/mala/README.md b/lib/features/mala/README.md index e344a811..e59c6960 100644 --- a/lib/features/mala/README.md +++ b/lib/features/mala/README.md @@ -31,7 +31,9 @@ presentation/ mala_counter_notifier.dart per-mantra counter (seed + increment) mala_sync_manager.dart app-scoped background sync mala_settings_provider.dart sound / vibration toggles (persisted) - accumulator_groups_provider.dart joined groups for current preset (autoDispose family) + accumulator_groups_provider.dart joined groups + session seed fetch (autoDispose family) + group_accumulation_counts_provider.dart per-group session counts (Hive + sync) + mala_accumulation_selection_provider.dart personal vs group selection (SharedPreferences) accumulation_search_provider.dart preset search state (settings / add flows) services/ mala_sound_player.dart bead-tap click (just_audio) @@ -57,20 +59,28 @@ fetched by authenticated users, so the catch-all is safe. | --- | --- | --- | | `GET` | `/accumulators/presets` | Catalogue of preset mantras (paged, `language`). Sent with `no_cache` so it skips the 5-min HTTP cache and always returns the latest titles/images. | | `GET` | `/accumulators/{parent_id}` | The user's detail for one preset. **404 ⇒ no accumulator yet ⇒ seed at 0.** | -| `GET` | `/accumulators/{accumulator_id}/groups` | Groups using this preset. Query `joined_only=true` returns only groups the user has joined. `{accumulator_id}` is the preset id (`Mantra.presetId`). | +| `GET` | `/accumulators/{accumulator_id}/groups` | Groups using this preset. Query `joined_only=true` returns only groups the user has joined. `{accumulator_id}` is the preset id (`Mantra.presetId`). Each row includes `user_total_count` — the user's **lifetime** total for that group (shown in the accumulations sheet). Sent with `no_cache`. | | `POST` | `/accumulators/user` | Lazily create the user's accumulator (`{parent_id}`, starts at 0). | | `PUT` | `/accumulators/user/{id}` | Push the absolute `current_count`. | -| `DELETE` | `/accumulators/user/{id}` | Soft-delete the active session accumulator (reset). | -| `POST` | `/group-accumulators/{group_accumulator_id}` | Submit the user's absolute group count (`{current_count}`). Path param is `group_accumulator_id` from the groups list, not `group_id`. | +| `DELETE` | `/accumulators/user/{id}` | Soft-delete the active session accumulator (personal reset). | +| `GET` | `/group-accumulators/{group_accumulator_id}` | Group accumulator detail (group_profile). `user.total_count` is the user's **active session** count for bead tapping and sync. | +| `POST` | `/group-accumulators/{group_accumulator_id}` | Submit the user's absolute group session count (`{current_count}`). Path param is `group_accumulator_id` from the groups list, not `group_id`. | +| `DELETE` | `/group-accumulators/{group_accumulator_id}` | Soft-delete the user's active group session (group reset). Lifetime history on prior records is preserved server-side. | `mala_image_url` appears at both the accumulator and mantra level; it drives the bead artwork (see below). ## Counting model -- **Monotonic absolute totals.** The client always sends the absolute lifetime - total, and both sides take `max()`. Re-sending the same total is a no-op, so - retries are always safe and counts reconcile across devices. +Personal and group bead counting both sync **active session** absolutes +(`current_count` / `user.total_count`). Lifetime totals for group rows in the +accumulations sheet come from a separate field — see +[Group counting: session vs lifetime](#group-counting-session-vs-lifetime). + +- **Monotonic absolute session totals.** The client always sends the absolute + session total for the active accumulator, and both sides take `max()`. + Re-sending the same total is a no-op, so retries are always safe and counts + reconcile across devices. - **Seed-before-send.** `MalaCounterNotifier.seed()` fetches the server total and `max()`-merges with the local total *before* taps are enabled (`isSeeding`), so a stale low value is never sent. Seed uses API @@ -121,6 +131,55 @@ flushes even after the user leaves the screen. Re-entry runs `seed()` again; with no active `accumulator_id`, the on-screen count stays at 0 even if the parent detail echoes a non-zero `current_count`. +### Group reset + +Same pattern as personal reset, scoped to one joined group: + +1. Optional **POST** if the local group entry is dirty (flush unsynced taps so + lifetime totals on the deleted record are preserved). +2. **DELETE** `/group-accumulators/{group_accumulator_id}` — soft-delete the + active group session. +3. **`clearGroupSession()`** locally — group `total=0`, `syncedTotal=0` in + `mala_group_counts`; the user remains joined. +4. Next tap/sync: **POST** the new absolute session count as counting resumes. + +After a successful group reset, providers are invalidated so session counts +re-seed from detail (`joinedGroupUserCountsProvider`) and lifetime totals +refresh from the groups list (`joinedAccumulatorGroupsProvider`). + +## Group counting: session vs lifetime + +Group accumulators expose **two different user counts** from two endpoints: + +| Source | Field | Meaning | Used for | +| --- | --- | --- | --- | +| `GET /accumulators/{presetId}/groups` | `user_total_count` | Lifetime total across all sessions for that group | [GroupAccumulationsSheet] row labels only | +| `GET /group-accumulators/{id}` (group_profile) | `user.total_count` | Active session count (resets on DELETE) | Mala counter, bead taps, Hive, background sync | + +**On-screen counter (`_CounterBlock` / bead arc):** when a group is selected, +shows the **session** count from [groupAccumulationCountsProvider] (Hive + +`joinedGroupUserCountsProvider` seed). Resets to 0 after a group reset. + +**Accumulations sheet:** group rows show **`AccumulatorGroup.userTotalCount`** +(lifetime from the groups list), plus any **unsynced local taps** +(`displayLifetimeCount` = API baseline + dirty tail). The sheet watches +[joinedAccumulatorGroupsProvider] and [groupAccumulationCountsProvider] so counts +update live; after a successful group POST the groups list is refetched. Lifetime +does not drop to 0 when the active session is reset. + +**Personal row in the sheet:** shows `MalaCounterState.total` (personal active +session), same semantics as the counter when personal is selected. + +Providers: + +- [joinedAccumulatorGroupsProvider] — groups list metadata + `userTotalCount` + (lifetime). +- [joinedGroupUserCountsProvider] — fetches detail per joined group to seed + session counts (`user.total_count`). +- [groupAccumulationCountsProvider] — local session state, increments on tap, + `mergeFromServerCounts()` reconciles with detail API, `resetCount()` for + group reset. + ## Local store (`MalaLocalDataSource`) Hive box `mala_counts`, keys `userId:presetId`, value = JSON `LocalMalaState` @@ -137,26 +196,20 @@ Opened once in app bootstrap via `MalaLocalDataSource.init()`. ## Bead artwork & caching -Resolution order in `MalaScreen`: -**accumulator detail `beadImageUrl` → preset/mantra `beadImageUrl` → drawn -gradient bead**. There is no bundled asset fallback: while the network image -loads, or whenever there's no URL or it fails to load, the painter draws a -gradient bead (`_drawDrawnBead`). - -- **Preset preview source.** `Mantra.beadImageUrl` resolves to the - **mantra-level** `mala_image_url` (`PresetMantraModel`) first, falling back to - the accumulator-level one (`PresetAccumulatorModel`). The mantra-level image - mirrors what the detail endpoint (`AccumulatorDetailModel.mala_image_url`) - returns, so the pre-seed preview and the post-seed image share one URL — the - URL doesn't change after seeding, so `MalaBeads` skips a second fetch and the - bead doesn't flicker. (See `PresetAccumulatorModel.toEntity()`.) -- The detail image is threaded through `MalaCount → MalaCounterState → - MalaBeads` so per-user bead customization works. -- It is **persisted** in `LocalMalaState.beadImageUrl` and surfaced into state at - seed start (before/without network) so the correct bead shows offline on a - cold start. -- `MalaBeads` loads via **`CachedNetworkImageProvider`** (on-disk cache), so the - image bytes survive across launches and load offline. +`MalaCounterNotifier` downloads bead artwork via dio during seed (not in the +widget). URLs from the API are **presigned S3 links** that expire (~1 hour), so +stale Hive URLs are retried against fresh catalogue / detail URLs before giving +up. Successful downloads are stored as **`beadImageBase64`** in Hive. + +`MalaBeads` renders **`beadImageBytes` only** (MemoryImage). While bytes are +still downloading — or when download fails — the painter draws a gradient bead +(`_drawDrawnBead`). This avoids loading expired presigned URLs directly in the +widget layer (which would log ImageResourceService 403 errors). + +- **Preset source.** `Mantra.beadImageUrl` resolves to the mantra-level + `mala_image_url` first, falling back to the accumulator-level one. +- **Offline.** Persisted bytes in `LocalMalaState` let the strand render on a + cold start without network. ## Bead input & feedback @@ -248,19 +301,19 @@ Entry point for group counting tied to the current preset. Fetched per mantra vi a chevron. Tapping opens [GroupAccumulationsSheet] with the cached groups list. - **Selection:** [malaAccumulationSelectionProvider] persists the active source per preset (`personal` or `group:{uuid}` in SharedPreferences). The mala - counter and bead arc display the selected total; taps increment personal or - group counts accordingly. Group counts are persisted locally per - `(userId, groupAccumulatorId)` and synced in the background by - [MalaSyncManager] (debounced tap flush, round-complete immediate flush, - lifecycle + reconnect), mirroring personal accumulation. + counter and bead arc display the selected **session** total; taps increment + personal or group session counts accordingly. Group session counts are + persisted locally per `(userId, groupAccumulatorId)` and synced in the + background by [MalaSyncManager] (debounced tap flush, round-complete immediate + flush, lifecycle + reconnect), mirroring personal accumulation. - **Images:** group `image` is parsed as `ImageUrlModel` (`thumbnail` / `medium` / `original`) via `ImageModel.fromJsonMap()` and mapped to `ResponsiveImage` on the entity. Avatars render through `ResponsiveCoverImage` so the thumbnail tier is picked for the small circle. `AccumulatorGroup` entity fields used today: `groupAccumulatorId`, `groupId`, -`title`, `image`, `userTotalCount`, `isJoined`. The DTO also carries -`target_count`, dates, etc. — not yet mapped on the client. +`title`, `image`, `userTotalCount` (lifetime — sheet display only), `isJoined`. +The DTO also carries `target_count`, dates, etc. — not yet mapped on the client. On API failure the provider returns an empty list (bar stays hidden, slot reserved). @@ -268,19 +321,25 @@ reserved). ## Group accumulations sheet (`GroupAccumulationsSheet`) Opened from the bar pill. Receives the already-fetched [AccumulatorGroup] list -and the user's personal count for the current preset (`MalaCounterState.total`). +and the user's personal **session** count for the current preset +(`MalaCounterState.total`). - **User row:** name and avatar from [userProvider] (local cache written at login; refreshes via `GET /users/info` when the sheet opens and no user is - cached yet). Count shows the personal mala total. Tappable; selected row uses - `AppColors.blue` / `AppColors.blueDark`. -- **Groups list:** each row shows group image, title, and count from - [groupAccumulationCountsProvider] (Hive + API reconcile via `max()`, local - increments on tap). Tappable; accent colour on the active row. + cached yet). Count shows the personal mala session total. Tappable; selected + row uses `AppColors.blue` / `AppColors.blueDark`. +- **Groups list:** each row shows group image, title, and a **lifetime** count + (`userTotalCount` from the groups list + unsynced local taps). Not the session + count used by the counter — so a group reset clears the counter but lifetime + totals in the sheet stay visible. Watches providers for live updates. Tappable; + accent colour on the active row. - **Persistence:** selection survives app restarts via `StorageKeys.malaAccumulationSelectionPrefix` + preset id. Invalid group ids fall back to personal when the groups list reloads. +Settings (from the mala app bar) can reset the active session (personal or +group), add offline mala rounds to the active session, and bookmark the preset. + ## Analytics `mala_screen_opened`, `mala_mantra_switched`, `mala_round_completed`, @@ -293,7 +352,7 @@ and the user's personal count for the current preset (`MalaCounterState.total`). completion, fresh-install seed-at-0, `resetCount()` success/failure/mounted. - `mala_sync_manager_test.dart` — create-once-then-update, absolute-total PUT, `max()` adoption, dirty-on-failure, logged-out no-op, per-user namespacing, - group count POST flush + dirty-on-failure. + group count POST flush + dirty-on-failure, group reset (flush-then-DELETE). - `mala_beads_test.dart` — tap increments, right-to-left swipe increments, left-to-right doesn't, and disabled beads ignore both. diff --git a/lib/features/mala/data/datasources/mala_local_datasource.dart b/lib/features/mala/data/datasources/mala_local_datasource.dart index 8920ce8d..4ddda83c 100644 --- a/lib/features/mala/data/datasources/mala_local_datasource.dart +++ b/lib/features/mala/data/datasources/mala_local_datasource.dart @@ -178,6 +178,19 @@ class MalaLocalDataSource { return next; } + /// Adds [delta] beads to the monotonic local total (e.g. offline mala rounds). + Future addToTotal( + String userId, + String presetId, + int delta, + ) async { + if (delta <= 0) return read(userId, presetId); + final s = read(userId, presetId); + final next = s.copyWith(total: s.total + delta); + await write(userId, presetId, next); + return next; + } + /// Clears the on-screen session after a reset: count back to zero and no /// active accumulator id. Preserves [beadImageUrl] for offline rendering. Future clearSession(String userId, String presetId) async { @@ -271,6 +284,31 @@ class MalaLocalDataSource { return next; } + Future addGroupToTotal( + String userId, + String groupAccumulatorId, + int delta, + ) async { + if (delta <= 0) return readGroup(userId, groupAccumulatorId); + final s = readGroup(userId, groupAccumulatorId); + final next = s.copyWith(total: s.total + delta); + await writeGroup(userId, groupAccumulatorId, next); + return next; + } + + /// Clears the local group count after a reset (`total` and `syncedTotal` back + /// to zero). The user remains joined; counting resumes via POST on next tap. + Future clearGroupSession( + String userId, + String groupAccumulatorId, + ) async { + await writeGroup( + userId, + groupAccumulatorId, + const LocalGroupMalaState(), + ); + } + List groupAccumulatorIdsForUser(String userId) { final prefix = '$userId:'; return _groupBox.keys diff --git a/lib/features/mala/data/datasources/mala_remote_datasource.dart b/lib/features/mala/data/datasources/mala_remote_datasource.dart index cbc5bde0..cd757a1e 100644 --- a/lib/features/mala/data/datasources/mala_remote_datasource.dart +++ b/lib/features/mala/data/datasources/mala_remote_datasource.dart @@ -64,6 +64,7 @@ class MalaRemoteDataSource { final response = await dio.get( '/accumulators/$accumulatorId/groups', queryParameters: {'joined_only': joinedOnly}, + options: Options(extra: {'no_cache': true}), ); if (response.statusCode == 200) { return AccumulatorGroupsResponseModel.fromJson( @@ -171,6 +172,29 @@ class MalaRemoteDataSource { } } + /// `DELETE /group-accumulators/{group_accumulator_id}` — soft-delete the + /// user's count for this group accumulator. Resets [current_count] to zero + /// server-side while preserving lifetime history on the deleted record. + Future deleteGroupAccumulator(String groupAccumulatorId) async { + try { + final response = await dio.delete( + '/group-accumulators/$groupAccumulatorId', + ); + if (response.statusCode == 200 || + response.statusCode == 204 || + response.statusCode == 202) { + return; + } + throw _statusToException( + response.statusCode, + 'Failed to delete group accumulator', + ); + } on DioException catch (e) { + _logger.error('Dio error in deleteGroupAccumulator', e); + throw _dioToException(e, 'Failed to delete group accumulator'); + } + } + /// `POST /group-accumulators/{group_accumulator_id}` — submit absolute count. /// /// Body: `{ "current_count": }` ([SubmitGroupCountRequest]). diff --git a/lib/features/mala/data/repositories/mala_repository_impl.dart b/lib/features/mala/data/repositories/mala_repository_impl.dart index 81e24ed0..49434ce0 100644 --- a/lib/features/mala/data/repositories/mala_repository_impl.dart +++ b/lib/features/mala/data/repositories/mala_repository_impl.dart @@ -143,6 +143,20 @@ class MalaRepositoryImpl implements MalaRepository { } } + @override + Future> deleteGroupAccumulator( + String groupAccumulatorId, + ) async { + try { + await remote.deleteGroupAccumulator(groupAccumulatorId); + return const Right(unit); + } on AppException catch (e) { + return Left(_toFailure(e)); + } catch (e) { + return Left(UnknownFailure('Failed to delete group accumulator: $e')); + } + } + Failure _toFailure(AppException e) { if (e is AuthenticationException) return AuthenticationFailure(e.message); if (e is NotFoundException) return NotFoundFailure(e.message); diff --git a/lib/features/mala/domain/entities/accumulator_group.dart b/lib/features/mala/domain/entities/accumulator_group.dart index fef70d46..e00c184b 100644 --- a/lib/features/mala/domain/entities/accumulator_group.dart +++ b/lib/features/mala/domain/entities/accumulator_group.dart @@ -17,7 +17,9 @@ class AccumulatorGroup extends Equatable { final String groupId; final String? title; final ResponsiveImage? image; - /// User's total for this group accumulator (`user_total_count` from API). + /// User's lifetime total for this group accumulator (`user_total_count` from + /// `GET /accumulators/{id}/groups`). Shown in [GroupAccumulationsSheet]. + /// Active session counting uses [joinedGroupUserCountsProvider] instead. final int userTotalCount; final bool isJoined; diff --git a/lib/features/mala/domain/repositories/mala_repository.dart b/lib/features/mala/domain/repositories/mala_repository.dart index c05a8981..bcd22eac 100644 --- a/lib/features/mala/domain/repositories/mala_repository.dart +++ b/lib/features/mala/domain/repositories/mala_repository.dart @@ -49,4 +49,12 @@ abstract class MalaRepository { required String groupAccumulatorId, required int currentCount, }); + + /// Soft-delete the user's group count + /// (`DELETE /group-accumulators/{group_accumulator_id}`). Resets the user's + /// count to zero server-side while preserving lifetime totals on the deleted + /// record. + Future> deleteGroupAccumulator( + String groupAccumulatorId, + ); } diff --git a/lib/features/mala/domain/usecases/mala_usecases.dart b/lib/features/mala/domain/usecases/mala_usecases.dart index 5e106d88..ca27d67b 100644 --- a/lib/features/mala/domain/usecases/mala_usecases.dart +++ b/lib/features/mala/domain/usecases/mala_usecases.dart @@ -97,3 +97,14 @@ class SubmitGroupAccumulatorCountUseCase currentCount: params.currentCount, ); } + +/// Soft-deletes the user's group count +/// (`DELETE /group-accumulators/{group_accumulator_id}`). +class DeleteGroupAccumulatorUseCase extends UseCase { + DeleteGroupAccumulatorUseCase(this._repository); + final MalaRepository _repository; + + @override + Future> call(String groupAccumulatorId) => + _repository.deleteGroupAccumulator(groupAccumulatorId); +} diff --git a/lib/features/mala/presentation/providers/accumulator_groups_provider.dart b/lib/features/mala/presentation/providers/accumulator_groups_provider.dart index bd218f35..ff54d311 100644 --- a/lib/features/mala/presentation/providers/accumulator_groups_provider.dart +++ b/lib/features/mala/presentation/providers/accumulator_groups_provider.dart @@ -1,9 +1,13 @@ +import 'package:flutter_pecha/features/group_profile/presentation/providers/group_accumulator_providers.dart'; import 'package:flutter_pecha/features/mala/domain/entities/accumulator_group.dart'; import 'package:flutter_pecha/features/mala/presentation/providers/mala_providers.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; /// Joined group accumulators for the current preset /// (`GET /accumulators/{accumulator_id}/groups?joined_only=true`). +/// +/// Metadata only (title, image, membership, lifetime [AccumulatorGroup.userTotalCount]). +/// Active session counts for bead tapping use [joinedGroupUserCountsProvider]. final joinedAccumulatorGroupsProvider = FutureProvider.autoDispose .family, String>((ref, presetId) async { final result = await ref @@ -11,3 +15,30 @@ final joinedAccumulatorGroupsProvider = FutureProvider.autoDispose .getJoinedAccumulatorGroups(presetId); return result.fold((_) => const [], (groups) => groups); }); + +/// Per-group user session counts keyed by [AccumulatorGroup.groupAccumulatorId]. +/// +/// Uses [GroupAccumulatorRepositoryInterface.getGroupAccumulator] → +/// `GroupAccumulatorDetail.user.totalCount`. +final joinedGroupUserCountsProvider = FutureProvider.autoDispose + .family, String>((ref, presetId) async { + final groups = await ref.watch( + joinedAccumulatorGroupsProvider(presetId).future, + ); + if (groups.isEmpty) return const {}; + + final repository = ref.watch(groupAccumulatorRepositoryProvider); + final entries = await Future.wait( + groups.map((group) async { + final result = await repository.getGroupAccumulator( + group.groupAccumulatorId, + ); + final count = result.fold( + (_) => 0, + (detail) => detail.user?.totalCount ?? 0, + ); + return MapEntry(group.groupAccumulatorId, count); + }), + ); + return Map.fromEntries(entries); + }); diff --git a/lib/features/mala/presentation/providers/group_accumulation_counts_provider.dart b/lib/features/mala/presentation/providers/group_accumulation_counts_provider.dart index 518143d3..a6e4a193 100644 --- a/lib/features/mala/presentation/providers/group_accumulation_counts_provider.dart +++ b/lib/features/mala/presentation/providers/group_accumulation_counts_provider.dart @@ -4,34 +4,50 @@ import 'dart:math'; import 'package:flutter/services.dart'; import 'package:flutter_pecha/features/mala/data/datasources/mala_local_datasource.dart'; import 'package:flutter_pecha/features/mala/domain/entities/accumulator_group.dart'; +import 'package:flutter_pecha/features/mala/domain/usecases/mala_usecases.dart'; +import 'package:flutter_pecha/features/mala/presentation/providers/accumulator_groups_provider.dart'; import 'package:flutter_pecha/features/mala/presentation/providers/mala_providers.dart'; import 'package:flutter_pecha/features/mala/presentation/providers/mala_sync_manager.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -/// Local group [userTotalCount] values keyed by [AccumulatorGroup.groupAccumulatorId]. +/// Local group user session counts keyed by [AccumulatorGroup.groupAccumulatorId]. /// /// Persisted in Hive per `(userId, groupAccumulatorId)` with `total` / -/// `syncedTotal` dirty tracking. Background sync via [MalaSyncManager]. +/// `syncedTotal` dirty tracking. Seeded from group_profile's +/// `GET /group-accumulators/{id}` (`user.totalCount`). Background sync via +/// [MalaSyncManager] (`POST` with `current_count`). class GroupAccumulationCountsNotifier extends StateNotifier> { GroupAccumulationCountsNotifier({ required Ref ref, + required String presetId, required MalaLocalDataSource local, required MalaSyncManager sync, + required DeleteGroupAccumulatorUseCase deleteGroupAccumulator, required Future Function() currentUserId, }) : _ref = ref, + _presetId = presetId, _local = local, _sync = sync, + _deleteGroupAccumulator = deleteGroupAccumulator, _currentUserId = currentUserId, super(const {}) { + _sync.onGroupCountSynced = handleGroupCountSynced; unawaited(_init()); } final Ref _ref; + final String _presetId; final MalaLocalDataSource _local; final MalaSyncManager _sync; + final DeleteGroupAccumulatorUseCase _deleteGroupAccumulator; final Future Function() _currentUserId; String? _userId; + bool _isResetting = false; + /// Groups recently reset; ignore stale server totals until GET confirms 0. + final Set _postResetGroupIds = {}; + + bool get isResetting => _isResetting; Future _init() async { _userId = await _currentUserId(); @@ -48,9 +64,10 @@ class GroupAccumulationCountsNotifier extends StateNotifier> { state = next; } - /// Reconcile API totals with local Hive state (`max()` on both sides). - Future mergeFromApi(List groups) async { - if (groups.isEmpty) return; + /// Reconcile group_profile detail user totals with local Hive state + /// (`max()` on both sides). + Future mergeFromServerCounts(Map serverCounts) async { + if (serverCounts.isEmpty) return; var userId = _userId; userId ??= await _currentUserId(); @@ -61,22 +78,41 @@ class GroupAccumulationCountsNotifier extends StateNotifier> { var changed = false; var hasDirtyTail = false; - for (final group in groups) { - final id = group.groupAccumulatorId; + for (final entry in serverCounts.entries) { + final id = entry.key; + var serverTotal = entry.value; + + if (_postResetGroupIds.contains(id)) { + if (serverTotal == 0) { + _postResetGroupIds.remove(id); + } else { + // Stale detail response after DELETE — keep the local zero session. + serverTotal = 0; + } + } + final localState = _local.readGroup(userId, id); - final total = max(localState.total, group.userTotalCount); - final syncedTotal = group.userTotalCount; - final reconciled = localState.copyWith( - total: total, - syncedTotal: max(localState.syncedTotal, syncedTotal), - ); + final LocalGroupMalaState reconciled; + if (localState.isDirty) { + final total = max(localState.total, serverTotal); + reconciled = localState.copyWith( + total: total, + syncedTotal: max(localState.syncedTotal, serverTotal), + ); + } else { + // Clean local: mirror server exactly (seed, post-sync, post-reset). + reconciled = LocalGroupMalaState( + total: serverTotal, + syncedTotal: serverTotal, + ); + } if (reconciled.total != localState.total || reconciled.syncedTotal != localState.syncedTotal) { await _local.writeGroup(userId, id, reconciled); } - if (next[id] != total) { - next[id] = total; + if (next[id] != reconciled.total) { + next[id] = reconciled.total; changed = true; } if (reconciled.isDirty) hasDirtyTail = true; @@ -86,24 +122,33 @@ class GroupAccumulationCountsNotifier extends StateNotifier> { if (hasDirtyTail) unawaited(_sync.flush(SyncReason.launch)); } - int countFor(String groupAccumulatorId, List groups) { + int countFor(String groupAccumulatorId, [List? groups]) { final fromState = state[groupAccumulatorId]; if (fromState != null) return fromState; final userId = _userId; if (userId != null) { - final localTotal = _local.readGroup(userId, groupAccumulatorId).total; - if (localTotal > 0) return localTotal; - } - - for (final group in groups) { - if (group.groupAccumulatorId == groupAccumulatorId) { - return group.userTotalCount; - } + return _local.readGroup(userId, groupAccumulatorId).total; } return 0; } + /// Lifetime total for [GroupAccumulationsSheet]: API baseline plus any taps + /// not yet reflected in `user_total_count`. + int displayLifetimeCount(String groupAccumulatorId, int lifetimeFromApi) { + final userId = _userId; + if (userId == null) return lifetimeFromApi; + + final local = _local.readGroup(userId, groupAccumulatorId); + final dirty = local.total - local.syncedTotal; + if (dirty <= 0) return lifetimeFromApi; + return lifetimeFromApi + dirty; + } + + void handleGroupCountSynced(String groupAccumulatorId) { + _ref.invalidate(joinedAccumulatorGroupsProvider(_presetId)); + } + void increment({ required String groupAccumulatorId, required List groups, @@ -114,6 +159,7 @@ class GroupAccumulationCountsNotifier extends StateNotifier> { final userId = _userId; if (userId == null || userId.isEmpty) return; + _postResetGroupIds.remove(groupAccumulatorId); final current = countFor(groupAccumulatorId, groups); final newTotal = current + 1; state = {...state, groupAccumulatorId: newTotal}; @@ -130,14 +176,72 @@ class GroupAccumulationCountsNotifier extends StateNotifier> { _sync.onTap(roundComplete: roundComplete); } + + void addRounds({ + required String groupAccumulatorId, + required List groups, + required int rounds, + required int beadsPerRound, + }) { + if (rounds <= 0) return; + + final userId = _userId; + if (userId == null || userId.isEmpty) return; + + _postResetGroupIds.remove(groupAccumulatorId); + final current = countFor(groupAccumulatorId, groups); + final delta = rounds * beadsPerRound; + final newTotal = current + delta; + state = {...state, groupAccumulatorId: newTotal}; + unawaited(_local.addGroupToTotal(userId, groupAccumulatorId, delta)); + _sync.onTap(roundComplete: true); + } + + /// Resets the user's group count to zero by soft-deleting on the server + /// (`DELETE /group-accumulators/{id}`). Unsynced taps are flushed first. + /// Returns false on failure. + Future resetCount({required String groupAccumulatorId}) async { + if (_isResetting) return false; + + final userId = _userId ?? await _currentUserId(); + if (userId == null || userId.isEmpty) return false; + _userId = userId; + + _isResetting = true; + try { + await _sync.resetGroupAccumulator( + groupAccumulatorId, + deleteGroupAccumulator: _deleteGroupAccumulator, + ); + if (!mounted) return false; + _postResetGroupIds.add(groupAccumulatorId); + state = {...state, groupAccumulatorId: 0}; + return true; + } catch (_) { + return false; + } finally { + if (mounted) _isResetting = false; + } + } } final groupAccumulationCountsProvider = StateNotifierProvider.autoDispose .family, String>( - (ref, presetId) => GroupAccumulationCountsNotifier( - ref: ref, - local: ref.watch(malaLocalDataSourceProvider), - sync: ref.watch(malaSyncManagerProvider), - currentUserId: () => resolveMalaUserId(ref), - ), + (ref, presetId) { + final sync = ref.watch(malaSyncManagerProvider); + final notifier = GroupAccumulationCountsNotifier( + ref: ref, + presetId: presetId, + local: ref.watch(malaLocalDataSourceProvider), + sync: sync, + deleteGroupAccumulator: ref.watch(deleteGroupAccumulatorUseCaseProvider), + currentUserId: () => resolveMalaUserId(ref), + ); + ref.onDispose(() { + if (sync.onGroupCountSynced == notifier.handleGroupCountSynced) { + sync.onGroupCountSynced = null; + } + }); + return notifier; + }, ); diff --git a/lib/features/mala/presentation/providers/mala_counter_notifier.dart b/lib/features/mala/presentation/providers/mala_counter_notifier.dart index 8721a32a..e69252dc 100644 --- a/lib/features/mala/presentation/providers/mala_counter_notifier.dart +++ b/lib/features/mala/presentation/providers/mala_counter_notifier.dart @@ -6,6 +6,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_pecha/core/analytics/analytics_events.dart'; import 'package:flutter_pecha/core/analytics/analytics_service.dart'; import 'package:flutter_pecha/core/utils/app_logger.dart'; +import 'package:flutter_pecha/core/utils/network_image_utils.dart'; import 'package:flutter_pecha/features/mala/data/datasources/mala_local_datasource.dart'; import 'package:flutter_pecha/features/mala/domain/entities/mantra.dart'; import 'package:flutter_pecha/features/mala/domain/usecases/mala_usecases.dart'; @@ -135,12 +136,11 @@ class MalaCounterNotifier extends StateNotifier { beadImageUrl: fallbackImageUrl, beadImageBytes: localState.beadImageBytes, ); - if (localState.beadImageBytes == null && fallbackImageUrl != null) { - unawaited(_storeBeadImage(userId, fallbackImageUrl)); - } final result = await _getAccumulatorDetail(_presetId); if (!mounted) return; // screen left mid-seed + + String? detailBeadImageUrl; result.fold( (failure) { _logger.warning('Seed failed: ${failure.message}'); @@ -148,6 +148,7 @@ class MalaCounterNotifier extends StateNotifier { unawaited(_sync.flush(SyncReason.launch)); }, (detail) { + detailBeadImageUrl = detail.beadImageUrl; // detail.accumulatorId is null when the user has no active accumulator // (fresh install, or after reset soft-deletes the session record). // detail.total maps to API `current_count` (active session), not @@ -184,39 +185,114 @@ class MalaCounterNotifier extends StateNotifier { beadImageUrl: beadImageUrl, beadImageBytes: localState.beadImageBytes, ); - if (beadImageUrl != null) { - unawaited(_storeBeadImage(userId, beadImageUrl)); - } // Push any offline tail captured before this seed. if (total > syncedTotal) unawaited(_sync.flush(SyncReason.launch)); }, ); - } - Future _storeBeadImage(String userId, String imageUrl) async { - try { - final bytes = await _downloadImageBytes(imageUrl); - if (bytes.isEmpty) return; - final current = _local.read(userId, _presetId); - await _local.write( + unawaited( + _refreshBeadImage( userId, - _presetId, - current.copyWith( - beadImageUrl: imageUrl, - beadImageBase64: base64Encode(bytes), - ), - ); + urlCandidates: [ + if (detailBeadImageUrl != null) detailBeadImageUrl!, + if (fallbackImageUrl != null) fallbackImageUrl, + ], + refetchDetailOnFailure: detailBeadImageUrl == null, + ), + ); + } + + /// Downloads bead artwork via [MalaRemoteDataSource.fetchImageBytes] + /// (injected as [_downloadImageBytes]) and persists bytes in Hive. Presigned + /// S3 URLs expire, so stale cached URLs are retried against fresh catalogue / + /// detail URLs when needed. + Future _refreshBeadImage( + String userId, { + List urlCandidates = const [], + bool refetchDetailOnFailure = true, + }) async { + final localState = _local.read(userId, _presetId); + final cachedBytes = localState.beadImageBytes; + if (cachedBytes != null) { if (!mounted) return; state = state.copyWith( - beadImageUrl: imageUrl, - beadImageBytes: Uint8List.fromList(bytes), + beadImageUrl: localState.beadImageUrl ?? state.beadImageUrl, + beadImageBytes: cachedBytes, ); + return; + } + + final candidates = []; + void addCandidate(String? url) { + if (url == null || url.isEmpty) return; + final key = stableNetworkImageCacheKey(url); + if (candidates.any((c) => stableNetworkImageCacheKey(c) == key)) return; + candidates.add(url); + } + + for (final url in urlCandidates) { + addCandidate(url); + } + addCandidate(_mantra.beadImageUrl); + addCandidate(localState.beadImageUrl); + + for (final url in candidates) { + final bytes = await _tryDownloadBeadImage(url); + if (bytes == null) continue; + await _persistBeadImage(userId, url, bytes); + return; + } + + if (!refetchDetailOnFailure) return; + + final detailResult = await _getAccumulatorDetail(_presetId); + await detailResult.fold( + (_) async {}, + (detail) async { + final freshUrl = detail.beadImageUrl; + if (freshUrl == null || freshUrl.isEmpty) return; + + final bytes = await _tryDownloadBeadImage(freshUrl); + if (bytes == null) return; + await _persistBeadImage(userId, freshUrl, bytes); + }, + ); + } + + /// Network fetch delegated to [MalaRemoteDataSource.fetchImageBytes]. + Future?> _tryDownloadBeadImage(String url) async { + try { + final bytes = await _downloadImageBytes(url); + if (bytes.isEmpty) return null; + return bytes; } catch (e) { - _logger.warning('Failed to store mala bead image locally: $e'); + _logger.warning('Bead image download failed for $url: $e'); + return null; } } + Future _persistBeadImage( + String userId, + String imageUrl, + List bytes, + ) async { + final current = _local.read(userId, _presetId); + await _local.write( + userId, + _presetId, + current.copyWith( + beadImageUrl: imageUrl, + beadImageBase64: base64Encode(bytes), + ), + ); + if (!mounted) return; + state = state.copyWith( + beadImageUrl: imageUrl, + beadImageBytes: Uint8List.fromList(bytes), + ); + } + /// +1 recitation. No-op while seeding or resetting. Monotonic — never decrements. void incrementBead({ required bool soundEnabled, @@ -248,6 +324,20 @@ class MalaCounterNotifier extends StateNotifier { _sync.onTap(roundComplete: roundComplete); } + /// Adds completed mala rounds counted outside the app (monotonic). + void addRounds(int rounds) { + if (rounds <= 0 || state.isSeeding || state.isResetting) return; + + final userId = _userId; + if (userId == null || userId.isEmpty) return; + + final delta = rounds * state.beadsPerRound; + final newTotal = state.total + delta; + state = state.copyWith(total: newTotal); + unawaited(_local.addToTotal(userId, _presetId, delta)); + _sync.onTap(roundComplete: true); + } + /// Resets the on-screen session to zero by soft-deleting the active server /// accumulator (`DELETE /accumulators/user/{id}`). Unsynced taps are flushed /// first. Returns false on failure. diff --git a/lib/features/mala/presentation/providers/mala_providers.dart b/lib/features/mala/presentation/providers/mala_providers.dart index 02d0b8ef..d441f78f 100644 --- a/lib/features/mala/presentation/providers/mala_providers.dart +++ b/lib/features/mala/presentation/providers/mala_providers.dart @@ -73,6 +73,11 @@ final submitGroupAccumulatorCountUseCaseProvider = ); }); +final deleteGroupAccumulatorUseCaseProvider = + Provider((ref) { + return DeleteGroupAccumulatorUseCase(ref.watch(malaRepositoryProvider)); + }); + // ============ Auth helpers ============ bool _isAuthenticated(Ref ref) { diff --git a/lib/features/mala/presentation/providers/mala_sync_manager.dart b/lib/features/mala/presentation/providers/mala_sync_manager.dart index 7fac0b51..d171a639 100644 --- a/lib/features/mala/presentation/providers/mala_sync_manager.dart +++ b/lib/features/mala/presentation/providers/mala_sync_manager.dart @@ -68,6 +68,10 @@ class MalaSyncManager with WidgetsBindingObserver { int _retryAttempt = 0; StreamSubscription? _connectivitySub; + /// Called after a group session count POST succeeds. Used to refresh lifetime + /// totals from `GET /accumulators/{id}/groups`. + void Function(String groupAccumulatorId)? onGroupCountSynced; + /// Attach lifecycle + connectivity observers and flush any offline tail. void start() { if (_started) return; @@ -217,6 +221,77 @@ class MalaSyncManager with WidgetsBindingObserver { } } + /// Resets the user's group count by soft-deleting via + /// `DELETE /group-accumulators/{id}`. Unsynced taps are flushed first so + /// lifetime totals on the deleted record are preserved server-side. + /// + /// [deleteGroupAccumulator] is passed per call (not stored on the manager) so + /// reset stays correct after hot reload of the app-scoped sync manager. + Future resetGroupAccumulator( + String groupAccumulatorId, { + required DeleteGroupAccumulatorUseCase deleteGroupAccumulator, + }) async { + if (!_isLoggedIn()) { + throw StateError('Cannot reset group mala while logged out'); + } + final userId = await _currentUserId(); + if (userId == null || userId.isEmpty) { + throw StateError('Cannot reset group mala without a user id'); + } + + await _awaitSyncIdle(); + _isSyncing = true; + _debounce?.cancel(); + + try { + final before = _local.readGroup(userId, groupAccumulatorId); + _logger.info( + 'Group reset start groupAccumulatorId=$groupAccumulatorId ' + 'total=${before.total} synced=${before.syncedTotal}', + ); + + if (before.isDirty) { + _logger.info('Group reset flushing dirty tail before DELETE'); + await _pushGroupTotal(userId, groupAccumulatorId, before.total); + } + + _logger.info( + 'Group reset DELETE /group-accumulators/$groupAccumulatorId', + ); + final deleted = await deleteGroupAccumulator(groupAccumulatorId); + deleted.fold( + (failure) => throw Exception(failure.message), + (_) {}, + ); + + await _local.clearGroupSession(userId, groupAccumulatorId); + + _logger.info( + 'Group reset complete groupAccumulatorId=$groupAccumulatorId', + ); + + _analytics?.track( + AnalyticsEvents.malaSynced, + properties: { + 'groupAccumulatorId': groupAccumulatorId, + 'total': 0, + 'reset': true, + 'group': true, + }, + ); + } catch (e, st) { + _logger.warning( + 'Group reset failed groupAccumulatorId=$groupAccumulatorId: $e', + e, + st, + ); + rethrow; + } finally { + _isSyncing = false; + if (_dirty) _dirty = false; + } + } + Future _awaitSyncIdle() async { final deadline = DateTime.now().add(_syncIdleTimeout); while (_isSyncing) { @@ -320,6 +395,7 @@ class MalaSyncManager with WidgetsBindingObserver { 'group': true, }, ); + onGroupCountSynced?.call(groupAccumulatorId); }, ); } diff --git a/lib/features/mala/presentation/screens/mala_screen.dart b/lib/features/mala/presentation/screens/mala_screen.dart index 3e89e126..4395f5ea 100644 --- a/lib/features/mala/presentation/screens/mala_screen.dart +++ b/lib/features/mala/presentation/screens/mala_screen.dart @@ -131,6 +131,7 @@ class _MalaScreenState extends ConsumerState { ); final language = Localizations.localeOf(context).languageCode; + final isDark = Theme.of(context).brightness == Brightness.dark; final counter = ref.watch(malaCounterProvider(mantra)); final notifier = ref.read(malaCounterProvider(mantra).notifier); final settings = ref.watch(malaSettingsProvider); @@ -142,19 +143,24 @@ class _MalaScreenState extends ConsumerState { ); final groups = groupsAsync.valueOrNull ?? const []; ref.watch(groupAccumulationCountsProvider(mantra.presetId)); + ref.watch(joinedGroupUserCountsProvider(mantra.presetId)); final groupCountsNotifier = ref.read( groupAccumulationCountsProvider(mantra.presetId).notifier, ); ref.listen(joinedAccumulatorGroupsProvider(mantra.presetId), (_, next) { next.whenData((loadedGroups) { - groupCountsNotifier.mergeFromApi(loadedGroups); ref .read(malaAccumulationSelectionProvider(mantra.presetId).notifier) .validateAgainst(loadedGroups); + ref.invalidate(joinedGroupUserCountsProvider(mantra.presetId)); }); }); + ref.listen(joinedGroupUserCountsProvider(mantra.presetId), (_, next) { + next.whenData(groupCountsNotifier.mergeFromServerCounts); + }); + final displayTotal = _displayTotal( selection: selection, personalTotal: counter.total, @@ -204,7 +210,10 @@ class _MalaScreenState extends ConsumerState { flex: 36, child: DecoratedBox( decoration: BoxDecoration( - color: AppColors.surfaceWhite, + color: + isDark + ? const Color(0xCC454545) + : AppColors.surfaceWhite, borderRadius: BorderRadius.circular(16), ), child: ClipRRect( @@ -250,9 +259,6 @@ class _MalaScreenState extends ConsumerState { beadInRound: displayBeadInRound, beadsPerRound: beadsPerRound, enabled: countingEnabled, - beadImageUrl: - counter.beadImageUrl ?? - mantra.beadImageUrl, beadImageBytes: counter.beadImageBytes, beadColor: const Color(0xFF8D6E63), threadColor: const Color(0xFFC62828), diff --git a/lib/features/mala/presentation/widgets/add_mala_rounds_dialog.dart b/lib/features/mala/presentation/widgets/add_mala_rounds_dialog.dart new file mode 100644 index 00000000..488fc659 --- /dev/null +++ b/lib/features/mala/presentation/widgets/add_mala_rounds_dialog.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_pecha/core/constants/app_assets.dart'; +import 'package:flutter_pecha/core/extensions/context_ext.dart'; +import 'package:flutter_pecha/core/theme/app_colors.dart'; + +/// Dialog for adding mala rounds completed outside the app. +/// +/// Returns the selected round count, or `null` when dismissed. +Future showAddMalaRoundsDialog(BuildContext context) { + return showDialog( + context: context, + barrierDismissible: true, + builder: (_) => const _AddMalaRoundsDialog(), + ); +} + +class _AddMalaRoundsDialog extends StatefulWidget { + const _AddMalaRoundsDialog(); + + @override + State<_AddMalaRoundsDialog> createState() => _AddMalaRoundsDialogState(); +} + +class _AddMalaRoundsDialogState extends State<_AddMalaRoundsDialog> { + static const _maxRounds = 9; + + int _rounds = 0; + + void _decrement() { + if (_rounds <= 0) return; + HapticFeedback.selectionClick(); + setState(() => _rounds--); + } + + void _increment() { + if (_rounds >= _maxRounds) return; + HapticFeedback.selectionClick(); + setState(() => _rounds++); + } + + void _confirm() { + if (_rounds <= 0) return; + HapticFeedback.mediumImpact(); + Navigator.of(context).pop(_rounds); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final backgroundColor = + isDark ? AppColors.cardBackgroundDark : AppColors.goldLight; + + return Dialog( + backgroundColor: backgroundColor, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l10n.mala_add_rounds_title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + fontSize: 18, + ), + ), + const SizedBox(height: 8), + Text( + l10n.mala_add_rounds_message, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _RoundStepButton( + icon: AppAssets.minusCircle, + onPressed: _rounds > 0 ? _decrement : null, + ), + const SizedBox(width: 20), + Container( + width: 72, + height: 48, + alignment: Alignment.center, + decoration: BoxDecoration( + color: + isDark ? AppColors.surfaceDark : AppColors.surfaceWhite, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: + isDark ? AppColors.cardBorderDark : AppColors.grey300, + ), + ), + child: Text( + '$_rounds', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 20), + _RoundStepButton( + icon: AppAssets.plusCircle, + onPressed: _rounds < _maxRounds ? _increment : null, + ), + ], + ), + const SizedBox(height: 16), + FilledButton( + onPressed: _rounds > 0 ? _confirm : null, + style: FilledButton.styleFrom( + backgroundColor: + isDark ? AppColors.textPrimaryDark : Colors.black, + foregroundColor: isDark ? AppColors.textPrimary : Colors.white, + disabledBackgroundColor: + isDark ? AppColors.grey600 : AppColors.grey300, + disabledForegroundColor: + isDark ? AppColors.grey500 : AppColors.grey600, + minimumSize: const Size(double.infinity, 52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(999), + ), + ), + child: Text( + l10n.ai_confirm, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + color: + _rounds > 0 + ? (isDark ? AppColors.textPrimary : Colors.white) + : null, + ), + ), + ), + ], + ), + ), + ); + } +} + +class _RoundStepButton extends StatelessWidget { + const _RoundStepButton({required this.icon, required this.onPressed}); + + final IconData icon; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final enabled = onPressed != null; + final color = enabled ? theme.colorScheme.onSurface : AppColors.grey400; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onPressed, + customBorder: const CircleBorder(), + child: Icon(icon, size: 40, color: color), + ), + ); + } +} diff --git a/lib/features/mala/presentation/widgets/group_accumulations_bar.dart b/lib/features/mala/presentation/widgets/group_accumulations_bar.dart index 549f3e6a..85ee2981 100644 --- a/lib/features/mala/presentation/widgets/group_accumulations_bar.dart +++ b/lib/features/mala/presentation/widgets/group_accumulations_bar.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter_pecha/core/constants/app_assets.dart'; import 'package:flutter_pecha/core/theme/app_colors.dart'; +import 'package:flutter_pecha/core/widgets/cached_network_image_widget.dart'; import 'package:flutter_pecha/core/widgets/responsive_cover_image.dart'; +import 'package:flutter_pecha/features/auth/presentation/providers/state_providers.dart'; import 'package:flutter_pecha/features/mala/domain/entities/accumulator_group.dart'; import 'package:flutter_pecha/features/mala/presentation/providers/accumulator_groups_provider.dart'; import 'package:flutter_pecha/features/mala/presentation/widgets/group_accumulations_sheet.dart'; @@ -11,6 +14,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; /// Always reserves [barHeight] so bead layout does not shift when the groups /// request resolves. The pill is shown only when /// `GET /accumulators/{presetId}/groups?joined_only=true` returns groups. +/// +/// Tapping opens [GroupAccumulationsSheet], which shows lifetime +/// [AccumulatorGroup.userTotalCount] per group. The mala counter above uses +/// session counts from [groupAccumulationCountsProvider] when a group is selected. class GroupAccumulationsBar extends ConsumerWidget { const GroupAccumulationsBar({ super.key, @@ -19,6 +26,7 @@ class GroupAccumulationsBar extends ConsumerWidget { }); final String presetId; + /// Personal active session total (`MalaCounterState.total`), shown on the user row. final int userTotalCount; static const barHeight = 40.0; @@ -49,7 +57,7 @@ class GroupAccumulationsBar extends ConsumerWidget { } } -class _GroupAccumulationsBarContent extends StatelessWidget { +class _GroupAccumulationsBarContent extends ConsumerWidget { const _GroupAccumulationsBarContent({ required this.presetId, required this.groups, @@ -65,17 +73,21 @@ class _GroupAccumulationsBarContent extends StatelessWidget { final double avatarOverlap; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final pillColor = + isDark ? const Color(0xCC454545) : AppColors.grey100; final iconColor = Theme.of(context).colorScheme.onSurfaceVariant; - final preview = groups.take(2).toList(); - final stackWidth = - preview.length == 1 ? avatarSize : avatarSize + avatarOverlap; + final userAvatarUrl = ref.watch(userProvider).user?.avatarUrl; + final groupPreview = groups.take(2).toList(); + final avatarCount = 1 + groupPreview.length; + final stackWidth = avatarSize + (avatarCount - 1) * avatarOverlap; return Align( alignment: Alignment.centerLeft, child: Material( - color: AppColors.grey100, - borderRadius: BorderRadius.circular(20), + color: pillColor, + borderRadius: BorderRadius.circular(12), child: InkWell( borderRadius: BorderRadius.circular(20), onTap: @@ -97,18 +109,29 @@ class _GroupAccumulationsBarContent extends StatelessWidget { child: Stack( clipBehavior: Clip.none, children: [ - for (var i = 0; i < preview.length; i++) + Positioned( + left: 0, + child: _BarUserAvatar( + avatarUrl: userAvatarUrl, + size: avatarSize, + ), + ), + for (var i = 0; i < groupPreview.length; i++) Positioned( - left: i * avatarOverlap, + left: (i + 1) * avatarOverlap, child: _GroupAvatar( - group: preview[i], + group: groupPreview[i], size: avatarSize, ), ), ], ), ), - Icon(Icons.chevron_right, size: 20, color: iconColor), + Icon( + AppAssets.caretRight2, + size: 24, + color: iconColor, + ), ], ), ), @@ -118,6 +141,42 @@ class _GroupAccumulationsBarContent extends StatelessWidget { } } +class _BarUserAvatar extends StatelessWidget { + const _BarUserAvatar({required this.size, this.avatarUrl}); + + final double size; + final String? avatarUrl; + + @override + Widget build(BuildContext context) { + final fallbackColor = Theme.of(context).colorScheme.surfaceContainerHighest; + + return Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: fallbackColor, + border: Border.all(color: AppColors.surfaceWhite, width: 1.5), + ), + clipBehavior: Clip.antiAlias, + child: + avatarUrl != null && avatarUrl!.isNotEmpty + ? CachedNetworkImageWidget( + imageUrl: avatarUrl, + width: size, + height: size, + fit: BoxFit.cover, + ) + : Icon( + AppAssets.profile, + size: size * 0.55, + color: AppColors.grey600, + ), + ); + } +} + class _GroupAvatar extends StatelessWidget { const _GroupAvatar({required this.group, required this.size}); @@ -126,8 +185,7 @@ class _GroupAvatar extends StatelessWidget { @override Widget build(BuildContext context) { - final fallbackColor = - Theme.of(context).colorScheme.surfaceContainerHighest; + final fallbackColor = Theme.of(context).colorScheme.surfaceContainerHighest; return Container( width: size, diff --git a/lib/features/mala/presentation/widgets/group_accumulations_sheet.dart b/lib/features/mala/presentation/widgets/group_accumulations_sheet.dart index acd0f88f..945bc29b 100644 --- a/lib/features/mala/presentation/widgets/group_accumulations_sheet.dart +++ b/lib/features/mala/presentation/widgets/group_accumulations_sheet.dart @@ -8,11 +8,19 @@ import 'package:flutter_pecha/core/widgets/responsive_cover_image.dart'; import 'package:flutter_pecha/features/auth/domain/entities/user.dart'; import 'package:flutter_pecha/features/auth/presentation/providers/state_providers.dart'; import 'package:flutter_pecha/features/mala/domain/entities/accumulator_group.dart'; +import 'package:flutter_pecha/features/mala/presentation/providers/accumulator_groups_provider.dart'; import 'package:flutter_pecha/features/mala/presentation/providers/group_accumulation_counts_provider.dart'; import 'package:flutter_pecha/features/mala/presentation/providers/mala_accumulation_selection_provider.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; +/// Bottom sheet for choosing personal vs group accumulation on the mala screen. +/// +/// Group row counts show lifetime totals from +/// `GET /accumulators/{presetId}/groups` (`user_total_count`), plus any unsynced +/// local taps via [GroupAccumulationCountsNotifier.displayLifetimeCount]. The +/// on-screen mala counter uses session counts from [groupAccumulationCountsProvider] +/// instead; those reset to 0 on group DELETE while lifetime totals here do not. class GroupAccumulationsSheet extends ConsumerStatefulWidget { const GroupAccumulationsSheet({ super.key, @@ -75,6 +83,12 @@ class _GroupAccumulationsSheetState final accentColor = isDark ? AppColors.blueDark : AppColors.blue; final dividerColor = isDark ? AppColors.cardBorderDark : AppColors.grey300; final locale = intlFormatLocaleOf(context); + ref.watch(groupAccumulationCountsProvider(widget.presetId)); + final groups = + ref + .watch(joinedAccumulatorGroupsProvider(widget.presetId)) + .valueOrNull ?? + widget.groups; final countsNotifier = ref.read( groupAccumulationCountsProvider(widget.presetId).notifier, ); @@ -108,30 +122,20 @@ class _GroupAccumulationsSheetState ), ), const SizedBox(height: 12), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Text( - context.l10n.mala_group_accumulations, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - ), - const SizedBox(height: 16), _SelectableAccumulationRow( isSelected: selection.isPersonal, accentColor: accentColor, onTap: - () => ref - .read( - malaAccumulationSelectionProvider( - widget.presetId, - ).notifier, - ) - .selectPersonal(), + () => + ref + .read( + malaAccumulationSelectionProvider( + widget.presetId, + ).notifier, + ) + .selectPersonal(), leading: _UserAvatar(avatarUrl: user?.avatarUrl), - title: - user != null ? _userDisplayName(user) : '—', + title: user != null ? _userDisplayName(user) : '—', formattedCount: NumberFormat.decimalPattern( locale, ).format(widget.personalTotalCount), @@ -141,7 +145,7 @@ class _GroupAccumulationsSheetState Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), child: Text( - context.l10n.mala_groups_section, + context.l10n.mala_group_accumulations, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), @@ -151,7 +155,7 @@ class _GroupAccumulationsSheetState child: ListView.separated( shrinkWrap: true, padding: const EdgeInsets.only(bottom: 8), - itemCount: widget.groups.length, + itemCount: groups.length, separatorBuilder: (_, __) => Divider( height: 1, @@ -161,14 +165,10 @@ class _GroupAccumulationsSheetState color: dividerColor, ), itemBuilder: (context, index) { - final group = widget.groups[index]; + final group = groups[index]; final isSelected = selection.groupAccumulatorId == group.groupAccumulatorId; - final count = countsNotifier.countFor( - group.groupAccumulatorId, - widget.groups, - ); return _SelectableAccumulationRow( isSelected: isSelected, @@ -188,7 +188,12 @@ class _GroupAccumulationsSheetState : context.l10n.mala_group_untitled, formattedCount: NumberFormat.decimalPattern( locale, - ).format(count), + ).format( + countsNotifier.displayLifetimeCount( + group.groupAccumulatorId, + group.userTotalCount, + ), + ), ); }, ), @@ -231,10 +236,8 @@ class _SelectableAccumulationRow extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final nameColor = - isSelected ? accentColor : theme.colorScheme.onSurface; - final countColor = - isSelected ? accentColor : theme.colorScheme.onSurface; + final nameColor = isSelected ? accentColor : theme.colorScheme.onSurface; + final countColor = isSelected ? accentColor : theme.colorScheme.onSurface; return Material( color: Colors.transparent, @@ -283,8 +286,7 @@ class _UserAvatar extends StatelessWidget { @override Widget build(BuildContext context) { - final fallbackColor = - Theme.of(context).colorScheme.surfaceContainerHighest; + final fallbackColor = Theme.of(context).colorScheme.surfaceContainerHighest; return ClipOval( child: SizedBox( diff --git a/lib/features/mala/presentation/widgets/mala_beads.dart b/lib/features/mala/presentation/widgets/mala_beads.dart index b5a068bc..08315054 100644 --- a/lib/features/mala/presentation/widgets/mala_beads.dart +++ b/lib/features/mala/presentation/widgets/mala_beads.dart @@ -1,7 +1,6 @@ import 'dart:typed_data'; import 'dart:ui' as ui; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; /// A tappable strand of prayer beads that advances **forward only**. @@ -14,8 +13,8 @@ import 'package:flutter/material.dart'; /// left pile and a new bead enters from the top-right. Counting is monotonic, /// so the motion never reverses. /// -/// Beads render the [beadImageUrl] image when present; a drawn gradient bead -/// shows while that image loads, and whenever there's no URL or it fails. +/// Beads render [beadImageBytes] when present; a drawn gradient bead shows +/// while bytes are downloading or when unavailable. class MalaBeads extends StatefulWidget { const MalaBeads({ super.key, @@ -26,7 +25,6 @@ class MalaBeads extends StatefulWidget { required this.beadColor, required this.threadColor, this.enabled = true, - this.beadImageUrl, this.beadImageBytes, }); @@ -38,7 +36,6 @@ class MalaBeads extends StatefulWidget { final Color beadColor; final Color threadColor; final bool enabled; - final String? beadImageUrl; final Uint8List? beadImageBytes; @override @@ -70,8 +67,7 @@ class _MalaBeadsState extends State @override void didUpdateWidget(covariant MalaBeads oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.beadImageUrl != oldWidget.beadImageUrl || - widget.beadImageBytes != oldWidget.beadImageBytes) { + if (widget.beadImageBytes != oldWidget.beadImageBytes) { _resolveBeadImage(); } if (widget.total != oldWidget.total) { @@ -89,8 +85,8 @@ class _MalaBeadsState extends State } } - /// Load local Hive-backed image bytes first. With no local bytes, fall back - /// to the network URL. With neither — or if both fail — draw the gradient bead. + /// Renders Hive-backed bytes only. Network fetch (with presigned URL refresh) + /// is handled by [MalaCounterNotifier]; until bytes arrive, draw the gradient bead. void _resolveBeadImage() { final bytes = widget.beadImageBytes; if (bytes != null && bytes.isNotEmpty) { @@ -98,15 +94,9 @@ class _MalaBeadsState extends State return; } - final url = widget.beadImageUrl; - if (url == null || url.isEmpty) { - _detachImageListener(); - _imageStream = null; - if (_beadImage != null && mounted) setState(() => _beadImage = null); - return; - } - - _resolveImageProvider(CachedNetworkImageProvider(url)); + _detachImageListener(); + _imageStream = null; + if (_beadImage != null && mounted) setState(() => _beadImage = null); } void _resolveImageProvider(ImageProvider provider) { diff --git a/lib/features/mala/presentation/widgets/mala_settings_sheet.dart b/lib/features/mala/presentation/widgets/mala_settings_sheet.dart index edee7c43..6ab4009d 100644 --- a/lib/features/mala/presentation/widgets/mala_settings_sheet.dart +++ b/lib/features/mala/presentation/widgets/mala_settings_sheet.dart @@ -2,20 +2,28 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_pecha/core/config/locale/locale_notifier.dart'; import 'package:flutter_pecha/core/constants/app_assets.dart'; +import 'package:flutter_pecha/core/deep_linking/deep_link_url_builder.dart'; import 'package:flutter_pecha/core/extensions/context_ext.dart'; -import 'package:flutter_pecha/core/network/connectivity_service.dart' show connectivityNotifierProvider; +import 'package:flutter_pecha/core/network/connectivity_service.dart' + show connectivityNotifierProvider; import 'package:flutter_pecha/core/theme/app_colors.dart'; import 'package:flutter_pecha/core/widgets/destructive_confirmation_dialog.dart'; import 'package:flutter_pecha/features/mala/domain/entities/mantra.dart'; +import 'package:flutter_pecha/features/group_profile/presentation/providers/group_accumulator_providers.dart'; +import 'package:flutter_pecha/features/mala/presentation/providers/accumulator_groups_provider.dart'; +import 'package:flutter_pecha/features/mala/presentation/providers/group_accumulation_counts_provider.dart'; import 'package:flutter_pecha/features/mala/presentation/providers/mala_accumulation_selection_provider.dart'; import 'package:flutter_pecha/features/mala/presentation/providers/mala_providers.dart'; import 'package:flutter_pecha/features/mala/presentation/providers/mala_settings_provider.dart'; +import 'package:flutter_pecha/features/mala/presentation/widgets/add_mala_rounds_dialog.dart'; import 'package:flutter_pecha/features/practice/data/datasource/bookmark_remote_datasource.dart'; import 'package:flutter_pecha/features/practice/presentation/controllers/bookmark_controller.dart'; import 'package:flutter_pecha/features/practice/presentation/providers/bookmark_providers.dart'; +import 'package:flutter_pecha/shared/utils/helper_functions.dart'; import 'package:flutter_pecha/shared/widgets/app_toggle_switch.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:share_plus/share_plus.dart'; class MalaSettingsSheet extends ConsumerWidget { const MalaSettingsSheet({super.key, required this.mantra}); @@ -40,16 +48,29 @@ class MalaSettingsSheet extends ConsumerWidget { final settings = ref.watch(malaSettingsProvider); final settingsNotifier = ref.read(malaSettingsProvider.notifier); final isOnline = ref.watch(connectivityNotifierProvider); - final isPersonal = ref - .watch(malaAccumulationSelectionProvider(mantra.presetId)) - .isPersonal; + final selection = ref.watch( + malaAccumulationSelectionProvider(mantra.presetId), + ); + final isPersonal = selection.isPersonal; + final counter = ref.watch(malaCounterProvider(mantra)); + final canAddRounds = + !counter.isSeeding && + !counter.seedFailed && + (selection.isPersonal || selection.groupAccumulatorId != null); + final canReset = + isOnline && + !counter.isSeeding && + !counter.isResetting && + (isPersonal || selection.groupAccumulatorId != null); final isBookmarked = ref.watch( isBookmarkedProvider( - BookmarkTarget(type: BookmarkType.accumulator, sourceId: mantra.presetId), + BookmarkTarget( + type: BookmarkType.accumulator, + sourceId: mantra.presetId, + ), ), ); - final dividerColor = - isDark ? AppColors.cardBorderDark : AppColors.grey300; + final dividerColor = isDark ? AppColors.cardBorderDark : AppColors.grey300; const destructiveColor = Color(0xFFB03027); return Container( @@ -71,23 +92,32 @@ class MalaSettingsSheet extends ConsumerWidget { borderRadius: BorderRadius.circular(2), ), ), + if (isPersonal) + _MalaSettingsTile( + icon: AppAssets.plus, + label: l10n.mala_add_to_practice, + enabled: isPersonal, + onTap: () => _onAddToPractice(context), + ), + if (isPersonal) Divider(height: 1, color: dividerColor), _MalaSettingsTile( - icon: AppAssets.plus, - label: l10n.mala_add_to_practice, - enabled: isPersonal, - onTap: () => _onAddToPractice(context), - ), - Divider(height: 1, color: dividerColor), - _MalaSettingsTile( - icon: - isBookmarked - ? AppAssets.bookmarkSimpleFill - : AppAssets.bookmarkSimple, - label: l10n.mala_add_to_bookmark, - enabled: isPersonal, - onTap: () => _onToggleBookmark(context, ref), + icon: AppAssets.plusCircle, + label: l10n.mala_add_mala_round, + enabled: canAddRounds, + onTap: () => _onAddMalaRound(context, ref), ), Divider(height: 1, color: dividerColor), + if (isPersonal) + _MalaSettingsTile( + icon: + isBookmarked + ? AppAssets.bookmarkSimpleFill + : AppAssets.bookmarkSimple, + label: l10n.mala_add_to_bookmark, + enabled: isPersonal, + onTap: () => _onToggleBookmark(context, ref), + ), + if (isPersonal) Divider(height: 1, color: dividerColor), _MalaSettingsToggleTile( icon: AppAssets.speakerSimpleHigh, label: l10n.mala_sound, @@ -102,12 +132,18 @@ class MalaSettingsSheet extends ConsumerWidget { onChanged: settingsNotifier.setVibrationEnabled, ), Divider(height: 1, color: dividerColor), + _MalaSettingsTile( + icon: AppAssets.readerShare, + label: l10n.share, + onTap: () => _onShare(context), + ), + Divider(height: 1, color: dividerColor), _MalaSettingsTile( icon: AppAssets.arrowCounterClockwise, label: l10n.mala_reset_count, labelColor: destructiveColor, iconColor: destructiveColor, - enabled: isOnline && isPersonal, + enabled: canReset, onTap: () => _onResetCount(context, ref), ), const SizedBox(height: 8), @@ -117,6 +153,20 @@ class MalaSettingsSheet extends ConsumerWidget { ); } + Future _onShare(BuildContext context) async { + final navigator = Navigator.of(context); + final sharePositionOrigin = getSharePositionOrigin(context: context); + final shareUrl = DeepLinkUrlBuilder.malaLink(presetId: mantra.presetId).toString(); + final shareMessage = context.l10n.share_mala_message; + navigator.pop(); + await SharePlus.instance.share( + ShareParams( + text: '$shareMessage\n\n$shareUrl', + sharePositionOrigin: sharePositionOrigin, + ), + ); + } + void _onAddToPractice(BuildContext context) { // Mala is login-gated, so the user is always authenticated here; the // edit-routine route's auth guard is a backstop. Capture the router before @@ -127,6 +177,38 @@ class MalaSettingsSheet extends ConsumerWidget { router.pushNamed('edit-routine', extra: {'initialMantra': mantra}); } + Future _onAddMalaRound(BuildContext context, WidgetRef ref) async { + final rounds = await showAddMalaRoundsDialog(context); + if (rounds == null || rounds <= 0 || !context.mounted) return; + + final selection = ref.read( + malaAccumulationSelectionProvider(mantra.presetId), + ); + final counter = ref.read(malaCounterProvider(mantra)); + + if (selection.isPersonal) { + ref.read(malaCounterProvider(mantra).notifier).addRounds(rounds); + } else { + final groupId = selection.groupAccumulatorId; + if (groupId == null) return; + final groups = + ref + .read(joinedAccumulatorGroupsProvider(mantra.presetId)) + .valueOrNull ?? + const []; + ref + .read(groupAccumulationCountsProvider(mantra.presetId).notifier) + .addRounds( + groupAccumulatorId: groupId, + groups: groups, + rounds: rounds, + beadsPerRound: counter.beadsPerRound, + ); + } + + if (context.mounted) Navigator.of(context).pop(); + } + Future _onToggleBookmark(BuildContext context, WidgetRef ref) async { final language = ref.read(contentLanguageProvider); final navigator = Navigator.of(context); @@ -134,10 +216,7 @@ class MalaSettingsSheet extends ConsumerWidget { final didToggle = await BookmarkController( ref: ref, context: context, - ).toggleMala( - mantra.presetId, - name: mantra.displayTitle(language), - ); + ).toggleMala(mantra.presetId, name: mantra.displayTitle(language)); if (context.mounted && didToggle) navigator.pop(); } @@ -145,6 +224,9 @@ class MalaSettingsSheet extends ConsumerWidget { Future _onResetCount(BuildContext context, WidgetRef ref) async { final l10n = context.l10n; final messenger = ScaffoldMessenger.of(context); + final selection = ref.read( + malaAccumulationSelectionProvider(mantra.presetId), + ); final result = await showDestructiveConfirmationDialog( context, @@ -155,11 +237,28 @@ class MalaSettingsSheet extends ConsumerWidget { barrierDismissible: false, onConfirmed: () async { HapticFeedback.mediumImpact(); - return ref.read(malaCounterProvider(mantra).notifier).resetCount(); + if (selection.isPersonal) { + return ref.read(malaCounterProvider(mantra).notifier).resetCount(); + } + final groupAccumulatorId = selection.groupAccumulatorId; + if (groupAccumulatorId == null) return false; + return ref + .read(groupAccumulationCountsProvider(mantra.presetId).notifier) + .resetCount(groupAccumulatorId: groupAccumulatorId); }, ); if (!context.mounted) return; if (result == true) { + if (!selection.isPersonal) { + final groupId = selection.groupAccumulatorId; + if (groupId != null) { + ref.invalidate(groupAccumulatorDetailProvider(groupId)); + } + // Refresh session counts from detail API; post-reset guard prevents stale totals. + ref.invalidate(joinedGroupUserCountsProvider(mantra.presetId)); + // Refresh lifetime totals for the accumulations sheet. + ref.invalidate(joinedAccumulatorGroupsProvider(mantra.presetId)); + } Navigator.of(context).pop(); } else if (result == false) { messenger.showSnackBar( diff --git a/lib/features/mala/presentation/widgets/mantra_switcher.dart b/lib/features/mala/presentation/widgets/mantra_switcher.dart index a8bac6ec..d36ca811 100644 --- a/lib/features/mala/presentation/widgets/mantra_switcher.dart +++ b/lib/features/mala/presentation/widgets/mantra_switcher.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_pecha/core/constants/app_assets.dart'; import 'package:flutter_pecha/features/mala/domain/entities/mantra.dart'; /// Mantra display + chevron switcher as an **infinite, looping carousel**. @@ -90,7 +91,7 @@ class _MantraSwitcherState extends State { crossAxisAlignment: CrossAxisAlignment.center, children: [ _Chevron( - icon: Icons.chevron_left, + icon: AppAssets.caretLeft2, onTap: _canLoop ? () => _animateBy(-1) : null, ), Expanded( @@ -118,7 +119,7 @@ class _MantraSwitcherState extends State { ), ), _Chevron( - icon: Icons.chevron_right, + icon: AppAssets.caretRight2, onTap: _canLoop ? () => _animateBy(1) : null, ), ], @@ -190,7 +191,7 @@ class _Chevron extends StatelessWidget { final color = Theme.of(context).colorScheme.onSurface; return IconButton( onPressed: onTap, - icon: Icon(icon, size: 32), + icon: Icon(icon, size: 24), color: color, disabledColor: color.withValues(alpha: 0.25), splashRadius: 24, diff --git a/lib/features/more/presentation/widgets/me_stats_section.dart b/lib/features/more/presentation/widgets/me_stats_section.dart index 151e860c..51e1b2a1 100644 --- a/lib/features/more/presentation/widgets/me_stats_section.dart +++ b/lib/features/more/presentation/widgets/me_stats_section.dart @@ -180,9 +180,6 @@ class _StatCard extends StatelessWidget { color: cardColor, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(MeStatsSection._borderRadius), - side: BorderSide( - color: isDark ? AppColors.cardBorderDark : AppColors.grey300, - ), ), clipBehavior: Clip.antiAlias, child: InkWell( @@ -269,9 +266,6 @@ class _PracticeDaysCard extends StatelessWidget { color: cardColor, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(MeStatsSection._borderRadius), - side: BorderSide( - color: isDark ? AppColors.cardBorderDark : AppColors.grey300, - ), ), clipBehavior: Clip.antiAlias, child: InkWell( diff --git a/lib/features/more/presentation/widgets/me_stats_section_skeleton.dart b/lib/features/more/presentation/widgets/me_stats_section_skeleton.dart index 5f92d83a..69ae372b 100644 --- a/lib/features/more/presentation/widgets/me_stats_section_skeleton.dart +++ b/lib/features/more/presentation/widgets/me_stats_section_skeleton.dart @@ -14,7 +14,6 @@ class MeStatsSectionSkeleton extends StatelessWidget { Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; final cardColor = isDark ? AppColors.cardDark : AppColors.surfaceWhite; - final borderColor = isDark ? AppColors.cardBorderDark : AppColors.grey300; return Padding( padding: const EdgeInsets.fromLTRB( @@ -32,7 +31,6 @@ class MeStatsSectionSkeleton extends StatelessWidget { const SizedBox(height: 12), _SkeletonCard( cardColor: cardColor, - borderColor: borderColor, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -85,7 +83,6 @@ class MeStatsSectionSkeleton extends StatelessWidget { const SizedBox(height: _cardSpacing), _SkeletonCard( cardColor: cardColor, - borderColor: borderColor, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), child: Row( children: [ @@ -98,19 +95,9 @@ class MeStatsSectionSkeleton extends StatelessWidget { const SizedBox(height: _cardSpacing), Row( children: [ - Expanded( - child: _StatCardSkeleton( - cardColor: cardColor, - borderColor: borderColor, - ), - ), + Expanded(child: _StatCardSkeleton(cardColor: cardColor)), const SizedBox(width: _cardSpacing), - Expanded( - child: _StatCardSkeleton( - cardColor: cardColor, - borderColor: borderColor, - ), - ), + Expanded(child: _StatCardSkeleton(cardColor: cardColor)), ], ), ], @@ -123,13 +110,11 @@ class MeStatsSectionSkeleton extends StatelessWidget { class _SkeletonCard extends StatelessWidget { const _SkeletonCard({ required this.cardColor, - required this.borderColor, required this.child, this.padding = const EdgeInsets.all(16), }); final Color cardColor; - final Color borderColor; final Widget child; final EdgeInsetsGeometry padding; @@ -141,7 +126,6 @@ class _SkeletonCard extends StatelessWidget { borderRadius: BorderRadius.circular( MeStatsSectionSkeleton._borderRadius, ), - side: BorderSide(color: borderColor), ), child: Padding(padding: padding, child: child), ); @@ -149,16 +133,14 @@ class _SkeletonCard extends StatelessWidget { } class _StatCardSkeleton extends StatelessWidget { - const _StatCardSkeleton({required this.cardColor, required this.borderColor}); + const _StatCardSkeleton({required this.cardColor}); final Color cardColor; - final Color borderColor; @override Widget build(BuildContext context) { return _SkeletonCard( cardColor: cardColor, - borderColor: borderColor, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/features/more/presentation/widgets/me_streak_card.dart b/lib/features/more/presentation/widgets/me_streak_card.dart index d503f632..53f55135 100644 --- a/lib/features/more/presentation/widgets/me_streak_card.dart +++ b/lib/features/more/presentation/widgets/me_streak_card.dart @@ -45,9 +45,6 @@ class _MeStreakCardState extends State { color: cardColor, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(_borderRadius), - side: BorderSide( - color: isDark ? AppColors.cardBorderDark : AppColors.grey300, - ), ), clipBehavior: Clip.antiAlias, child: InkWell( diff --git a/lib/features/more/presentation/widgets/streak_share_sheet.dart b/lib/features/more/presentation/widgets/streak_share_sheet.dart index a26ba4ea..767b4322 100644 --- a/lib/features/more/presentation/widgets/streak_share_sheet.dart +++ b/lib/features/more/presentation/widgets/streak_share_sheet.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_pecha/core/constants/app_assets.dart'; +import 'package:flutter_pecha/core/deep_linking/deep_link_url_builder.dart'; import 'package:flutter_pecha/core/l10n/generated/app_localizations.dart'; import 'package:flutter_pecha/core/theme/app_colors.dart'; import 'package:flutter_pecha/core/theme/app_theme.dart'; @@ -95,9 +96,13 @@ Future shareStreakQuote( globalKey: shareOriginKey, ); + final moreLink = DeepLinkUrlBuilder.moreLink().toString(); + final shareMessage = AppLocalizations.of(context)!.share_streak_message; + await SharePlus.instance.share( ShareParams( files: [XFile(tempFile.path)], + text: '$shareMessage\n\n$moreLink', sharePositionOrigin: sharePositionOrigin, ), ); diff --git a/lib/features/plans/data/utils/plan_date_format.dart b/lib/features/plans/data/utils/plan_date_format.dart new file mode 100644 index 00000000..8ab72d9b --- /dev/null +++ b/lib/features/plans/data/utils/plan_date_format.dart @@ -0,0 +1,60 @@ +import 'package:flutter_pecha/features/plans/data/utils/plan_utils.dart'; + +/// Fixed English calendar-date labels for plan/series date ranges. +/// +/// Format: `1 May 2025 - 2 Dec 2025` (day, 3-letter uppercase month, year). +/// Intentionally not localized so the same string appears in every locale. +abstract final class PlanDateFormat { + static const _months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + + /// Formats a single calendar date, e.g. `1 May 2025`. + static String formatDate(DateTime date, {bool includeYear = true}) { + final normalized = PlanUtils.calendarDateOnly(date); + final month = _months[normalized.month - 1]; + if (!includeYear) return '${normalized.day} $month'; + return '${normalized.day} $month ${normalized.year}'; + } + + /// Formats an inclusive calendar range, e.g. `1 May 2025 - 2 Dec 2025`. + static String formatRange( + DateTime start, + DateTime end, { + bool includeYear = true, + }) { + return '${formatDate(start, includeYear: includeYear)} - ${formatDate(end, includeYear: includeYear)}'; + } + + /// Returns null when either bound is missing. + static String? formatRangeOrNull( + DateTime? start, + DateTime? end, { + bool includeYear = true, + }) { + if (start == null || end == null) return null; + return formatRange(start, end, includeYear: includeYear); + } + + /// Formats a single date when [end] is null, otherwise a range. + static String? formatRangeOrSingle( + DateTime? start, + DateTime? end, { + bool includeYear = true, + }) { + if (start == null) return null; + if (end == null) return formatDate(start, includeYear: includeYear); + return formatRange(start, end, includeYear: includeYear); + } +} diff --git a/lib/features/plans/plans.dart b/lib/features/plans/plans.dart index 5552a81f..e50b66f8 100644 --- a/lib/features/plans/plans.dart +++ b/lib/features/plans/plans.dart @@ -64,6 +64,7 @@ export 'data/repositories/author_repository.dart'; // Data - Utils export 'data/utils/plan_utils.dart'; +export 'data/utils/plan_date_format.dart'; // Presentation - Providers export 'presentation/providers/plan_search_provider.dart'; diff --git a/lib/features/plans/presentation/plan_info.dart b/lib/features/plans/presentation/plan_info.dart index 72e7a89f..ddb77c65 100644 --- a/lib/features/plans/presentation/plan_info.dart +++ b/lib/features/plans/presentation/plan_info.dart @@ -6,13 +6,13 @@ import 'package:flutter_pecha/core/widgets/responsive_cover_image.dart'; import 'package:flutter_pecha/features/auth/presentation/providers/state_providers.dart'; import 'package:flutter_pecha/features/onboarding/presentation/providers/event_enrollment_providers.dart'; import 'package:flutter_pecha/features/plans/domain/entities/plan.dart'; +import 'package:flutter_pecha/features/plans/data/utils/plan_date_format.dart'; import 'package:flutter_pecha/features/plans/presentation/providers/user_plans_provider.dart'; import 'package:flutter_pecha/features/plans/presentation/author_detail_screen.dart'; import 'package:flutter_pecha/shared/extensions/typography_extensions.dart'; import 'package:flutter_pecha/core/extensions/context_ext.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:intl/intl.dart'; class PlanInfo extends ConsumerStatefulWidget { const PlanInfo({super.key, required this.plan, this.author}); @@ -274,7 +274,7 @@ class _PlanInfoState extends ConsumerState { if (startDate != null) { final today = DateUtils.dateOnly(DateTime.now()); final normalizedStart = DateUtils.dateOnly(startDate.toLocal()); - final formattedDate = DateFormat('MMMM d, y').format(normalizedStart); + final formattedDate = PlanDateFormat.formatDate(normalizedStart); if (today.isBefore(normalizedStart)) { await showDialog( diff --git a/lib/features/plans/presentation/widgets/plan_track/missed_days_badge.dart b/lib/features/plans/presentation/widgets/plan_track/missed_days_badge.dart index ffab0079..24801d96 100644 --- a/lib/features/plans/presentation/widgets/plan_track/missed_days_badge.dart +++ b/lib/features/plans/presentation/widgets/plan_track/missed_days_badge.dart @@ -49,26 +49,39 @@ class MissedDaysBadge extends StatelessWidget { final color = Theme.of(context).colorScheme.onSurfaceVariant; final firstMissedDay = _findFirstMissedDay(); - - return GestureDetector( - onTap: firstMissedDay != null ? () => onTap?.call(firstMissedDay) : null, - child: Container( - padding: EdgeInsets.symmetric( - horizontal: onTap != null ? 10 : 12, - vertical: 4, + final isTappable = onTap != null && firstMissedDay != null; + if (!isTappable) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Text( + context.l10n.missedDaysCount(missedDays), + style: TextStyle(fontSize: 10, color: color), ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (onTap != null) ...[ + ); + } + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () => onTap?.call(firstMissedDay), + borderRadius: BorderRadius.circular(20), + child: Ink( + decoration: BoxDecoration( + border: Border.all(color: color.withValues(alpha: 0.35)), + borderRadius: BorderRadius.circular(20), + ), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ Icon(Icons.arrow_back, size: 10, color: color), const SizedBox(width: 4), + Text( + context.l10n.missedDaysCount(missedDays), + style: TextStyle(fontSize: 10, color: color), + ), ], - Text( - context.l10n.missedDaysCount(missedDays), - style: TextStyle(fontSize: 10, color: color), - ), - ], + ), ), ), ); diff --git a/lib/features/plans/presentation/widgets/plan_track/plan_date_range_label.dart b/lib/features/plans/presentation/widgets/plan_track/plan_date_range_label.dart index df441715..06870527 100644 --- a/lib/features/plans/presentation/widgets/plan_track/plan_date_range_label.dart +++ b/lib/features/plans/presentation/widgets/plan_track/plan_date_range_label.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:flutter_pecha/features/plans/data/utils/plan_date_format.dart'; import 'package:flutter_pecha/features/plans/data/utils/plan_utils.dart'; -import 'package:intl/intl.dart'; /// Immutable value type encapsulating a plan's `[start, end]` calendar /// window along with its pre-formatted label and a `today within range` @@ -19,7 +19,7 @@ class PlanDateRange { /// True when `DateUtils.dateOnly(DateTime.now())` is within `[start, end]`. final bool isCurrent; - /// Pre-formatted "MMM dd - MMM dd" label. + /// Pre-formatted "1 May 2025 - 2 Dec 2025" label (not localized). final String formatted; const PlanDateRange({ @@ -35,6 +35,7 @@ class PlanDateRange { static PlanDateRange? tryCreate({ required DateTime? startDate, required int totalDays, + bool includeYear = true, }) { if (startDate == null || totalDays <= 0) return null; @@ -43,8 +44,11 @@ class PlanDateRange { final today = DateUtils.dateOnly(DateTime.now()); final isCurrent = !today.isBefore(start) && !today.isAfter(end); - final formatter = DateFormat('MMM dd'); - final formatted = '${formatter.format(start)} - ${formatter.format(end)}'; + final formatted = PlanDateFormat.formatRange( + start, + end, + includeYear: includeYear, + ); return PlanDateRange( start: start, diff --git a/lib/features/plans/services/plan_share_service.dart b/lib/features/plans/services/plan_share_service.dart index c6db36df..39ee5e9e 100644 --- a/lib/features/plans/services/plan_share_service.dart +++ b/lib/features/plans/services/plan_share_service.dart @@ -19,33 +19,28 @@ class PlanShareService { return 'weBuddhist://plans/invite?planId=$planId'; } - /// Generate share message with plan details - String generateShareMessage(String planTitle, String deepLink) { - return 'Join me in practicing $planTitle on WeBuddhist.\n\n$deepLink'; - } - /// Share a plan with friends - /// Opens the native share sheet with the plan invitation + /// Opens the native share sheet with the plan invitation. + /// [localizedMessage] and [localizedSubject] should come from the caller's + /// localization context (e.g. l10n.share_plan_message / l10n.share_plan_subject). Future sharePlan( String planId, String planTitle, { - String? customMessage, + required String localizedMessage, + required String localizedSubject, }) async { try { _logger.info('Sharing plan: $planId - $planTitle'); - // Generate deep link final deepLink = generatePlanInviteLink(planId); _logger.debug('Generated deep link: $deepLink'); - // Generate message - final message = customMessage ?? generateShareMessage(planTitle, deepLink); + final message = '$localizedMessage\n\n$deepLink'; - // Share using native share sheet // ignore: deprecated_member_use await Share.share( message, - subject: 'Join me on WeBuddhist', + subject: localizedSubject, ); _logger.info('Share completed successfully'); diff --git a/lib/features/practice/presentation/screens/bookmarks_screen.dart b/lib/features/practice/presentation/screens/bookmarks_screen.dart index a895108e..c6d94d71 100644 --- a/lib/features/practice/presentation/screens/bookmarks_screen.dart +++ b/lib/features/practice/presentation/screens/bookmarks_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_pecha/core/constants/app_assets.dart'; import 'package:flutter_pecha/core/extensions/context_ext.dart'; import 'package:flutter_pecha/core/theme/app_colors.dart'; import 'package:flutter_pecha/features/practice/data/models/bookmark_models.dart'; @@ -21,7 +22,13 @@ class _BookmarksScreenState extends ConsumerState with SingleTickerProviderStateMixin { late final TabController _tabController; - static const _tabLabels = ['All', 'Plans', 'Mala', 'Timers', 'Texts']; + List _tabLabels(BuildContext context) => [ + context.l10n.search_all, + context.l10n.home_shortcut_plans, + context.l10n.bookmark_mala, + context.l10n.bookmark_timers, + context.l10n.bookmark_texts, + ]; static const _tabs = [ BookmarkTab.all, BookmarkTab.plans, @@ -41,7 +48,7 @@ class _BookmarksScreenState extends ConsumerState @override void initState() { super.initState(); - _tabController = TabController(length: _tabLabels.length, vsync: this); + _tabController = TabController(length: _tabs.length, vsync: this); } @override @@ -116,37 +123,18 @@ class _BookmarksScreenState extends ConsumerState final state = ref.watch(bookmarksProvider); return Scaffold( - backgroundColor: - isDark - ? AppColors.scaffoldBackgroundDark - : AppColors.scaffoldBackgroundLight, appBar: AppBar( - backgroundColor: - isDark - ? AppColors.scaffoldBackgroundDark - : AppColors.scaffoldBackgroundLight, - elevation: 0, - scrolledUnderElevation: 0, - centerTitle: true, leading: IconButton( - icon: Icon( - Icons.arrow_back, - color: isDark ? AppColors.textPrimaryDark : AppColors.textPrimary, - ), + icon: const Icon(AppAssets.arrowLeft), onPressed: () => Navigator.of(context).pop(), ), title: Text( context.l10n.bookmarks, - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w600, - color: isDark ? AppColors.textPrimaryDark : AppColors.textPrimary, - ), - ), - bottom: PreferredSize( - preferredSize: const Size.fromHeight(48), - child: _buildTabBar(isDark), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18), ), + scrolledUnderElevation: 0, + centerTitle: true, + bottom: _buildTabBar(context, isDark), ), body: TabBarView( controller: _tabController, @@ -168,32 +156,51 @@ class _BookmarksScreenState extends ConsumerState ); } - Widget _buildTabBar(bool isDark) { - final indicatorColor = - isDark ? AppColors.textPrimaryDark : AppColors.textPrimary; + /// Scrollable tabs that still fill the screen width evenly when labels are + /// short; tabs grow past their share when a locale needs more room. + PreferredSizeWidget _buildTabBar(BuildContext context, bool isDark) { + final labels = _tabLabels(context); + final minTabWidth = MediaQuery.sizeOf(context).width / labels.length; return TabBar( controller: _tabController, isScrollable: true, tabAlignment: TabAlignment.start, - indicator: UnderlineTabIndicator( - borderSide: BorderSide(width: 2, color: indicatorColor), - insets: const EdgeInsets.symmetric(horizontal: 4), + labelPadding: EdgeInsets.zero, + padding: EdgeInsets.zero, + tabs: + labels + .map( + (label) => Tab( + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: minTabWidth), + child: Text( + label, + textAlign: TextAlign.center, + maxLines: 1, + ), + ), + ), + ) + .toList(), + labelStyle: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, ), - overlayColor: WidgetStateProperty.all(Colors.transparent), - splashFactory: NoSplash.splashFactory, - labelColor: isDark ? AppColors.textPrimaryDark : AppColors.textPrimary, - unselectedLabelColor: - isDark ? AppColors.textSubtleDark : AppColors.grey500, - labelStyle: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600), unselectedLabelStyle: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.normal, + fontSize: 14, ), - dividerColor: isDark ? AppColors.cardBorderDark : AppColors.grey300, - tabs: _tabLabels.map((label) => Tab(text: label)).toList(), + labelColor: + isDark ? AppColors.textPrimaryDark : AppColors.textPrimary, + unselectedLabelColor: + isDark ? AppColors.textTertiaryDark : AppColors.textSecondary, + indicatorColor: Colors.blue, + indicatorSize: TabBarIndicatorSize.tab, + dividerColor: Colors.transparent, ); } + } /// Renders one tab: loading / error / empty / grouped list states. diff --git a/lib/features/practice/presentation/screens/select_session_screen.dart b/lib/features/practice/presentation/screens/select_session_screen.dart index 9ac97ce8..42494891 100644 --- a/lib/features/practice/presentation/screens/select_session_screen.dart +++ b/lib/features/practice/presentation/screens/select_session_screen.dart @@ -19,8 +19,8 @@ import 'package:flutter_pecha/features/timer/domain/entities/preset_timer.dart'; import 'package:flutter_pecha/features/timer/presentation/providers/timers_providers.dart'; import 'package:flutter_pecha/shared/domain/value_objects/responsive_image.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_pecha/features/plans/data/utils/plan_date_format.dart'; import 'package:go_router/go_router.dart'; -import 'package:intl/intl.dart'; import 'package:phosphor_flutter/phosphor_flutter.dart'; import 'package:flutter_pecha/core/l10n/generated/app_localizations.dart'; @@ -139,7 +139,7 @@ class _SelectSessionScreenState extends ConsumerState tabs: [ Tab(text: localizations.home_shortcut_plans), Tab(text: localizations.home_chants), - Tab(text: localizations.home_mala), + Tab(text: localizations.session_mala), Tab(text: localizations.home_timer), ], labelStyle: const TextStyle( @@ -358,11 +358,7 @@ class _PlansTab extends ConsumerWidget { } static String? _formatDateRange(DateTime? start, DateTime? end) { - if (start == null) return null; - final fmt = DateFormat('MMM d'); - final startStr = fmt.format(start.toLocal()); - if (end == null) return startStr; - return '$startStr - ${fmt.format(end.toLocal())}'; + return PlanDateFormat.formatRangeOrSingle(start, end); } } @@ -651,12 +647,6 @@ class _TimersTab extends ConsumerWidget { ? AppColors.surfaceVariantDark : AppColors.grey100, borderRadius: BorderRadius.circular(8), - border: Border.all( - color: - isDark - ? AppColors.cardBorderDark - : AppColors.grey300, - ), ), child: Icon( PhosphorIconsRegular.timer, @@ -756,14 +746,8 @@ class _SessionCard extends StatelessWidget { child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(12), - child: Container( + child: Padding( padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isDark ? AppColors.cardBorderDark : AppColors.grey100, - ), - ), child: child, ), ), diff --git a/lib/features/practice/presentation/widgets/bookmark_card.dart b/lib/features/practice/presentation/widgets/bookmark_card.dart index a0d7557b..63de4b3d 100644 --- a/lib/features/practice/presentation/widgets/bookmark_card.dart +++ b/lib/features/practice/presentation/widgets/bookmark_card.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_pecha/core/theme/app_colors.dart'; import 'package:flutter_pecha/core/widgets/responsive_cover_image.dart'; +import 'package:flutter_pecha/features/plans/data/utils/plan_date_format.dart'; import 'package:flutter_pecha/features/practice/data/models/bookmark_models.dart'; -import 'package:intl/intl.dart'; import 'package:phosphor_flutter/phosphor_flutter.dart'; /// A single bookmark row. @@ -29,15 +29,12 @@ class BookmarkCard extends StatelessWidget { final isDark = Theme.of(context).brightness == Brightness.dark; final cardColor = isDark ? AppColors.cardBackgroundDark : AppColors.cardBackgroundLight; - final borderColor = - isDark ? AppColors.cardBorderDark : AppColors.grey300; return Container( margin: const EdgeInsets.only(bottom: 12), decoration: BoxDecoration( color: cardColor, borderRadius: BorderRadius.circular(12), - border: Border.all(color: borderColor), ), clipBehavior: Clip.antiAlias, child: Material( @@ -97,13 +94,12 @@ class BookmarkCard extends StatelessWidget { ); } - /// "MMM d - MMM d" for plan/series bookmarks that carry a schedule window. + /// Fixed-format date label for plan/series bookmarks with a schedule window. String? get _dateRangeLabel { - final start = bookmark.startDate; - if (start == null) return null; - final fmt = DateFormat('MMM d'); - final end = bookmark.endDate; - return end == null ? fmt.format(start) : '${fmt.format(start)} - ${fmt.format(end)}'; + return PlanDateFormat.formatRangeOrSingle( + bookmark.startDate, + bookmark.endDate, + ); } Widget _buildTextRow(BuildContext context, bool isDark) { diff --git a/lib/features/practice/presentation/widgets/practice_plan_card.dart b/lib/features/practice/presentation/widgets/practice_plan_card.dart index e91b63c8..aebb8aca 100644 --- a/lib/features/practice/presentation/widgets/practice_plan_card.dart +++ b/lib/features/practice/presentation/widgets/practice_plan_card.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter_pecha/core/constants/app_assets.dart'; import 'package:flutter_pecha/core/theme/app_colors.dart'; import 'package:flutter_pecha/core/widgets/responsive_cover_image.dart'; import 'package:flutter_pecha/features/home/domain/entities/series.dart'; -import 'package:intl/intl.dart'; +import 'package:flutter_pecha/features/plans/data/utils/plan_date_format.dart'; class PracticePlanCard extends StatelessWidget { const PracticePlanCard({ @@ -68,16 +69,15 @@ class PracticePlanCard extends StatelessWidget { overflow: TextOverflow.ellipsis, ), ), - if (dateRange != null) + if (dateRange != null || series.enrolledCount > 0) Padding( padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), - child: Text( - dateRange, - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - maxLines: 1, + child: _PracticePlanMetaRow( + dateRange: dateRange, + enrolledCount: series.enrolledCount, + fontSize: 12, + secondaryColor: + Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], @@ -87,10 +87,44 @@ class PracticePlanCard extends StatelessWidget { } static String? _formatSeriesDateRange(Series series) { - final startDate = series.startDate; - final endDate = series.endDate; - if (startDate == null || endDate == null) return null; - final formatter = DateFormat('d MMM'); - return '${formatter.format(startDate.toLocal())} – ${formatter.format(endDate.toLocal())}'; + return PlanDateFormat.formatRangeOrNull(series.startDate, series.endDate); + } +} + +class _PracticePlanMetaRow extends StatelessWidget { + const _PracticePlanMetaRow({ + required this.dateRange, + required this.enrolledCount, + required this.fontSize, + required this.secondaryColor, + }); + + final String? dateRange; + final int enrolledCount; + final double fontSize; + final Color secondaryColor; + + @override + Widget build(BuildContext context) { + final textStyle = TextStyle(fontSize: fontSize, color: secondaryColor); + + return Row( + children: [ + if (dateRange != null) + Expanded( + child: Text( + dateRange!, + style: textStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (enrolledCount > 0) ...[ + Icon(AppAssets.usercard, size: fontSize + 2, color: secondaryColor), + const SizedBox(width: 4), + Text('$enrolledCount', style: textStyle), + ], + ], + ); } } diff --git a/lib/features/practice/presentation/widgets/practice_plan_list_tile.dart b/lib/features/practice/presentation/widgets/practice_plan_list_tile.dart index 89e5723c..09fd67df 100644 --- a/lib/features/practice/presentation/widgets/practice_plan_list_tile.dart +++ b/lib/features/practice/presentation/widgets/practice_plan_list_tile.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter_pecha/core/constants/app_assets.dart'; import 'package:flutter_pecha/core/theme/app_colors.dart'; import 'package:flutter_pecha/core/widgets/responsive_cover_image.dart'; import 'package:flutter_pecha/features/home/domain/entities/series.dart'; -import 'package:intl/intl.dart'; +import 'package:flutter_pecha/features/plans/data/utils/plan_date_format.dart'; /// Horizontal list-row card for a practice plan: a small rounded thumbnail on /// the left with the title and date range on the right. @@ -61,16 +62,47 @@ class PracticePlanListTile extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, ), - if (dateRange != null) ...[ + if (dateRange != null || series.enrolledCount > 0) ...[ const SizedBox(height: 4), - Text( - dateRange, - style: TextStyle( - fontSize: 13, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + Row( + children: [ + if (dateRange != null) + Expanded( + child: Text( + dateRange, + style: TextStyle( + fontSize: 13, + color: + Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (series.enrolledCount > 0) ...[ + Icon( + AppAssets.usercard, + size: 15, + color: + Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + '${series.enrolledCount}', + style: TextStyle( + fontSize: 13, + color: + Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + ), + ], + ], ), ], ], @@ -84,10 +116,6 @@ class PracticePlanListTile extends StatelessWidget { } static String? _formatSeriesDateRange(Series series) { - final startDate = series.startDate; - final endDate = series.endDate; - if (startDate == null || endDate == null) return null; - final formatter = DateFormat('MMM d'); - return '${formatter.format(startDate.toLocal())} - ${formatter.format(endDate.toLocal())}'; + return PlanDateFormat.formatRangeOrNull(series.startDate, series.endDate); } } diff --git a/lib/features/practice/presentation/widgets/practice_timer_card.dart b/lib/features/practice/presentation/widgets/practice_timer_card.dart index 74bf1609..72f94238 100644 --- a/lib/features/practice/presentation/widgets/practice_timer_card.dart +++ b/lib/features/practice/presentation/widgets/practice_timer_card.dart @@ -16,9 +16,6 @@ class PracticeTimerCard extends StatelessWidget { @override Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final borderColor = - isDark ? const Color(0xFF353535) : const Color(0xFFE4E4E4); final isTibetan = AppFontConfig.isTibetanLanguage( Localizations.localeOf(context).languageCode, ); @@ -45,7 +42,6 @@ class PracticeTimerCard extends StatelessWidget { color: Theme.of(context).cardColor, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), - side: BorderSide(color: borderColor), ), clipBehavior: Clip.antiAlias, child: InkWell( diff --git a/lib/features/practice/presentation/widgets/routine_filled_state.dart b/lib/features/practice/presentation/widgets/routine_filled_state.dart index fea00f4b..472d6117 100644 --- a/lib/features/practice/presentation/widgets/routine_filled_state.dart +++ b/lib/features/practice/presentation/widgets/routine_filled_state.dart @@ -382,9 +382,6 @@ class _RoutineBlockSection extends ConsumerWidget { ? AppColors.cardBackgroundDark : AppColors.cardBackgroundLight, borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isDark ? AppColors.cardBorderDark : AppColors.grey100, - ), ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12), diff --git a/lib/features/practice/presentation/widgets/routine_time_block.dart b/lib/features/practice/presentation/widgets/routine_time_block.dart index f674c946..ca58a44d 100644 --- a/lib/features/practice/presentation/widgets/routine_time_block.dart +++ b/lib/features/practice/presentation/widgets/routine_time_block.dart @@ -143,11 +143,6 @@ class RoutineTimeBlock extends StatelessWidget { ? AppColors.cardBackgroundDark : AppColors.cardBackgroundLight, borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isDark - ? AppColors.cardBorderDark - : AppColors.grey100, - ), ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12), diff --git a/lib/features/reader/presentation/widgets/reader_actions/segement_action_bar.dart b/lib/features/reader/presentation/widgets/reader_actions/segement_action_bar.dart index 0c2e5dfe..6d9dd98b 100644 --- a/lib/features/reader/presentation/widgets/reader_actions/segement_action_bar.dart +++ b/lib/features/reader/presentation/widgets/reader_actions/segement_action_bar.dart @@ -9,6 +9,7 @@ import 'package:flutter_pecha/features/practice/data/datasource/bookmark_remote_ import 'package:flutter_pecha/features/practice/presentation/controllers/bookmark_controller.dart'; import 'package:flutter_pecha/features/practice/presentation/providers/bookmark_providers.dart'; import 'package:flutter_pecha/features/reader/presentation/providers/reader_notifier.dart'; +import 'package:flutter_pecha/features/reader/presentation/widgets/reader_panels/reader_panel_constants.dart'; import 'package:flutter_pecha/features/texts/data/models/segment.dart'; import 'package:flutter_pecha/features/texts/data/models/segment_info.dart'; import 'package:flutter_pecha/features/texts/presentation/providers/segment_provider.dart'; @@ -389,10 +390,8 @@ class _ResourcesPanelState extends State<_ResourcesPanel> { GestureDetector( behavior: HitTestBehavior.opaque, onVerticalDragEnd: - (details) => _handleDragHandleDragEnd( - context, - details, - ), + (details) => + _handleDragHandleDragEnd(context, details), child: ColoredBox( color: cardBackgroundColor, child: SizedBox( @@ -405,8 +404,8 @@ class _ResourcesPanelState extends State<_ResourcesPanel> { width: 36, height: 4, decoration: BoxDecoration( - color: theme.colorScheme.onSurface.withValues( - alpha: 0.35, + color: ReaderPanelConstants.dragHandleColor( + context, ), borderRadius: BorderRadius.circular(2), ), @@ -468,11 +467,7 @@ class _ResourcesPanelState extends State<_ResourcesPanel> { ), ), ), - Divider( - height: 1, - thickness: 1, - color: Theme.of(context).dividerColor, - ), + Divider(height: 1, thickness: 1, color: Theme.of(context).dividerColor), for (final tile in widget.tiles) tile, if (showMorePrompt) _SwipeForMorePrompt(onTap: _expand), if (showVideos && widget.videos.isNotEmpty) @@ -764,8 +759,12 @@ class _ShareButtonState extends ConsumerState<_ShareButton> { if (!mounted) return; final sharePositionOrigin = getSharePositionOrigin(context: context); + final shareMessage = context.l10n.share_passage_message; await SharePlus.instance.share( - ShareParams(text: shareUrl, sharePositionOrigin: sharePositionOrigin), + ShareParams( + text: '$shareMessage\n\n$shareUrl', + sharePositionOrigin: sharePositionOrigin, + ), ); widget.onClose(); } catch (e) { diff --git a/lib/features/reader/presentation/widgets/reader_app_bar/reader_font_size_bottom_sheet.dart b/lib/features/reader/presentation/widgets/reader_app_bar/reader_font_size_bottom_sheet.dart index 19dba529..382a9334 100644 --- a/lib/features/reader/presentation/widgets/reader_app_bar/reader_font_size_bottom_sheet.dart +++ b/lib/features/reader/presentation/widgets/reader_app_bar/reader_font_size_bottom_sheet.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_pecha/features/reader/presentation/widgets/reader_panels/reader_panel_constants.dart'; import 'package:flutter_pecha/features/texts/presentation/providers/font_size_notifier.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -54,7 +55,7 @@ class ReaderFontSizeBottomSheet extends ConsumerWidget { width: 40, height: 4, decoration: BoxDecoration( - color: Colors.grey.shade300, + color: ReaderPanelConstants.dragHandleColor(context), borderRadius: BorderRadius.circular(2), ), ), diff --git a/lib/features/reader/presentation/widgets/reader_app_bar/reader_more_bottom_sheet.dart b/lib/features/reader/presentation/widgets/reader_app_bar/reader_more_bottom_sheet.dart index 561a631b..a4e96426 100644 --- a/lib/features/reader/presentation/widgets/reader_app_bar/reader_more_bottom_sheet.dart +++ b/lib/features/reader/presentation/widgets/reader_app_bar/reader_more_bottom_sheet.dart @@ -7,6 +7,7 @@ import 'package:flutter_pecha/features/practice/data/datasource/bookmark_remote_ import 'package:flutter_pecha/features/practice/presentation/controllers/bookmark_controller.dart'; import 'package:flutter_pecha/features/practice/presentation/providers/bookmark_providers.dart'; import 'package:flutter_pecha/features/reader/presentation/widgets/reader_app_bar/reader_font_size_bottom_sheet.dart'; +import 'package:flutter_pecha/features/reader/presentation/widgets/reader_panels/reader_panel_constants.dart'; import 'package:flutter_pecha/features/texts/presentation/providers/font_size_notifier.dart'; import 'package:flutter_pecha/shared/utils/helper_functions.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -67,8 +68,12 @@ class _ReaderMoreBottomSheetState extends ConsumerState { DeepLinkUrlBuilder.readerLink(textId: widget.textId).toString(); if (!mounted) return; final sharePositionOrigin = getSharePositionOrigin(context: context); + final shareMessage = context.l10n.share_chant_message; await SharePlus.instance.share( - ShareParams(text: shareUrl, sharePositionOrigin: sharePositionOrigin), + ShareParams( + text: '$shareMessage\n\n$shareUrl', + sharePositionOrigin: sharePositionOrigin, + ), ); if (mounted) Navigator.of(context).pop(); } finally { @@ -120,7 +125,7 @@ class _ReaderMoreBottomSheetState extends ConsumerState { width: 36, height: 4, decoration: BoxDecoration( - color: theme.colorScheme.onSurface.withValues(alpha: 0.2), + color: ReaderPanelConstants.dragHandleColor(context), borderRadius: BorderRadius.circular(2), ), ), @@ -180,7 +185,7 @@ class _ReaderMoreBottomSheetState extends ConsumerState { ListTile( leading: Icon(AppAssets.plus, color: theme.colorScheme.onSurface), title: Text( - 'Add to my practices', + l10n.mala_add_to_practice, style: theme.textTheme.bodyLarge, ), onTap: () { diff --git a/lib/features/reader/presentation/widgets/reader_panels/reader_bottom_panel_shell.dart b/lib/features/reader/presentation/widgets/reader_panels/reader_bottom_panel_shell.dart index aa471818..b8511b65 100644 --- a/lib/features/reader/presentation/widgets/reader_panels/reader_bottom_panel_shell.dart +++ b/lib/features/reader/presentation/widgets/reader_panels/reader_bottom_panel_shell.dart @@ -81,18 +81,18 @@ class _ReaderBottomPanelShellState behavior: HitTestBehavior.opaque, onVerticalDragUpdate: _handleDragUpdate, onVerticalDragEnd: _handleDragEnd, - child: Column( + child: ColoredBox( + color: theme.colorScheme.onSurface.withValues(alpha: 0.05), + child: Column( mainAxisSize: MainAxisSize.min, children: [ Padding( - padding: const EdgeInsets.only(top: 10, bottom: 6), + padding: const EdgeInsets.only(top: 8, bottom: 2), child: Container( width: ReaderPanelConstants.dragHandleWidth, height: ReaderPanelConstants.dragHandleHeight, decoration: BoxDecoration( - color: theme.colorScheme.onSurface.withValues( - alpha: 0.25, - ), + color: ReaderPanelConstants.dragHandleColor(context), borderRadius: BorderRadius.circular(2), ), ), @@ -100,9 +100,9 @@ class _ReaderBottomPanelShellState Padding( padding: const EdgeInsets.fromLTRB( 8, - 4, + 2, ReaderPanelConstants.horizontalPadding, - 12, + 8, ), child: Row( children: [ @@ -132,6 +132,7 @@ class _ReaderBottomPanelShellState ], ), ), + ), Expanded(child: widget.child), ], ), diff --git a/lib/features/reader/presentation/widgets/reader_panels/reader_panel_constants.dart b/lib/features/reader/presentation/widgets/reader_panels/reader_panel_constants.dart index df385e2d..571bf7b4 100644 --- a/lib/features/reader/presentation/widgets/reader_panels/reader_panel_constants.dart +++ b/lib/features/reader/presentation/widgets/reader_panels/reader_panel_constants.dart @@ -28,4 +28,10 @@ class ReaderPanelConstants { ? Colors.white.withValues(alpha: 0.18) : Colors.black.withValues(alpha: 0.14); } + + /// Drag pill color: solid black in light mode, solid white in dark mode. + static Color dragHandleColor(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return isDark ? Colors.white : Colors.black; + } } diff --git a/lib/features/reader/presentation/widgets/reader_settings/picker_sheet_scaffold.dart b/lib/features/reader/presentation/widgets/reader_settings/picker_sheet_scaffold.dart index d14ec924..b1514953 100644 --- a/lib/features/reader/presentation/widgets/reader_settings/picker_sheet_scaffold.dart +++ b/lib/features/reader/presentation/widgets/reader_settings/picker_sheet_scaffold.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_pecha/features/reader/presentation/widgets/reader_panels/reader_panel_constants.dart'; class PickerSheetScaffold extends StatelessWidget { const PickerSheetScaffold({ @@ -30,7 +31,7 @@ class PickerSheetScaffold extends StatelessWidget { width: 40, height: 4, decoration: BoxDecoration( - color: theme.colorScheme.onSurface.withValues(alpha: 0.2), + color: ReaderPanelConstants.dragHandleColor(context), borderRadius: BorderRadius.circular(2), ), ), diff --git a/lib/features/timer/presentation/widgets/preset_timer_card.dart b/lib/features/timer/presentation/widgets/preset_timer_card.dart index 9290fde5..177e0ad8 100644 --- a/lib/features/timer/presentation/widgets/preset_timer_card.dart +++ b/lib/features/timer/presentation/widgets/preset_timer_card.dart @@ -28,8 +28,6 @@ class PresetTimerCard extends StatelessWidget { Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; final cardColor = isDark ? AppColors.surfaceDark : AppColors.surfaceWhite; - final borderColor = - isDark ? AppColors.cardBorderDark : const Color(0xFFE4E4E4); final textColor = Theme.of(context).colorScheme.onSurface; final isTibetan = AppFontConfig.isTibetanLanguage( Localizations.localeOf(context).languageCode, @@ -63,7 +61,6 @@ class PresetTimerCard extends StatelessWidget { color: cardColor, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(_borderRadius), - side: BorderSide(color: borderColor), ), clipBehavior: Clip.antiAlias, child: InkWell( diff --git a/lib/features/timer/presentation/widgets/timer_more_bottom_sheet.dart b/lib/features/timer/presentation/widgets/timer_more_bottom_sheet.dart index 47b55954..2f44be09 100644 --- a/lib/features/timer/presentation/widgets/timer_more_bottom_sheet.dart +++ b/lib/features/timer/presentation/widgets/timer_more_bottom_sheet.dart @@ -1,12 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_pecha/core/constants/app_assets.dart'; +import 'package:flutter_pecha/core/deep_linking/deep_link_url_builder.dart'; import 'package:flutter_pecha/core/extensions/context_ext.dart'; import 'package:flutter_pecha/features/practice/data/datasource/bookmark_remote_datasource.dart'; import 'package:flutter_pecha/features/practice/presentation/controllers/bookmark_controller.dart'; import 'package:flutter_pecha/features/practice/presentation/providers/bookmark_providers.dart'; import 'package:flutter_pecha/features/timer/domain/entities/preset_timer.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:share_plus/share_plus.dart'; /// Bottom sheet opened from the three-dot (⋮) button on a [PresetTimerCard]. /// @@ -30,12 +32,29 @@ class TimerMoreBottomSheet extends ConsumerStatefulWidget { class _TimerMoreBottomSheetState extends ConsumerState { bool _isBookmarking = false; + bool _isSharing = false; BookmarkTarget get _bookmarkTarget => BookmarkTarget( type: BookmarkType.timer, sourceId: widget.timer.id, ); + Future _share() async { + if (_isSharing) return; + setState(() => _isSharing = true); + try { + final nav = Navigator.of(context); + final shareUrl = DeepLinkUrlBuilder.timerLink(timerId: widget.timer.id).toString(); + final shareMessage = context.l10n.share_timer_message; + nav.pop(); + await SharePlus.instance.share( + ShareParams(text: '$shareMessage\n\n$shareUrl'), + ); + } finally { + if (mounted) setState(() => _isSharing = false); + } + } + Future _toggleBookmark() async { if (_isBookmarking) return; setState(() => _isBookmarking = true); @@ -80,7 +99,7 @@ class _TimerMoreBottomSheetState extends ConsumerState { ListTile( leading: Icon(AppAssets.plus, color: theme.colorScheme.onSurface), title: Text( - 'Add to my practices', + l10n.mala_add_to_practice, style: theme.textTheme.bodyLarge, ), onTap: () { @@ -116,6 +135,27 @@ class _TimerMoreBottomSheetState extends ConsumerState { }, ), + // ── Share ────────────────────────────────────────────────────── + _SectionDivider(theme: theme), + ListTile( + leading: + _isSharing + ? SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.onSurface, + ), + ) + : Icon(AppAssets.readerShare, color: theme.colorScheme.onSurface), + title: Text(l10n.share, style: theme.textTheme.bodyLarge), + onTap: () { + HapticFeedback.lightImpact(); + _share(); + }, + ), + const SizedBox(height: 8), ], ), diff --git a/lib/main.dart b/lib/main.dart index edf26a82..8b8289b7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -26,6 +26,7 @@ import 'package:flutter_pecha/features/push_notifications/presentation/providers import 'package:flutter_pecha/features/home/data/datasource/home_local_datasource.dart'; import 'package:flutter_pecha/features/home/presentation/providers/use_case_providers.dart'; import 'package:flutter_pecha/features/auth/presentation/providers/state_providers.dart'; +import 'package:flutter_pecha/features/home/presentation/screens/main_navigation_screen.dart'; import 'package:flutter_pecha/features/mala/data/datasources/mala_local_datasource.dart'; import 'package:flutter_pecha/features/mala/presentation/providers/mala_providers.dart'; import 'package:flutter_pecha/features/more/data/datasource/user_stats_local_datasource.dart'; @@ -263,6 +264,9 @@ class _MyAppState extends ConsumerState with WidgetsBindingObserver { WidgetsBinding.instance.addPostFrameCallback((_) { AirbridgeDeepLinkService.setRouter(router); AppLinksDeepLinkService.instance.setRouter(router); + AppLinksDeepLinkService.instance.setTabSetter((int tabIndex) { + ref.read(mainNavigationIndexProvider.notifier).state = tabIndex; + }); }); _hasRegisteredDeepLinkRouters = true; } diff --git a/pubspec.lock b/pubspec.lock index 23f0b1ce..852277f7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1687,10 +1687,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.6" tibetan_calendar: dependency: "direct main" description: diff --git a/test/core/config/locale_provider_test.mocks.dart b/test/core/config/locale_provider_test.mocks.dart new file mode 100644 index 00000000..1109a9ae --- /dev/null +++ b/test/core/config/locale_provider_test.mocks.dart @@ -0,0 +1,100 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in flutter_pecha/test/core/config/locale_provider_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i3; + +import 'package:flutter_pecha/core/utils/local_storage_service.dart' as _i2; +import 'package:mockito/mockito.dart' as _i1; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member + +/// A class which mocks [LocalStorageService]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockLocalStorageService extends _i1.Mock + implements _i2.LocalStorageService { + MockLocalStorageService() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Future setUserData(Map? userData) => + (super.noSuchMethod( + Invocation.method(#setUserData, [userData]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future?> getUserData() => + (super.noSuchMethod( + Invocation.method(#getUserData, []), + returnValue: _i3.Future?>.value(), + ) + as _i3.Future?>); + + @override + _i3.Future clearUserData() => + (super.noSuchMethod( + Invocation.method(#clearUserData, []), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future get(String? key) => + (super.noSuchMethod( + Invocation.method(#get, [key]), + returnValue: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future set(String? key, T? value) => + (super.noSuchMethod( + Invocation.method(#set, [key, value]), + returnValue: _i3.Future.value(false), + ) + as _i3.Future); + + @override + _i3.Future remove(String? key) => + (super.noSuchMethod( + Invocation.method(#remove, [key]), + returnValue: _i3.Future.value(false), + ) + as _i3.Future); + + @override + _i3.Future clear() => + (super.noSuchMethod( + Invocation.method(#clear, []), + returnValue: _i3.Future.value(false), + ) + as _i3.Future); + + @override + _i3.Future containsKey(String? key) => + (super.noSuchMethod( + Invocation.method(#containsKey, [key]), + returnValue: _i3.Future.value(false), + ) + as _i3.Future); +} diff --git a/test/features/mala/mala_counter_notifier_test.dart b/test/features/mala/mala_counter_notifier_test.dart index 81fbd3a7..f9d78de1 100644 --- a/test/features/mala/mala_counter_notifier_test.dart +++ b/test/features/mala/mala_counter_notifier_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'dart:typed_data'; import 'package:flutter_pecha/core/error/failures.dart'; import 'package:flutter_pecha/features/mala/data/datasources/mala_local_datasource.dart'; @@ -274,4 +275,107 @@ void main() { verifyNever(sync.flush(any)); notifier.dispose(); }); + + test('stores bead bytes after retrying expired presigned URL', () async { + const staleUrl = + 'https://cdn.example/mala/bead.webp?X-Amz-Expires=1&sig=old'; + const freshUrl = + 'https://cdn.example/mala/bead.webp?X-Amz-Expires=3600&sig=new'; + const imageBytes = [0x89, 0x50, 0x4E, 0x47]; + + when(getDetail(any)).thenAnswer( + (_) async => const Right( + MalaCount(accumulatorId: 'acc-1', total: 0, beadImageUrl: freshUrl), + ), + ); + + await local.write( + userId, + 'chenrezig', + const LocalMalaState(beadImageUrl: staleUrl), + ); + + final mantraWithImage = Mantra( + presetId: mantra.presetId, + beadImageUrl: staleUrl, + metadata: mantra.metadata, + mantra: mantra.mantra, + ); + + var downloadUrls = []; + final notifier = MalaCounterNotifier( + mantra: mantraWithImage, + local: local, + getAccumulatorDetail: getDetail, + deleteUserAccumulator: delete, + sync: sync, + currentUserId: () async => userId, + downloadImageBytes: (url) async { + downloadUrls.add(url); + if (url.contains('sig=old')) { + throw Exception('403'); + } + return imageBytes; + }, + ); + + await Future.delayed(const Duration(milliseconds: 50)); + + expect(downloadUrls, contains(freshUrl)); + expect(notifier.state.beadImageBytes, Uint8List.fromList(imageBytes)); + expect(local.read(userId, 'chenrezig').beadImageUrl, freshUrl); + notifier.dispose(); + }); + + test('retries stale presigned URL when detail has no bead image', () async { + const staleUrl = + 'https://cdn.example/mala/bead.webp?X-Amz-Expires=1&sig=old'; + const freshUrl = + 'https://cdn.example/mala/bead.webp?X-Amz-Expires=3600&sig=new'; + const imageBytes = [0x89, 0x50, 0x4E, 0x47]; + + var detailCalls = 0; + when(getDetail(any)).thenAnswer((_) async { + detailCalls++; + if (detailCalls == 1) { + return const Right(MalaCount(accumulatorId: 'acc-1', total: 0)); + } + return const Right( + MalaCount(accumulatorId: 'acc-1', total: 0, beadImageUrl: freshUrl), + ); + }); + + await local.write( + userId, + 'chenrezig', + const LocalMalaState(beadImageUrl: staleUrl), + ); + + var staleAttempts = 0; + final notifier = MalaCounterNotifier( + mantra: mantra, + local: local, + getAccumulatorDetail: getDetail, + deleteUserAccumulator: delete, + sync: sync, + currentUserId: () async => userId, + downloadImageBytes: (url) async { + if (url.contains('sig=old')) { + staleAttempts++; + throw Exception('403'); + } + return imageBytes; + }, + ); + + await Future.doWhile(() async { + await Future.delayed(const Duration(milliseconds: 10)); + return notifier.state.beadImageBytes == null; + }).timeout(const Duration(seconds: 1)); + + expect(staleAttempts, greaterThan(0)); + expect(notifier.state.beadImageBytes, Uint8List.fromList(imageBytes)); + expect(local.read(userId, 'chenrezig').beadImageUrl, freshUrl); + notifier.dispose(); + }); } diff --git a/test/features/mala/mala_counter_notifier_test.mocks.dart b/test/features/mala/mala_counter_notifier_test.mocks.dart index 76570179..73ec12dc 100644 --- a/test/features/mala/mala_counter_notifier_test.mocks.dart +++ b/test/features/mala/mala_counter_notifier_test.mocks.dart @@ -116,6 +116,22 @@ class MockMalaSyncManager extends _i1.Mock implements _i8.MalaSyncManager { ) as _i3.Future); + @override + _i3.Future resetGroupAccumulator( + String? groupAccumulatorId, { + required _i2.DeleteGroupAccumulatorUseCase? deleteGroupAccumulator, + }) => + (super.noSuchMethod( + Invocation.method( + #resetGroupAccumulator, + [groupAccumulatorId], + {#deleteGroupAccumulator: deleteGroupAccumulator}, + ), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + @override _i3.Future didPopRoute() => (super.noSuchMethod( diff --git a/test/features/mala/mala_sync_manager_test.dart b/test/features/mala/mala_sync_manager_test.dart index 85e6aa7b..0c8c6395 100644 --- a/test/features/mala/mala_sync_manager_test.dart +++ b/test/features/mala/mala_sync_manager_test.dart @@ -18,6 +18,7 @@ import 'mala_sync_manager_test.mocks.dart'; UpdateUserAccumulatorUseCase, DeleteUserAccumulatorUseCase, SubmitGroupAccumulatorCountUseCase, + DeleteGroupAccumulatorUseCase, ]) void main() { provideDummy>( @@ -31,6 +32,7 @@ void main() { late MockUpdateUserAccumulatorUseCase update; late MockDeleteUserAccumulatorUseCase delete; late MockSubmitGroupAccumulatorCountUseCase submitGroup; + late MockDeleteGroupAccumulatorUseCase deleteGroup; const userA = 'user-a'; const userB = 'user-b'; @@ -60,7 +62,9 @@ void main() { update = MockUpdateUserAccumulatorUseCase(); delete = MockDeleteUserAccumulatorUseCase(); submitGroup = MockSubmitGroupAccumulatorCountUseCase(); + deleteGroup = MockDeleteGroupAccumulatorUseCase(); when(submitGroup(any)).thenAnswer((_) async => const Right(unit)); + when(deleteGroup(any)).thenAnswer((_) async => const Right(unit)); }); tearDown(() async { @@ -341,4 +345,51 @@ void main() { expect(captured.groupAccumulatorId, groupAccId); expect(local.readGroup(userB, 'group-acc-b').isDirty, isTrue); }); + + test('group reset flushes dirty tail then soft-deletes group count', () async { + await local.writeGroup( + userA, + groupAccId, + const LocalGroupMalaState(total: 50, syncedTotal: 40), + ); + when(submitGroup(any)).thenAnswer((_) async => const Right(unit)); + when(deleteGroup(any)).thenAnswer((_) async => const Right(unit)); + + await buildManager().resetGroupAccumulator( + groupAccId, + deleteGroupAccumulator: deleteGroup, + ); + + final captured = verify(submitGroup(captureAny)).captured.single + as SubmitGroupAccumulatorCountParams; + expect(captured.groupAccumulatorId, groupAccId); + expect(captured.currentCount, 50); + verify(deleteGroup(groupAccId)).called(1); + + final after = local.readGroup(userA, groupAccId); + expect(after.total, 0); + expect(after.syncedTotal, 0); + expect(after.isDirty, isFalse); + }); + + test('group reset skips POST when fully synced then soft-deletes', () async { + await local.writeGroup( + userA, + groupAccId, + const LocalGroupMalaState(total: 50, syncedTotal: 50), + ); + when(deleteGroup(any)).thenAnswer((_) async => const Right(unit)); + + await buildManager().resetGroupAccumulator( + groupAccId, + deleteGroupAccumulator: deleteGroup, + ); + + verifyNever(submitGroup(any)); + verify(deleteGroup(groupAccId)).called(1); + + final after = local.readGroup(userA, groupAccId); + expect(after.total, 0); + expect(after.syncedTotal, 0); + }); } diff --git a/test/features/mala/mala_sync_manager_test.mocks.dart b/test/features/mala/mala_sync_manager_test.mocks.dart index fda59bb4..04d3d813 100644 --- a/test/features/mala/mala_sync_manager_test.mocks.dart +++ b/test/features/mala/mala_sync_manager_test.mocks.dart @@ -126,3 +126,28 @@ class MockSubmitGroupAccumulatorCountUseCase extends _i1.Mock ) as _i3.Future<_i4.Either<_i5.Failure, _i4.Unit>>); } + +/// A class which mocks [DeleteGroupAccumulatorUseCase]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockDeleteGroupAccumulatorUseCase extends _i1.Mock + implements _i2.DeleteGroupAccumulatorUseCase { + MockDeleteGroupAccumulatorUseCase() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Future<_i4.Either<_i5.Failure, _i4.Unit>> call( + String? groupAccumulatorId, + ) => + (super.noSuchMethod( + Invocation.method(#call, [groupAccumulatorId]), + returnValue: _i3.Future<_i4.Either<_i5.Failure, _i4.Unit>>.value( + _i7.dummyValue<_i4.Either<_i5.Failure, _i4.Unit>>( + this, + Invocation.method(#call, [groupAccumulatorId]), + ), + ), + ) + as _i3.Future<_i4.Either<_i5.Failure, _i4.Unit>>); +}