diff --git a/lib/core/config/protected_routes.dart b/lib/core/config/protected_routes.dart index 0e0bc3fdc..7d960e44d 100644 --- a/lib/core/config/protected_routes.dart +++ b/lib/core/config/protected_routes.dart @@ -82,6 +82,11 @@ class ProtectedRoutes { /// The token is sent when the user is authenticated; silently skipped for guests. static const List optionalPaths = [ '/plans/{planId}/days', + // Series list/detail: sends auth when logged in so the response includes + // user-enriched fields like `progress` and `partner`. + '/series', + '/series/{id}', + '/series/featured', '/author/groups', // Group detail + members: sends auth when logged in so fields like // `is_group_enrolled` reflect the current user (anonymous GET → false). diff --git a/lib/core/l10n/app_bo.arb b/lib/core/l10n/app_bo.arb index 738050565..252238ee1 100644 --- a/lib/core/l10n/app_bo.arb +++ b/lib/core/l10n/app_bo.arb @@ -717,6 +717,7 @@ "group_links_title": "འབྲེལ་ཐག", "group_and_more_links": "དང་འབྲེལ་ཐག་གཞན་ {count}", "group_practice_with_us": "ང་ཚོ་དང་མཉམ་དུ་སྒོམ་གྱི།", + "series_practicing_with_group": "{groupName} དང་མཉམ་དུ་ཉམས་ལེན་བྱེད", "group_join_to_contribute": "བསགས་གྲངས་སྐྱེལ་ཆེད་ཞུགས།", "group_accumulator_join_error": "བསགས་གྲངས་ལ་ཞུགས་མ་ཐུབ། ཡང་བསྐྱར་ཚོད་ལྟ་གནང་རོགས།", "group_accumulator_participants": "མཉམ་ཞུགས་པ་ {count}", diff --git a/lib/core/l10n/app_en.arb b/lib/core/l10n/app_en.arb index 93dc6f127..b07dd5962 100644 --- a/lib/core/l10n/app_en.arb +++ b/lib/core/l10n/app_en.arb @@ -794,6 +794,14 @@ "group_links_title": "Links", "group_and_more_links": "and {count} more links", "group_practice_with_us": "Practice with us", + "series_practicing_with_group": "Practicing with {groupName}", + "@series_practicing_with_group": { + "placeholders": { + "groupName": { + "type": "String" + } + } + }, "group_change_practice_title": "Change group practice", "group_change_practice_message": "You are already practicing this plan with another group. Would you like to change your practice group?", "group_join_to_contribute": "Join to contribute", diff --git a/lib/core/l10n/app_hi.arb b/lib/core/l10n/app_hi.arb index 547dce5b2..2589051ad 100644 --- a/lib/core/l10n/app_hi.arb +++ b/lib/core/l10n/app_hi.arb @@ -783,6 +783,7 @@ "group_links_title": "लिंक", "group_and_more_links": "और {count} और लिंक", "group_practice_with_us": "हमारे साथ अभ्यास करें", + "series_practicing_with_group": "{groupName} के साथ अभ्यास", "group_join_to_contribute": "योगदान के लिए शामिल हों", "group_accumulator_join_error": "संचय में शामिल होने में असमर्थ। कृपया पुनः प्रयास करें।", "group_accumulator_participants": "{count} प्रतिभागी", diff --git a/lib/core/l10n/app_mn.arb b/lib/core/l10n/app_mn.arb index 16dd70a88..96dfea913 100644 --- a/lib/core/l10n/app_mn.arb +++ b/lib/core/l10n/app_mn.arb @@ -783,6 +783,7 @@ "group_links_title": "Холбоосууд", "group_and_more_links": "болон дахиад {count} холбоос", "group_practice_with_us": "Бидэнтэй хамт дадлага хий", + "series_practicing_with_group": "{groupName}-тай хамт дадлага хийж байна", "group_join_to_contribute": "Хувь нэмэр оруулахын тулд нэгдэх", "group_accumulator_join_error": "Хуримтлалд нэгдэх боломжгүй байна. Дахин оролдоно уу.", "group_accumulator_participants": "{count} оролцогч", diff --git a/lib/core/l10n/app_ne.arb b/lib/core/l10n/app_ne.arb index 5e34c31d5..225652e6c 100644 --- a/lib/core/l10n/app_ne.arb +++ b/lib/core/l10n/app_ne.arb @@ -783,6 +783,7 @@ "group_links_title": "लिङ्कहरू", "group_and_more_links": "र अरू {count} लिङ्कहरू", "group_practice_with_us": "हामीसँग अभ्यास गर्नुहोस्", + "series_practicing_with_group": "{groupName} सँग अभ्यास", "group_join_to_contribute": "योगदानका लागि सामेल हुनुहोस्", "group_accumulator_join_error": "संचयमा सामेल हुन असमर्थ। कृपया फेरि प्रयास गर्नुहोस्।", "group_accumulator_participants": "{count} सहभागी", diff --git a/lib/core/l10n/app_zh.arb b/lib/core/l10n/app_zh.arb index 635dd0e47..1c55b094d 100644 --- a/lib/core/l10n/app_zh.arb +++ b/lib/core/l10n/app_zh.arb @@ -717,6 +717,7 @@ "group_links_title": "連結", "group_and_more_links": "及另外 {count} 個連結", "group_practice_with_us": "與我們一起修行", + "series_practicing_with_group": "與 {groupName} 一起修行", "group_join_to_contribute": "加入以貢獻", "group_accumulator_join_error": "無法加入累積,請再試一次。", "group_accumulator_participants": "{count} 位參與者", diff --git a/lib/core/l10n/generated/app_localizations.dart b/lib/core/l10n/generated/app_localizations.dart index 41f3cf210..471421f38 100644 --- a/lib/core/l10n/generated/app_localizations.dart +++ b/lib/core/l10n/generated/app_localizations.dart @@ -3010,6 +3010,12 @@ abstract class AppLocalizations { /// **'Practice with us'** String get group_practice_with_us; + /// No description provided for @series_practicing_with_group. + /// + /// In en, this message translates to: + /// **'Practicing with {groupName}'** + String series_practicing_with_group(String groupName); + /// No description provided for @group_change_practice_title. /// /// 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 baa90f7e8..68e1e6f36 100644 --- a/lib/core/l10n/generated/app_localizations_bo.dart +++ b/lib/core/l10n/generated/app_localizations_bo.dart @@ -1660,6 +1660,11 @@ class AppLocalizationsBo extends AppLocalizations { @override String get group_practice_with_us => 'ང་ཚོ་དང་མཉམ་དུ་སྒོམ་གྱི།'; + @override + String series_practicing_with_group(String groupName) { + return '$groupName དང་མཉམ་དུ་ཉམས་ལེན་བྱེད'; + } + @override String get group_change_practice_title => 'Change group practice'; diff --git a/lib/core/l10n/generated/app_localizations_en.dart b/lib/core/l10n/generated/app_localizations_en.dart index e685448f8..124d5bba9 100644 --- a/lib/core/l10n/generated/app_localizations_en.dart +++ b/lib/core/l10n/generated/app_localizations_en.dart @@ -1639,6 +1639,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String get group_practice_with_us => 'Practice with us'; + @override + String series_practicing_with_group(String groupName) { + return 'Practicing with $groupName'; + } + @override String get group_change_practice_title => 'Change group practice'; diff --git a/lib/core/l10n/generated/app_localizations_hi.dart b/lib/core/l10n/generated/app_localizations_hi.dart index e6aacc579..c1ac87f76 100644 --- a/lib/core/l10n/generated/app_localizations_hi.dart +++ b/lib/core/l10n/generated/app_localizations_hi.dart @@ -1652,6 +1652,11 @@ class AppLocalizationsHi extends AppLocalizations { @override String get group_practice_with_us => 'हमारे साथ अभ्यास करें'; + @override + String series_practicing_with_group(String groupName) { + return '$groupName के साथ अभ्यास'; + } + @override String get group_change_practice_title => 'Change group practice'; diff --git a/lib/core/l10n/generated/app_localizations_mn.dart b/lib/core/l10n/generated/app_localizations_mn.dart index 1c5510321..8fdcd510e 100644 --- a/lib/core/l10n/generated/app_localizations_mn.dart +++ b/lib/core/l10n/generated/app_localizations_mn.dart @@ -1656,6 +1656,11 @@ class AppLocalizationsMn extends AppLocalizations { @override String get group_practice_with_us => 'Бидэнтэй хамт дадлага хий'; + @override + String series_practicing_with_group(String groupName) { + return '$groupName-тай хамт дадлага хийж байна'; + } + @override String get group_change_practice_title => 'Change group practice'; diff --git a/lib/core/l10n/generated/app_localizations_ne.dart b/lib/core/l10n/generated/app_localizations_ne.dart index f4e13901f..79cd7df5b 100644 --- a/lib/core/l10n/generated/app_localizations_ne.dart +++ b/lib/core/l10n/generated/app_localizations_ne.dart @@ -1660,6 +1660,11 @@ class AppLocalizationsNe extends AppLocalizations { @override String get group_practice_with_us => 'हामीसँग अभ्यास गर्नुहोस्'; + @override + String series_practicing_with_group(String groupName) { + return '$groupName सँग अभ्यास'; + } + @override String get group_change_practice_title => 'Change group practice'; diff --git a/lib/core/l10n/generated/app_localizations_zh.dart b/lib/core/l10n/generated/app_localizations_zh.dart index 535570c4a..74a67ed7f 100644 --- a/lib/core/l10n/generated/app_localizations_zh.dart +++ b/lib/core/l10n/generated/app_localizations_zh.dart @@ -1565,6 +1565,11 @@ class AppLocalizationsZh extends AppLocalizations { @override String get group_practice_with_us => '與我們一起修行'; + @override + String series_practicing_with_group(String groupName) { + return '與 $groupName 一起修行'; + } + @override String get group_change_practice_title => 'Change group practice'; diff --git a/lib/features/home/data/datasource/home_local_datasource.dart b/lib/features/home/data/datasource/home_local_datasource.dart index 4be185fdf..0b11fed6f 100644 --- a/lib/features/home/data/datasource/home_local_datasource.dart +++ b/lib/features/home/data/datasource/home_local_datasource.dart @@ -43,10 +43,12 @@ class HomeLocalDatasource { String featuredDayKey(String language) => 'featured_day:$language'; String tagsKey(String language) => 'tags:$language'; + // v3: entries cached before the request sent auth lack progress/partner. String featuredSeriesKey(String language, int limit) => - 'featured_series:$language:$limit'; + 'featured_series:v3:$language:$limit'; String seriesListKey(String language) => 'series_list:$language'; - String seriesByIdKey(String language, String id) => 'series:$language:$id'; + String seriesByIdKey(String language, String id) => + 'series:v2:$language:$id'; String todayEventsKey(String language) => 'today_events:$language'; String verseOfDayKey(String language) => 'verse_of_day:$language'; String routineInfoKey(String userId) => 'routine_info:$userId'; diff --git a/lib/features/home/data/datasource/series_remote_datasource.dart b/lib/features/home/data/datasource/series_remote_datasource.dart index df3da2de1..a9e3453cc 100644 --- a/lib/features/home/data/datasource/series_remote_datasource.dart +++ b/lib/features/home/data/datasource/series_remote_datasource.dart @@ -72,6 +72,7 @@ class SeriesRemoteDatasource { final response = await dio.get( '/series', queryParameters: params.toQueryParams(), + options: Options(extra: {'no_cache': true}), ); if (response.statusCode == 200) { @@ -96,6 +97,7 @@ class SeriesRemoteDatasource { final response = await dio.get( '/series', queryParameters: {'language': language}, + options: Options(extra: {'no_cache': true}), ); if (response.statusCode == 200) { @@ -123,6 +125,7 @@ class SeriesRemoteDatasource { final response = await dio.get( '/series/$id', queryParameters: {'language': language}, + options: Options(extra: {'no_cache': true}), ); if (response.statusCode == 200) { diff --git a/lib/features/home/data/models/series_model.dart b/lib/features/home/data/models/series_model.dart index a748e8f79..55e5476d4 100644 --- a/lib/features/home/data/models/series_model.dart +++ b/lib/features/home/data/models/series_model.dart @@ -52,6 +52,8 @@ class SeriesModel { final List plans; final int totalDays; final Map? groupJson; + final Map? progressJson; + final Map? partnerJson; SeriesModel({ required this.id, @@ -67,6 +69,8 @@ class SeriesModel { this.plans = const [], this.totalDays = 0, this.groupJson, + this.progressJson, + this.partnerJson, }); String? get imageUrl => image?.displayUrl; @@ -121,6 +125,14 @@ class SeriesModel { json['group'] is Map ? json['group'] as Map : null, + progressJson: + json['progress'] is Map + ? json['progress'] as Map + : null, + partnerJson: + json['partner'] is Map + ? json['partner'] as Map + : null, ); } @@ -139,6 +151,8 @@ class SeriesModel { 'plans': plans.map((p) => p.toJson()).toList(), 'total_days': totalDays, 'group': groupJson, + 'progress': progressJson, + 'partner': partnerJson, }; } @@ -158,6 +172,28 @@ class SeriesModel { endDate: endDate, plans: plans.map((p) => p.toEntity()).toList(), group: _parseGroup(language), + progress: _parseProgress(), + partner: _parsePartner(), + ); + } + + SeriesProgress? _parseProgress() { + final p = progressJson; + if (p == null) return null; + return SeriesProgress( + totalDayCount: (p['total_day_count'] as num?)?.toInt() ?? 0, + currentDayNumber: (p['current_day_number'] as num?)?.toInt() ?? 0, + ); + } + + SeriesPartner? _parsePartner() { + final p = partnerJson; + if (p == null) return null; + final name = p['group_name'] as String? ?? ''; + if (name.isEmpty) return null; + return SeriesPartner( + groupName: name, + groupImage: p['group_image'] as String?, ); } diff --git a/lib/features/home/domain/entities/series.dart b/lib/features/home/domain/entities/series.dart index 47b765f1e..0d348b55d 100644 --- a/lib/features/home/domain/entities/series.dart +++ b/lib/features/home/domain/entities/series.dart @@ -23,6 +23,29 @@ class SeriesGroup { }); } +class SeriesProgress { + final int totalDayCount; + final int currentDayNumber; + + const SeriesProgress({ + required this.totalDayCount, + required this.currentDayNumber, + }); + + /// 0..1 completion ratio, safe against zero/overflowing day counts. + double get fraction { + if (totalDayCount <= 0) return 0; + return (currentDayNumber / totalDayCount).clamp(0.0, 1.0); + } +} + +class SeriesPartner { + final String groupName; + final String? groupImage; + + const SeriesPartner({required this.groupName, this.groupImage}); +} + class Series { final String id; final String title; @@ -37,6 +60,8 @@ class Series { final DateTime? endDate; final List plans; final SeriesGroup? group; + final SeriesProgress? progress; + final SeriesPartner? partner; const Series({ required this.id, @@ -52,6 +77,8 @@ class Series { this.endDate, this.plans = const [], this.group, + this.progress, + this.partner, }); /// Smallest cover URL — legacy string-only callers. diff --git a/lib/features/home/presentation/widgets/featured_plan_section.dart b/lib/features/home/presentation/widgets/featured_plan_section.dart index 5a4e90b0a..6fdadb0ea 100644 --- a/lib/features/home/presentation/widgets/featured_plan_section.dart +++ b/lib/features/home/presentation/widgets/featured_plan_section.dart @@ -1,5 +1,4 @@ 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'; @@ -8,6 +7,7 @@ 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/home/presentation/widgets/series_plan_card_widgets.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'; @@ -152,51 +152,6 @@ Color _featuredPlanBackgroundColor(BuildContext context) { return isDark ? AppColors.cardBackgroundDark : AppColors.surfaceWhite; } -class _FeaturedPlanMetaRow extends StatelessWidget { - const _FeaturedPlanMetaRow({required this.series, required this.fontSize}); - - final Series series; - final double fontSize; - - @override - Widget build(BuildContext context) { - final dateRange = _formatSeriesDateRange(series); - if (dateRange == null && series.enrolledCount <= 0) { - return const SizedBox.shrink(); - } - - final isTibetan = context.isTibetanLocale; - 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), - ], - ], - ); - } -} - class _FeaturedPlanHeroCard extends StatelessWidget { const _FeaturedPlanHeroCard({ required this.series, @@ -271,12 +226,31 @@ class _FeaturedPlanHeroCard extends StatelessWidget { maxLines: 2, overflow: TextOverflow.ellipsis, ), - if (dateRange != null || series.enrolledCount > 0) ...[ + if (series.partner != null) ...[ SizedBox(height: titleDateGap), - _FeaturedPlanMetaRow( - series: series, - fontSize: dateFontSize, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: SeriesPlanPartnerRow( + partner: series.partner!, + fontSize: dateFontSize, + avatarSize: 32, + ), + ), + if (series.enrolledCount > 0) ...[ + const SizedBox(width: 12), + SeriesPlanEnrolledCount( + count: series.enrolledCount, + fontSize: dateFontSize, + ), + ], + ], ), + ] else if (dateRange != null || series.enrolledCount > 0) ...[ + SizedBox(height: titleDateGap), + SeriesPlanMetaRow(series: series, fontSize: dateFontSize), ], ], ), @@ -366,12 +340,49 @@ class _FeaturedPlanListItem extends StatelessWidget { maxLines: 2, overflow: TextOverflow.ellipsis, ), - if (dateRange != null || series.enrolledCount > 0) ...[ - SizedBox(height: titleDateGap), - _FeaturedPlanMetaRow( - series: series, - fontSize: dateFontSize, - ), + if (series.partner != null) ...[ + if (series.progress != null && + series.progress!.totalDayCount > 0) ...[ + SizedBox(height: titleDateGap + 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: SeriesPlanProgressBar( + progress: series.progress!, + ), + ), + if (series.enrolledCount > 0) + SeriesPlanEnrolledCount( + count: series.enrolledCount, + fontSize: dateFontSize, + ), + ], + ), + ] else if (series.enrolledCount > 0) ...[ + SizedBox(height: titleDateGap), + Align( + alignment: Alignment.centerRight, + child: SeriesPlanEnrolledCount( + count: series.enrolledCount, + fontSize: dateFontSize, + ), + ), + ], + ] else ...[ + if (dateRange != null || series.enrolledCount > 0) ...[ + SizedBox(height: titleDateGap), + SeriesPlanMetaRow( + series: series, + fontSize: dateFontSize, + ), + ], + if (series.progress != null && + series.progress!.totalDayCount > 0) ...[ + SizedBox(height: titleDateGap + 4), + SeriesPlanProgressBar(progress: series.progress!), + ], ], ], ), diff --git a/lib/features/home/presentation/widgets/plan_list_view.dart b/lib/features/home/presentation/widgets/plan_list_view.dart index 250564cbc..0f5818c8e 100644 --- a/lib/features/home/presentation/widgets/plan_list_view.dart +++ b/lib/features/home/presentation/widgets/plan_list_view.dart @@ -12,6 +12,7 @@ import 'package:flutter_pecha/features/group_profile/domain/entities/group_profi import 'package:flutter_pecha/features/group_profile/presentation/providers/group_profile_providers.dart'; import 'package:flutter_pecha/features/home/domain/entities/series.dart'; import 'package:flutter_pecha/features/home/presentation/providers/series_enrollment_provider.dart'; +import 'package:flutter_pecha/features/home/presentation/widgets/series_plan_card_widgets.dart'; import 'package:flutter_pecha/features/plans/data/models/plans_model.dart'; import 'package:flutter_pecha/features/plans/data/models/user/user_plans_model.dart'; import 'package:flutter_pecha/features/plans/domain/entities/plan.dart'; @@ -53,8 +54,7 @@ SeriesListEnrollmentState _watchSeriesListEnrollment( final enrollmentsAsync = ref.watch(userSeriesEnrollmentsProvider); return SeriesListEnrollmentState( - isSeriesEnrolled: - enrollmentsAsync.valueOrNull?.contains(seriesId) ?? false, + isSeriesEnrolled: enrollmentsAsync.valueOrNull?.contains(seriesId) ?? false, isLoading: enrollmentsAsync.isLoading, ); } @@ -180,13 +180,15 @@ class FeaturedPlanCard extends ConsumerWidget { final myPlansState = isSeriesContext ? null : ref.watch(myPlansPaginatedProvider); - final isPlanEnrolled = isSeriesContext - ? isSeriesEnrolled - : (!isGuest && _isPlanEnrolledFromMyData(ref, plan.id)); - - final enrolledInfo = !isSeriesContext && isPlanEnrolled - ? _getEnrolledInfoFromMyPlans(ref, plan.id) - : null; + final isPlanEnrolled = + isSeriesContext + ? isSeriesEnrolled + : (!isGuest && _isPlanEnrolledFromMyData(ref, plan.id)); + + final enrolledInfo = + !isSeriesContext && isPlanEnrolled + ? _getEnrolledInfoFromMyPlans(ref, plan.id) + : null; final isEnrolledInfoPending = !isSeriesContext && isPlanEnrolled && @@ -202,9 +204,10 @@ class FeaturedPlanCard extends ConsumerWidget { final isGroupPracticeFlow = groupId != null && !isGroupEnrolled; - final isLoadingEnrollmentData = isSeriesContext - ? (!isGroupPracticeFlow && seriesEnrollment.isLoading) - : myPlansState!.isLoading; + final isLoadingEnrollmentData = + isSeriesContext + ? (!isGroupPracticeFlow && seriesEnrollment.isLoading) + : myPlansState!.isLoading; final hideEnrollButton = isGroupEnrolled || @@ -259,8 +262,17 @@ class FeaturedPlanCard extends ConsumerWidget { height: lineHeight, ), ), + if (series?.partner != null) ...[ + if (series != null) const SizedBox(height: 8), + SeriesPlanPartnerRow( + partner: series!.partner!, + fontSize: subtitleFontSize, + avatarSize: 28, + ), + ], if (hasDescription) ...[ - if (series != null) const SizedBox(height: 6), + if (series != null) + const SizedBox(height: 6), Text( displayDescription, style: TextStyle( @@ -354,7 +366,9 @@ class FeaturedPlanCard extends ConsumerWidget { final groupId = this.groupId; if (groupId != null && !isGroupEnrolled) { final groupType = this.groupType ?? GroupType.community; - final profileResult = await ref.read(groupProfileProvider(groupId).future); + final profileResult = await ref.read( + groupProfileProvider(groupId).future, + ); if (!context.mounted) return; final enrollmentStatus = profileResult.fold( @@ -378,10 +392,7 @@ class FeaturedPlanCard extends ConsumerWidget { if (ok) { await ref.read(groupProfileProvider(groupId).future); if (!context.mounted) return; - await context.pushNamed( - 'edit-routine', - extra: {'enrollSeriesId': id}, - ); + await context.pushNamed('edit-routine', extra: {'enrollSeriesId': id}); if (!context.mounted) return; await completeGroupPracticeEnrollmentFlow( ref: ref, @@ -454,13 +465,15 @@ class PlanListItem extends ConsumerWidget { final myPlansState = isSeriesContext ? null : ref.watch(myPlansPaginatedProvider); - final isPlanEnrolled = isSeriesContext - ? isSeriesEnrolled - : (!isGuest && _isPlanEnrolledFromMyData(ref, plan.id)); - - final enrolledInfo = !isSeriesContext && isPlanEnrolled - ? _getEnrolledInfoFromMyPlans(ref, plan.id) - : null; + final isPlanEnrolled = + isSeriesContext + ? isSeriesEnrolled + : (!isGuest && _isPlanEnrolledFromMyData(ref, plan.id)); + + final enrolledInfo = + !isSeriesContext && isPlanEnrolled + ? _getEnrolledInfoFromMyPlans(ref, plan.id) + : null; final isEnrolledInfoPending = !isSeriesContext && isPlanEnrolled && diff --git a/lib/features/home/presentation/widgets/series_plan_card_widgets.dart b/lib/features/home/presentation/widgets/series_plan_card_widgets.dart new file mode 100644 index 000000000..0b6223467 --- /dev/null +++ b/lib/features/home/presentation/widgets/series_plan_card_widgets.dart @@ -0,0 +1,182 @@ +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/cached_network_image_widget.dart'; +import 'package:flutter_pecha/features/home/domain/entities/series.dart'; +import 'package:flutter_pecha/features/plans/data/utils/plan_date_format.dart'; + +class SeriesPlanProgressBar extends StatelessWidget { + const SeriesPlanProgressBar({super.key, required this.progress}); + + final SeriesProgress progress; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; + final trackColor = + isDark ? AppColors.grey800.withValues(alpha: 0.6) : AppColors.greyLight; + + return Align( + alignment: Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: progress.fraction, + minHeight: 6, + backgroundColor: trackColor, + valueColor: AlwaysStoppedAnimation(colorScheme.primary), + ), + ), + ), + ); + } +} + +class SeriesPlanPartnerRow extends StatelessWidget { + const SeriesPlanPartnerRow({ + super.key, + required this.partner, + required this.fontSize, + this.avatarSize, + }); + + final SeriesPartner partner; + final double fontSize; + final double? avatarSize; + + @override + Widget build(BuildContext context) { + final isTibetan = context.isTibetanLocale; + final secondaryColor = Theme.of(context).colorScheme.onSurfaceVariant; + final resolvedAvatarSize = avatarSize ?? fontSize + 8; + + return Row( + children: [ + ClipOval( + child: CachedNetworkImageWidget( + imageUrl: partner.groupImage, + width: resolvedAvatarSize, + height: resolvedAvatarSize, + fit: BoxFit.cover, + placeholder: SizedBox( + width: resolvedAvatarSize, + height: resolvedAvatarSize, + ), + errorWidget: SizedBox( + width: resolvedAvatarSize, + height: resolvedAvatarSize, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + context.l10n.series_practicing_with_group(partner.groupName), + strutStyle: context.tibetanStrutStyle(fontSize, compact: true), + style: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.w600, + color: secondaryColor, + height: isTibetan ? AppFontConfig.tibetanCompactLineHeight : 1.2, + leadingDistribution: + isTibetan ? AppFontConfig.tibetanLeadingDistribution : null, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } +} + +class SeriesPlanEnrolledCount extends StatelessWidget { + const SeriesPlanEnrolledCount({ + super.key, + required this.count, + required this.fontSize, + }); + + final int count; + final double fontSize; + + @override + Widget build(BuildContext context) { + final isTibetan = context.isTibetanLocale; + 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( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(AppAssets.usercard, size: fontSize + 2, color: secondaryColor), + const SizedBox(width: 4), + Text('$count', style: textStyle), + ], + ); + } +} + +class SeriesPlanMetaRow extends StatelessWidget { + const SeriesPlanMetaRow({ + super.key, + required this.series, + required this.fontSize, + }); + + final Series series; + final double fontSize; + + @override + Widget build(BuildContext context) { + final dateRange = PlanDateFormat.formatRangeOrNull( + series.startDate, + series.endDate, + ); + if (dateRange == null && series.enrolledCount <= 0) { + return const SizedBox.shrink(); + } + + final isTibetan = context.isTibetanLocale; + 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) + SeriesPlanEnrolledCount( + count: series.enrolledCount, + fontSize: fontSize, + ), + ], + ); + } +} diff --git a/lib/features/practice/presentation/widgets/practice_plan_card.dart b/lib/features/practice/presentation/widgets/practice_plan_card.dart index aebb8aca6..19b96255d 100644 --- a/lib/features/practice/presentation/widgets/practice_plan_card.dart +++ b/lib/features/practice/presentation/widgets/practice_plan_card.dart @@ -1,8 +1,8 @@ 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:flutter_pecha/features/home/presentation/widgets/series_plan_card_widgets.dart'; import 'package:flutter_pecha/features/plans/data/utils/plan_date_format.dart'; class PracticePlanCard extends StatelessWidget { @@ -17,6 +17,9 @@ class PracticePlanCard extends StatelessWidget { final VoidCallback onTap; final double width; + static const _metaFontSize = 12.0; + static const _titleFontSize = 14.0; + @override Widget build(BuildContext context) { final dateRange = _formatSeriesDateRange(series); @@ -61,7 +64,7 @@ class PracticePlanCard extends StatelessWidget { child: Text( series.title, style: const TextStyle( - fontSize: 14, + fontSize: _titleFontSize, fontWeight: FontWeight.w600, height: 1.3, ), @@ -69,15 +72,36 @@ class PracticePlanCard extends StatelessWidget { overflow: TextOverflow.ellipsis, ), ), - if (dateRange != null || series.enrolledCount > 0) + if (series.partner != null) + Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: SeriesPlanPartnerRow( + partner: series.partner!, + fontSize: _metaFontSize, + avatarSize: 28, + ), + ), + if (series.enrolledCount > 0) ...[ + const SizedBox(width: 8), + SeriesPlanEnrolledCount( + count: series.enrolledCount, + fontSize: _metaFontSize, + ), + ], + ], + ), + ) + else if (dateRange != null || series.enrolledCount > 0) Padding( padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), - child: _PracticePlanMetaRow( - dateRange: dateRange, - enrolledCount: series.enrolledCount, - fontSize: 12, - secondaryColor: - Theme.of(context).colorScheme.onSurfaceVariant, + child: SeriesPlanMetaRow( + series: series, + fontSize: _metaFontSize, ), ), ], @@ -90,41 +114,3 @@ class PracticePlanCard extends StatelessWidget { 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 09fd67dff..693f2544f 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,8 @@ 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:flutter_pecha/features/home/presentation/widgets/series_plan_card_widgets.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 @@ -17,6 +17,9 @@ class PracticePlanListTile extends StatelessWidget { final Series series; final VoidCallback onTap; + static const _titleFontSize = 15.0; + static const _metaFontSize = 13.0; + @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; @@ -55,55 +58,56 @@ class PracticePlanListTile extends StatelessWidget { Text( series.title, style: const TextStyle( - fontSize: 15, + fontSize: _titleFontSize, fontWeight: FontWeight.w700, height: 1.3, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), - if (dateRange != null || series.enrolledCount > 0) ...[ - const SizedBox(height: 4), - Row( - children: [ - if (dateRange != null) + if (series.partner != null) ...[ + if (series.progress != null && + series.progress!.totalDayCount > 0) ...[ + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ Expanded( - child: Text( - dateRange, - style: TextStyle( - fontSize: 13, - color: - Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + child: SeriesPlanProgressBar( + progress: series.progress!, ), ), - 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, + if (series.enrolledCount > 0) + SeriesPlanEnrolledCount( + count: series.enrolledCount, + fontSize: _metaFontSize, ), - ), ], - ], - ), + ), + ] else if (series.enrolledCount > 0) ...[ + const SizedBox(height: 4), + Align( + alignment: Alignment.centerRight, + child: SeriesPlanEnrolledCount( + count: series.enrolledCount, + fontSize: _metaFontSize, + ), + ), + ], + ] else ...[ + if (dateRange != null || series.enrolledCount > 0) ...[ + const SizedBox(height: 4), + SeriesPlanMetaRow( + series: series, + fontSize: _metaFontSize, + ), + ], + if (series.progress != null && + series.progress!.totalDayCount > 0) ...[ + const SizedBox(height: 8), + SeriesPlanProgressBar(progress: series.progress!), + ], ], ], ), diff --git a/lib/features/practice/presentation/widgets/practice_plans_section.dart b/lib/features/practice/presentation/widgets/practice_plans_section.dart index 08d77b536..ad4a9b0c4 100644 --- a/lib/features/practice/presentation/widgets/practice_plans_section.dart +++ b/lib/features/practice/presentation/widgets/practice_plans_section.dart @@ -33,7 +33,7 @@ class PracticePlansSection extends ConsumerWidget { ? () => _showAllPlans(context, seriesList) : null, child: SizedBox( - height: 220, + height: 240, child: ListView.separated( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 16), @@ -52,7 +52,7 @@ class PracticePlansSection extends ConsumerWidget { }), loading: () => const PracticeSectionSkeleton( - height: 220, + height: 240, axis: Axis.horizontal, itemCount: 2, itemWidth: 300,