diff --git a/lib/core/config/router/route_guard.dart b/lib/core/config/router/route_guard.dart index a240079e..45f3e2ad 100644 --- a/lib/core/config/router/route_guard.dart +++ b/lib/core/config/router/route_guard.dart @@ -59,23 +59,33 @@ class RouteGuard { String? Function() getPendingRoute, void Function(String?) setPendingRoute, ) async { - final hasOnboarded = await onboardingRepo.isOnboardingCompleted().then( - (result) => - result.fold((failure) => false, (hasCompleted) => hasCompleted), - ); - - // Force onboarding if not completed - if (!hasOnboarded && - path != AppRoutes.onboarding && - path != AppRoutes.login) { - return AppRoutes.onboarding; - } - - // Skip onboarding if already done - if (hasOnboarded && path == AppRoutes.onboarding) { + // Onboarding is temporarily disabled for everyone. Never force it, and + // send any direct navigation to /onboarding back to home. This also fixes + // the offline case, where the original check below defaulted to "not + // onboarded" and wrongly forced onboarding. + if (path == AppRoutes.onboarding) { return AppRoutes.home; } + // Original force-onboarding logic, preserved (commented out) so it can be + // re-enabled later by deleting the redirect above and uncommenting this. + // final hasOnboarded = await onboardingRepo.isOnboardingCompleted().then( + // (result) => + // result.fold((failure) => false, (hasCompleted) => hasCompleted), + // ); + // + // // Force onboarding if not completed + // if (!hasOnboarded && + // path != AppRoutes.onboarding && + // path != AppRoutes.login) { + // return AppRoutes.onboarding; + // } + // + // // Skip onboarding if already done + // if (hasOnboarded && path == AppRoutes.onboarding) { + // return AppRoutes.home; + // } + // Redirect from login or splash to pending route or home if (path == AppRoutes.login || path == AppRoutes.splash) { final pending = getPendingRoute(); 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 d67bcc5c..824cf811 100644 --- a/lib/core/deep_linking/app_links_deep_link_service.dart +++ b/lib/core/deep_linking/app_links_deep_link_service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:app_links/app_links.dart'; +import 'package:flutter_pecha/core/config/router/app_routes.dart'; import 'package:flutter_pecha/core/deep_linking/deep_link_router.dart'; import 'package:flutter_pecha/core/utils/app_logger.dart'; import 'package:go_router/go_router.dart'; @@ -56,7 +57,7 @@ class AppLinksDeepLinkService { if (pending == null) return false; _pendingUri = null; - _dispatch(pending); + _dispatch(pending, baseLocation: AppRoutes.home); return true; } @@ -81,10 +82,15 @@ class AppLinksDeepLinkService { _dispatch(uri); } - void _dispatch(Uri uri) { + void _dispatch(Uri uri, {String? baseLocation}) { final router = _router; if (router == null) return; - DeepLinkRouter.route(uri, router, source: 'app_links'); + DeepLinkRouter.route( + uri, + router, + source: 'app_links', + baseLocation: baseLocation, + ); } } diff --git a/lib/core/deep_linking/deep_link_router.dart b/lib/core/deep_linking/deep_link_router.dart index 5f8c4932..026569c5 100644 --- a/lib/core/deep_linking/deep_link_router.dart +++ b/lib/core/deep_linking/deep_link_router.dart @@ -1,3 +1,4 @@ +import 'package:flutter/widgets.dart'; import 'package:flutter_pecha/core/config/router/app_routes.dart'; import 'package:flutter_pecha/core/utils/app_logger.dart'; import 'package:flutter_pecha/features/reader/data/models/navigation_context.dart'; @@ -8,7 +9,12 @@ class DeepLinkRouter { static final _logger = AppLogger('DeepLinkRouter'); - static bool route(Uri uri, GoRouter router, {required String source}) { + static bool route( + Uri uri, + GoRouter router, { + required String source, + String? baseLocation, + }) { try { final destination = _resolveRoute(uri); if (destination == null) { @@ -17,7 +23,12 @@ class DeepLinkRouter { } _logger.info('Deep link from $source -> ${destination.location} ($uri)'); - if (destination.opensOnTop) { + if (destination.opensOnTop && baseLocation != null) { + router.go(baseLocation); + WidgetsBinding.instance.addPostFrameCallback((_) { + router.push(destination.location, extra: destination.extra); + }); + } else if (destination.opensOnTop) { router.push(destination.location, extra: destination.extra); } else { router.go(destination.location, extra: destination.extra); diff --git a/lib/features/practice/presentation/screens/select_session_screen.dart b/lib/features/practice/presentation/screens/select_session_screen.dart index d352827b..9ac97ce8 100644 --- a/lib/features/practice/presentation/screens/select_session_screen.dart +++ b/lib/features/practice/presentation/screens/select_session_screen.dart @@ -12,8 +12,8 @@ import 'package:flutter_pecha/features/practice/data/models/session_selection.da import 'package:flutter_pecha/features/practice/domain/entities/practice_item.dart'; import 'package:flutter_pecha/features/practice/domain/entities/practice_items_tab.dart'; import 'package:flutter_pecha/features/practice/presentation/providers/practice_items_paginated_provider.dart'; +import 'package:flutter_pecha/features/practice/presentation/providers/practice_recitations_paginated_provider.dart'; import 'package:flutter_pecha/features/recitation/data/models/recitation_model.dart'; -import 'package:flutter_pecha/features/recitation/presentation/providers/recitations_providers.dart'; import 'package:flutter_pecha/features/recitation/presentation/widgets/recitation_list_skeleton.dart'; import 'package:flutter_pecha/features/timer/domain/entities/preset_timer.dart'; import 'package:flutter_pecha/features/timer/presentation/providers/timers_providers.dart'; @@ -42,6 +42,7 @@ class _SelectSessionScreenState extends ConsumerState late TabController _tabController; final ScrollController _plansScrollController = ScrollController(); + final ScrollController _chantsScrollController = ScrollController(); final String? _enrollingItemId = null; @@ -50,6 +51,7 @@ class _SelectSessionScreenState extends ConsumerState super.initState(); _tabController = TabController(length: 4, vsync: this); _plansScrollController.addListener(_onPlansScroll); + _chantsScrollController.addListener(_onChantsScroll); } @override @@ -57,6 +59,8 @@ class _SelectSessionScreenState extends ConsumerState _tabController.dispose(); _plansScrollController.removeListener(_onPlansScroll); _plansScrollController.dispose(); + _chantsScrollController.removeListener(_onChantsScroll); + _chantsScrollController.dispose(); super.dispose(); } @@ -69,6 +73,13 @@ class _SelectSessionScreenState extends ConsumerState } } + void _onChantsScroll() { + if (_chantsScrollController.position.pixels >= + _chantsScrollController.position.maxScrollExtent - 200) { + ref.read(practiceRecitationsPaginatedProvider.notifier).loadMore(); + } + } + void _onPracticeItemSelected(PracticeItem item) { if (_enrollingItemId != null) return; final selection = switch (item) { @@ -80,9 +91,9 @@ class _SelectSessionScreenState extends ConsumerState void _onRecitationSelected(RecitationModel recitation) { if (_enrollingItemId != null) return; - Navigator.of(context).pop( - RecitationSessionSelection(recitation), - ); + Navigator.of( + context, + ).pop(RecitationSessionSelection(recitation)); } void _onMantraSelected(Mantra mantra) { @@ -113,13 +124,20 @@ class _SelectSessionScreenState extends ConsumerState actions: [ IconButton( icon: const Icon(Icons.search, size: 22), - onPressed: () {}, + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(localizations.mala_action_coming_soon), + behavior: SnackBarBehavior.floating, + ), + ); + }, ), ], bottom: TabBar( controller: _tabController, tabs: [ - Tab(text: localizations.home_plans), + Tab(text: localizations.home_shortcut_plans), Tab(text: localizations.home_chants), Tab(text: localizations.home_mala), Tab(text: localizations.home_timer), @@ -132,10 +150,10 @@ class _SelectSessionScreenState extends ConsumerState fontWeight: FontWeight.normal, fontSize: 14, ), - labelColor: isDark ? AppColors.textPrimaryDark : AppColors.textPrimary, - unselectedLabelColor: isDark - ? AppColors.textTertiaryDark - : AppColors.textSecondary, + labelColor: + isDark ? AppColors.textPrimaryDark : AppColors.textPrimary, + unselectedLabelColor: + isDark ? AppColors.textTertiaryDark : AppColors.textSecondary, indicatorColor: Colors.blue, indicatorSize: TabBarIndicatorSize.tab, dividerColor: Colors.transparent, @@ -151,6 +169,7 @@ class _SelectSessionScreenState extends ConsumerState onItemSelected: _onPracticeItemSelected, ), _ChantsTab( + scrollController: _chantsScrollController, enrollingItemId: _enrollingItemId, onRecitationSelected: _onRecitationSelected, ), @@ -223,9 +242,10 @@ class _PlansTab extends ConsumerWidget { return Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: Center( - child: itemsState.isLoadingMore - ? const CircularProgressIndicator() - : const SizedBox.shrink(), + child: + itemsState.isLoadingMore + ? const CircularProgressIndicator() + : const SizedBox.shrink(), ), ); } @@ -275,9 +295,10 @@ class _PlansTab extends ConsumerWidget { dateRange, style: TextStyle( fontSize: 13, - color: isDark - ? AppColors.textTertiaryDark - : AppColors.textSecondary, + color: + isDark + ? AppColors.textTertiaryDark + : AppColors.textSecondary, ), ), ], @@ -290,9 +311,8 @@ class _PlansTab extends ConsumerWidget { case PracticeSeriesItem(:final series): final dateRange = _formatDateRange(series.startDate, series.endDate); - final subtitle = series.subTitle?.isNotEmpty == true - ? series.subTitle - : dateRange; + final subtitle = + series.subTitle?.isNotEmpty == true ? series.subTitle : dateRange; return _SessionCard( isDark: isDark, onTap: enrollingItemId == null ? () => onItemSelected(item) : null, @@ -319,9 +339,10 @@ class _PlansTab extends ConsumerWidget { subtitle, style: TextStyle( fontSize: 13, - color: isDark - ? AppColors.textTertiaryDark - : AppColors.textSecondary, + color: + isDark + ? AppColors.textTertiaryDark + : AppColors.textSecondary, ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -348,10 +369,12 @@ class _PlansTab extends ConsumerWidget { // ─── Chants tab ────────────────────────────────────────────────────────────── class _ChantsTab extends ConsumerWidget { + final ScrollController scrollController; final String? enrollingItemId; final void Function(RecitationModel recitation) onRecitationSelected; const _ChantsTab({ + required this.scrollController, required this.enrollingItemId, required this.onRecitationSelected, }); @@ -359,99 +382,112 @@ class _ChantsTab extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final isDark = Theme.of(context).brightness == Brightness.dark; - final recitationsAsync = ref.watch(recitationsFutureProvider); - Future onRefresh() async { - ref.invalidate(recitationsFutureProvider); - await ref.read(recitationsFutureProvider.future); + final recitationsState = ref.watch(practiceRecitationsPaginatedProvider); + + Future onRefresh() => + ref.read(practiceRecitationsPaginatedProvider.notifier).refresh(); + + if (recitationsState.isLoading && recitationsState.recitations.isEmpty) { + return const RecitationListSkeleton(); } - return recitationsAsync.when( - loading: () => const RecitationListSkeleton(), - error: (_, __) => _RefreshableScrollBody( + if (recitationsState.error != null && + recitationsState.recitations.isEmpty) { + return _RefreshableScrollBody( onRefresh: onRefresh, child: const _SessionTabMessage(message: 'Unable to load chants'), - ), - data: (recitationsEither) => recitationsEither.fold( - (_) => _RefreshableScrollBody( - onRefresh: onRefresh, - child: const _SessionTabMessage(message: 'Unable to load chants'), - ), - (recitations) { - if (recitations.isEmpty) { - return _RefreshableScrollBody( - onRefresh: onRefresh, - child: const _SessionTabMessage(message: 'No chants found'), + ); + } + + final recitations = recitationsState.recitations; + + if (recitations.isEmpty && !recitationsState.isLoading) { + return _RefreshableScrollBody( + onRefresh: onRefresh, + child: const _SessionTabMessage(message: 'No chants found'), + ); + } + + return RefreshIndicator( + onRefresh: onRefresh, + child: ListView.builder( + controller: scrollController, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + itemCount: recitations.length + (recitationsState.hasMore ? 1 : 0), + itemBuilder: (context, index) { + if (index == recitations.length) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Center( + child: + recitationsState.isLoadingMore + ? const CircularProgressIndicator() + : const SizedBox.shrink(), + ), ); } - return RefreshIndicator( - onRefresh: onRefresh, - child: ListView.builder( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - itemCount: recitations.length, - itemBuilder: (context, index) { - final recitation = recitations[index]; - final description = recitation.firstSegment?.content; - - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: _SessionCard( - isDark: isDark, - onTap: enrollingItemId == null - ? () => onRecitationSelected(recitation) - : null, - child: Row( + final recitation = recitations[index]; + final description = recitation.firstSegment?.content; + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _SessionCard( + isDark: isDark, + onTap: + enrollingItemId == null + ? () => onRecitationSelected(recitation) + : null, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 4, + height: description != null ? null : 44, + constraints: const BoxConstraints(minHeight: 44), + decoration: BoxDecoration( + color: + isDark + ? AppColors.textTertiaryDark + : AppColors.grey400, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 4, - height: description != null ? null : 44, - constraints: const BoxConstraints(minHeight: 44), - decoration: BoxDecoration( - color: isDark - ? AppColors.textTertiaryDark - : AppColors.grey400, - borderRadius: BorderRadius.circular(2), + Text( + recitation.title, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - recitation.title, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - if (description != null && - description.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - description, - style: TextStyle( - fontSize: 13, - color: isDark - ? AppColors.textTertiaryDark - : AppColors.textSecondary, - ), - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - ], - ], + if (description != null && description.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + description, + style: TextStyle( + fontSize: 13, + color: + isDark + ? AppColors.textTertiaryDark + : AppColors.textSecondary, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, ), - ), + ], ], ), ), - ); - }, + ], + ), ), ); }, @@ -479,67 +515,72 @@ class _MalasTab extends ConsumerWidget { return catalogueAsync.when( loading: () => const Center(child: CircularProgressIndicator()), - error: (_, __) => _RefreshableScrollBody( - onRefresh: onRefresh, - child: const _SessionTabMessage(message: 'Unable to load malas'), - ), - data: (either) => either.fold( - (_) => _RefreshableScrollBody( - onRefresh: onRefresh, - child: const _SessionTabMessage(message: 'Unable to load malas'), - ), - (mantras) { - if (mantras.isEmpty) { - return _RefreshableScrollBody( - onRefresh: onRefresh, - child: const _SessionTabMessage(message: 'No malas found'), - ); - } - - return RefreshIndicator( + error: + (_, __) => _RefreshableScrollBody( onRefresh: onRefresh, - child: ListView.builder( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - itemCount: mantras.length, - itemBuilder: (context, index) { - final mantra = mantras[index]; - final imageUrl = - mantra.beadImageUrl ?? mantra.mantra?.beadImageUrl; - final title = mantra.displayTitle(language); - _logger.debug( - '🪬 Mala[$index] title=$title imageUrl=$imageUrl', + child: const _SessionTabMessage(message: 'Unable to load malas'), + ), + data: + (either) => either.fold( + (_) => _RefreshableScrollBody( + onRefresh: onRefresh, + child: const _SessionTabMessage(message: 'Unable to load malas'), + ), + (mantras) { + if (mantras.isEmpty) { + return _RefreshableScrollBody( + onRefresh: onRefresh, + child: const _SessionTabMessage(message: 'No malas found'), ); - - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: _SessionCard( - isDark: isDark, - onTap: () => onMantraSelected(mantra), - child: Row( - children: [ - _CircularImage(imageUrl: imageUrl, size: 52), - const SizedBox(width: 12), - Expanded( - child: Text( - title, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, + } + + return RefreshIndicator( + onRefresh: onRefresh, + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + itemCount: mantras.length, + itemBuilder: (context, index) { + final mantra = mantras[index]; + final imageUrl = + mantra.beadImageUrl ?? mantra.mantra?.beadImageUrl; + final title = mantra.displayTitle(language); + _logger.debug( + '🪬 Mala[$index] title=$title imageUrl=$imageUrl', + ); + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _SessionCard( + isDark: isDark, + onTap: () => onMantraSelected(mantra), + child: Row( + children: [ + _CircularImage(imageUrl: imageUrl, size: 52), + const SizedBox(width: 12), + Expanded( + child: Text( + title, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), + ], ), - ], - ), - ), - ); - }, - ), - ); - }, - ), + ), + ); + }, + ), + ); + }, + ), ); } } @@ -563,82 +604,90 @@ class _TimersTab extends ConsumerWidget { return timersAsync.when( loading: () => const Center(child: CircularProgressIndicator()), - error: (_, __) => _RefreshableScrollBody( - onRefresh: onRefresh, - child: const _SessionTabMessage(message: 'Unable to load timers'), - ), - data: (either) => either.fold( - (_) => _RefreshableScrollBody( - onRefresh: onRefresh, - child: const _SessionTabMessage(message: 'Unable to load timers'), - ), - (timers) { - if (timers.isEmpty) { - return _RefreshableScrollBody( - onRefresh: onRefresh, - child: const _SessionTabMessage(message: 'No timers found'), - ); - } - - return RefreshIndicator( + error: + (_, __) => _RefreshableScrollBody( onRefresh: onRefresh, - child: ListView.builder( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - itemCount: timers.length, - itemBuilder: (context, index) { - final timer = timers[index]; - - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: _SessionCard( - isDark: isDark, - onTap: () => onTimerSelected(timer), - child: Row( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: isDark - ? AppColors.surfaceVariantDark - : AppColors.grey100, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: isDark - ? AppColors.cardBorderDark - : AppColors.grey300, + child: const _SessionTabMessage(message: 'Unable to load timers'), + ), + data: + (either) => either.fold( + (_) => _RefreshableScrollBody( + onRefresh: onRefresh, + child: const _SessionTabMessage(message: 'Unable to load timers'), + ), + (timers) { + if (timers.isEmpty) { + return _RefreshableScrollBody( + onRefresh: onRefresh, + child: const _SessionTabMessage(message: 'No timers found'), + ); + } + + return RefreshIndicator( + onRefresh: onRefresh, + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + itemCount: timers.length, + itemBuilder: (context, index) { + final timer = timers[index]; + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _SessionCard( + isDark: isDark, + onTap: () => onTimerSelected(timer), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: + isDark + ? AppColors.surfaceVariantDark + : AppColors.grey100, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + isDark + ? AppColors.cardBorderDark + : AppColors.grey300, + ), + ), + child: Icon( + PhosphorIconsRegular.timer, + size: 22, + color: + isDark + ? AppColors.textTertiaryDark + : AppColors.textSecondary, + ), ), - ), - child: Icon( - PhosphorIconsRegular.timer, - size: 22, - color: isDark - ? AppColors.textTertiaryDark - : AppColors.textSecondary, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - '${timer.displayMinutes} ${localizations.timer_min}', - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, + const SizedBox(width: 12), + Expanded( + child: Text( + '${timer.displayMinutes} ${localizations.timer_min}', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + ], ), - ], - ), - ), - ); - }, - ), - ); - }, - ), + ), + ); + }, + ), + ); + }, + ), ); } } @@ -647,10 +696,7 @@ class _TimersTab extends ConsumerWidget { /// Makes pull-to-refresh work when tab content does not fill the viewport. class _RefreshableScrollBody extends StatelessWidget { - const _RefreshableScrollBody({ - required this.onRefresh, - required this.child, - }); + const _RefreshableScrollBody({required this.onRefresh, required this.child}); final Future Function() onRefresh; final Widget child; @@ -660,13 +706,11 @@ class _RefreshableScrollBody extends StatelessWidget { return RefreshIndicator( onRefresh: onRefresh, child: LayoutBuilder( - builder: (context, constraints) => SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: SizedBox( - height: constraints.maxHeight, - child: child, - ), - ), + builder: + (context, constraints) => SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: SizedBox(height: constraints.maxHeight, child: child), + ), ), ); } @@ -701,16 +745,13 @@ class _SessionCard extends StatelessWidget { final bool isDark; final VoidCallback? onTap; - const _SessionCard({ - required this.child, - required this.isDark, - this.onTap, - }); + const _SessionCard({required this.child, required this.isDark, this.onTap}); @override Widget build(BuildContext context) { return Material( - color: isDark ? AppColors.cardBackgroundDark : AppColors.cardBackgroundLight, + color: + isDark ? AppColors.cardBackgroundDark : AppColors.cardBackgroundLight, borderRadius: BorderRadius.circular(12), child: InkWell( onTap: onTap,