From f66dcb1901bdbf25d11348a346185f73d0867e8f Mon Sep 17 00:00:00 2001 From: tenzin dhakar Date: Mon, 29 Jun 2026 23:10:07 +0530 Subject: [PATCH 01/27] refactor(plan_navigation): update routing and content handling for inline TEXT/IMAGE subtasks - Enhanced routing to support both TEXT and IMAGE content types in PlanTextScreen. - Updated comments and documentation to reflect the new inlineImage content type. - Improved navigation logic to seamlessly handle transitions between different content types. - Adjusted UI components to render inline images appropriately alongside text content. --- lib/core/config/router/app_router.dart | 2 +- lib/core/config/router/app_routes.dart | 7 +- .../plans/domain/subtask_navigation.dart | 23 ++- .../screens/plan_text_screen.dart | 183 ++++++++++++------ .../plan_navigation_bottom_bar.dart | 2 +- .../plan_navigation/plan_navigator.dart | 25 ++- .../plan_segment_audio_controller.dart | 2 +- .../plan_preview/preview_activity_list.dart | 2 +- .../data/models/navigation_context.dart | 60 +++++- 9 files changed, 220 insertions(+), 86 deletions(-) diff --git a/lib/core/config/router/app_router.dart b/lib/core/config/router/app_router.dart index 7e61a097..3eabef3f 100644 --- a/lib/core/config/router/app_router.dart +++ b/lib/core/config/router/app_router.dart @@ -487,7 +487,7 @@ final appRouterProvider = Provider((ref) { }, ), - // plan text route - inline TEXT subtasks (sibling to /reader) + // plan content route - inline TEXT/IMAGE subtasks (sibling to /reader) GoRoute( path: "/plan-text/:subtaskId", name: "plan-text", diff --git a/lib/core/config/router/app_routes.dart b/lib/core/config/router/app_routes.dart index 68d16516..a28772b4 100644 --- a/lib/core/config/router/app_routes.dart +++ b/lib/core/config/router/app_routes.dart @@ -48,9 +48,10 @@ class AppRoutes { static const String reader = '/reader'; // ========== PLAN TEXT ROUTES ========== - /// Inline plan text screen — renders subtasks where `content_type == "TEXT"`. + /// Inline plan content screen — renders subtasks where `content_type` is + /// `TEXT` or `IMAGE`. /// Path param is the subtask id; the actual content travels in `extra` - /// as a [NavigationContext] whose `currentItem` carries `inlineContent`. + /// as a [NavigationContext] whose `currentItem` carries inline content. static const String planText = '/plan-text'; // ========== SEARCH ROUTES ========== @@ -88,7 +89,7 @@ class AppRoutes { practicePlanPreview, // Allow guests to browse/preview plans reader, notifications, // Local-only — guests can configure routine notifications - planText, // Guests can see inline TEXT subtasks + planText, // Guests can see inline TEXT/IMAGE subtasks }; /// Base paths that require full authentication (prefix matching) diff --git a/lib/features/plans/domain/subtask_navigation.dart b/lib/features/plans/domain/subtask_navigation.dart index 722b6545..e7655b93 100644 --- a/lib/features/plans/domain/subtask_navigation.dart +++ b/lib/features/plans/domain/subtask_navigation.dart @@ -10,6 +10,7 @@ import 'package:flutter_pecha/features/reader/data/models/navigation_context.dar /// Validation rules (kept in one place): /// - SOURCE_REFERENCE → valid iff `sourceTextId` is non-null and non-empty. /// - TEXT → valid iff `content.trim()` is non-empty. +/// - IMAGE → valid iff `content.trim()` is non-empty. /// - Anything else (unknown content type, missing fields) is silently dropped. /// /// Both task models (`UserTasksDto` for enrolled users, `PlanTasksModel` for @@ -52,7 +53,7 @@ class PlanSubtaskNavigation { } /// True if the given task has at least one navigable subtask - /// (SOURCE_REFERENCE or TEXT). + /// (SOURCE_REFERENCE, TEXT, or IMAGE). static bool isUserTaskNavigable(UserTasksDto task) { return task.subTasks.any(_isUserSubtaskNavigable); } @@ -115,6 +116,17 @@ class PlanSubtaskNavigation { startMs: subtask.startMs, endMs: subtask.endMs, ); + case PlanItemContentType.inlineImage: + if (!_hasInlineContent(subtask.content)) return null; + return PlanTextItem.inlineImage( + imageUrl: subtask.content, + title: title, + subtaskId: subtask.id, + isCompleted: subtask.isCompleted, + audioUrl: subtask.audioUrl, + startMs: subtask.startMs, + endMs: subtask.endMs, + ); case null: return null; } @@ -145,6 +157,15 @@ class PlanSubtaskNavigation { startMs: subtask.startMs, endMs: subtask.endMs, ); + case PlanItemContentType.inlineImage: + if (!_hasInlineContent(subtask.content)) return null; + return PlanTextItem.inlineImage( + imageUrl: subtask.content!, + title: title, + audioUrl: subtask.audioUrl, + startMs: subtask.startMs, + endMs: subtask.endMs, + ); case null: return null; } diff --git a/lib/features/plans/presentation/screens/plan_text_screen.dart b/lib/features/plans/presentation/screens/plan_text_screen.dart index 814683a4..5fa8eb8f 100644 --- a/lib/features/plans/presentation/screens/plan_text_screen.dart +++ b/lib/features/plans/presentation/screens/plan_text_screen.dart @@ -1,6 +1,7 @@ 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/widgets/cached_network_image_widget.dart'; import 'package:flutter_pecha/features/plans/presentation/widgets/plan_inline_markdown_view.dart'; import 'package:flutter_pecha/features/plans/presentation/widgets/plan_navigation/plan_audio_button.dart'; import 'package:flutter_pecha/features/plans/presentation/widgets/plan_navigation/plan_navigation_bottom_bar.dart'; @@ -15,11 +16,11 @@ import 'package:flutter_pecha/features/texts/presentation/providers/font_size_no import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -/// Lightweight reading screen for plan subtasks where `content_type == "TEXT"`. +/// Lightweight reading screen for inline plan subtasks (`TEXT` or `IMAGE`). /// -/// The subtask `content` is treated as markdown and rendered via -/// [PlanInlineMarkdownView]. Plain text remains valid markdown so callers -/// without formatting need no changes. +/// For `TEXT`, subtask `content` is treated as markdown and rendered via +/// [PlanInlineMarkdownView]. For `IMAGE`, subtask `content` is treated as the +/// image URL and rendered directly. /// /// Audio behaviour (shared with `ReaderScreen` via /// [PlanSegmentAudioController]): @@ -125,7 +126,7 @@ class _PlanTextScreenState extends ConsumerState { final fontSize = ref.watch(fontSizeProvider); final readerTheme = _readerTheme(context); - if (currentItem == null || currentItem.inlineContent == null) { + if (currentItem == null || !_hasRenderableContent(currentItem)) { return Theme( data: readerTheme, child: _buildMissingContentScaffold(context), @@ -138,69 +139,124 @@ class _PlanTextScreenState extends ConsumerState { data: readerTheme, child: Scaffold( backgroundColor: readerTheme.scaffoldBackgroundColor, - appBar: _buildAppBar(context, currentItem.title), + appBar: _buildAppBar( + context, + currentItem.title, + showFontControls: currentItem.isInlineText, + ), body: GestureDetector( - behavior: HitTestBehavior.opaque, - onHorizontalDragStart: canSwipe ? _onDragStart : null, - onHorizontalDragUpdate: canSwipe ? _onDragUpdate : null, - onHorizontalDragEnd: canSwipe ? _onDragEnd : null, - onHorizontalDragCancel: canSwipe ? _onDragCancel : null, - child: SafeArea( - child: AnimatedContainer( - duration: Duration(milliseconds: _isDragging ? 0 : 250), - curve: Curves.easeOutCubic, - transform: Matrix4.translationValues(_dragOffset * 0.25, 0, 0), - child: Column( - children: [ - Expanded( - child: Stack( - children: [ - // Text content — extra bottom padding reserves visual - // space so the last line stays above the floating button. - SingleChildScrollView( - padding: EdgeInsets.fromLTRB( - 20, - 16, - 20, - _hasAudio ? 88 : 16, - ), - child: PlanInlineMarkdownView( - content: currentItem.inlineContent!, - fontSize: fontSize, - ), - ), - // Floating play/pause — overlaid, zero layout footprint. - if (_hasAudio) - Positioned( - left: 0, - right: 0, - bottom: 8, - child: Center( - child: PlanAudioButton( - controller: _audioController!, + behavior: HitTestBehavior.opaque, + onHorizontalDragStart: canSwipe ? _onDragStart : null, + onHorizontalDragUpdate: canSwipe ? _onDragUpdate : null, + onHorizontalDragEnd: canSwipe ? _onDragEnd : null, + onHorizontalDragCancel: canSwipe ? _onDragCancel : null, + child: SafeArea( + child: AnimatedContainer( + duration: Duration(milliseconds: _isDragging ? 0 : 250), + curve: Curves.easeOutCubic, + transform: Matrix4.translationValues(_dragOffset * 0.25, 0, 0), + child: Column( + children: [ + Expanded( + child: Stack( + children: [ + if (currentItem.isInlineImage) + Positioned.fill( + child: Padding( + padding: EdgeInsets.only( + bottom: _hasAudio ? 72 : 0, + ), + child: _buildInlineImage(item: currentItem), + ), + ) + else + SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + 20, + 16, + 20, + _hasAudio ? 88 : 16, + ), + child: _buildInlineText( + content: currentItem.inlineContent!, + fontSize: fontSize, + ), + ), + // Floating play/pause — overlaid, zero layout footprint. + if (_hasAudio) + Positioned( + left: 0, + right: 0, + bottom: 8, + child: Center( + child: PlanAudioButton( + controller: _audioController!, + ), ), ), - ), - ], + ], + ), ), - ), - PlanNavigationBottomBar( - navigationContext: widget.navigationContext, - fallbackTitle: currentItem.title, - onPreviousTap: () => _navigate(SwipeDirection.previous), - onNextTap: () => _navigate(SwipeDirection.next), - onFinishedTap: _finish, - ), - ], + PlanNavigationBottomBar( + navigationContext: widget.navigationContext, + fallbackTitle: currentItem.title, + onPreviousTap: () => _navigate(SwipeDirection.previous), + onNextTap: () => _navigate(SwipeDirection.next), + onFinishedTap: _finish, + ), + ], + ), ), ), ), ), - ), ); } - AppBar _buildAppBar(BuildContext context, String title) { + bool _hasRenderableContent(PlanTextItem item) { + switch (item.contentType) { + case PlanItemContentType.inlineText: + return item.inlineContent?.trim().isNotEmpty == true; + case PlanItemContentType.inlineImage: + return item.imageUrl?.trim().isNotEmpty == true; + case PlanItemContentType.sourceReference: + return false; + } + } + + Widget _buildInlineText({ + required String content, + required double fontSize, + }) { + return PlanInlineMarkdownView(content: content, fontSize: fontSize); + } + + /// Scales the image to fill the available viewport while preserving aspect + /// ratio (no cropping). Uses both width and height so tall/portrait images + /// also expand to use the full screen area. + Widget _buildInlineImage({required PlanTextItem item}) { + return LayoutBuilder( + builder: (context, constraints) { + final width = + constraints.maxWidth.isFinite ? constraints.maxWidth : null; + final height = + constraints.maxHeight.isFinite ? constraints.maxHeight : null; + + return CachedNetworkImageWidget( + imageUrl: item.imageUrl, + width: width, + height: height, + fit: BoxFit.contain, + ); + }, + ); + } + + AppBar _buildAppBar( + BuildContext context, + String title, { + required bool showFontControls, + }) { return AppBar( backgroundColor: Theme.of(context).scaffoldBackgroundColor, elevation: 0, @@ -212,10 +268,15 @@ class _PlanTextScreenState extends ConsumerState { }, ), centerTitle: true, - actions: [ - ReaderFontSizeButton(onPressed: () => showFontSizeBottomSheet(context)), - const SizedBox(width: 12), - ], + actions: + showFontControls + ? [ + ReaderFontSizeButton( + onPressed: () => showFontSizeBottomSheet(context), + ), + const SizedBox(width: 12), + ] + : null, ); } diff --git a/lib/features/plans/presentation/widgets/plan_navigation/plan_navigation_bottom_bar.dart b/lib/features/plans/presentation/widgets/plan_navigation/plan_navigation_bottom_bar.dart index cc4ecc42..5736a713 100644 --- a/lib/features/plans/presentation/widgets/plan_navigation/plan_navigation_bottom_bar.dart +++ b/lib/features/plans/presentation/widgets/plan_navigation/plan_navigation_bottom_bar.dart @@ -4,7 +4,7 @@ import 'package:flutter_pecha/core/extensions/context_ext.dart'; import 'package:flutter_pecha/features/reader/data/models/navigation_context.dart'; /// Shared bottom navigation strip used by both `ReaderScreen` (for -/// SOURCE_REFERENCE items) and `PlanTextScreen` (for inline TEXT items). +/// SOURCE_REFERENCE items) and `PlanTextScreen` (for inline TEXT/IMAGE items). /// /// Renders one of three layouts based on the navigation context: /// - **Full controls** (canSwipe): prev arrow, "X of N" + title, next arrow, diff --git a/lib/features/plans/presentation/widgets/plan_navigation/plan_navigator.dart b/lib/features/plans/presentation/widgets/plan_navigation/plan_navigator.dart index bad28677..4de65119 100644 --- a/lib/features/plans/presentation/widgets/plan_navigation/plan_navigator.dart +++ b/lib/features/plans/presentation/widgets/plan_navigation/plan_navigator.dart @@ -7,10 +7,10 @@ import 'package:go_router/go_router.dart'; /// Centralised navigation between plan subtask screens. /// /// A plan day's subtask list is a flat sequence of mixed content types -/// (SOURCE_REFERENCE → ReaderScreen, TEXT → PlanTextScreen). When the user -/// taps prev/next or swipes, the next item may live on a *different* screen -/// than the current one. This helper picks the right route and replaces the -/// current screen, so the user perceives a seamless "1/N → 2/N" sequence. +/// (SOURCE_REFERENCE → ReaderScreen, TEXT/IMAGE → PlanTextScreen). When the +/// user taps prev/next or swipes, the next item may live on a *different* +/// screen than the current one. This helper picks the right route and replaces +/// the current screen, so the user perceives a seamless "1/N → 2/N" sequence. class PlanNavigator { PlanNavigator._(); @@ -65,12 +65,17 @@ class PlanNavigator { case PlanItemContentType.sourceReference: return '${AppRoutes.reader}/${item.textId}'; case PlanItemContentType.inlineText: - // Subtask id is required for plan-text routes; fall back to a stable - // synthetic id only if the item came from preview (no subtaskId). - // Item identity is irrelevant to PlanTextScreen — it reads content - // from navigationContext.currentItem. - final id = item.subtaskId ?? 'preview'; - return '${AppRoutes.planText}/$id'; + case PlanItemContentType.inlineImage: + return _planTextRouteFor(item); } } + + static String _planTextRouteFor(PlanTextItem item) { + // Subtask id is required for plan-text routes; fall back to a stable + // synthetic id only if the item came from preview (no subtaskId). + // Item identity is irrelevant to PlanTextScreen — it reads content + // from navigationContext.currentItem. + final id = item.subtaskId ?? 'preview'; + return '${AppRoutes.planText}/$id'; + } } diff --git a/lib/features/plans/presentation/widgets/plan_navigation/plan_segment_audio_controller.dart b/lib/features/plans/presentation/widgets/plan_navigation/plan_segment_audio_controller.dart index 140c3ef1..02457d40 100644 --- a/lib/features/plans/presentation/widgets/plan_navigation/plan_segment_audio_controller.dart +++ b/lib/features/plans/presentation/widgets/plan_navigation/plan_segment_audio_controller.dart @@ -7,7 +7,7 @@ import 'package:just_audio/just_audio.dart'; enum PlanAudioButtonState { play, loading, pause } /// Reusable audio engine for plan subtask playback, shared by `PlanTextScreen` -/// (inline TEXT) and `ReaderScreen` (SOURCE_REFERENCE). +/// (inline TEXT/IMAGE) and `ReaderScreen` (SOURCE_REFERENCE). /// /// Plays a single segment of a resolved audio file: /// - [url] is already resolved with precedence (subtask audio over day audio). diff --git a/lib/features/plans/presentation/widgets/plan_preview/preview_activity_list.dart b/lib/features/plans/presentation/widgets/plan_preview/preview_activity_list.dart index 8d203f46..7225e913 100644 --- a/lib/features/plans/presentation/widgets/plan_preview/preview_activity_list.dart +++ b/lib/features/plans/presentation/widgets/plan_preview/preview_activity_list.dart @@ -11,7 +11,7 @@ import 'package:flutter_pecha/features/reader/data/models/navigation_context.dar /// data) and never tracks subtask completion. /// /// Tapping a row opens the appropriate screen (ReaderScreen for -/// SOURCE_REFERENCE, PlanTextScreen for TEXT) with the unified +/// SOURCE_REFERENCE, PlanTextScreen for TEXT/IMAGE) with the unified /// [PlanTextItem] list, so the bottom-bar progress works the same as in /// the enrolled flow. class PreviewActivityList extends StatelessWidget { diff --git a/lib/features/reader/data/models/navigation_context.dart b/lib/features/reader/data/models/navigation_context.dart index 9d662812..9028cff6 100644 --- a/lib/features/reader/data/models/navigation_context.dart +++ b/lib/features/reader/data/models/navigation_context.dart @@ -14,7 +14,9 @@ enum NavigationSource { /// and is rendered by `ReaderScreen`. /// - [inlineText] carries inline content via [PlanTextItem.inlineContent] /// and is rendered by `PlanTextScreen`. -enum PlanItemContentType { sourceReference, inlineText } +/// - [inlineImage] carries an image URL via [PlanTextItem.imageUrl] +/// and is rendered by `PlanTextScreen`. +enum PlanItemContentType { sourceReference, inlineText, inlineImage } /// API-level content type strings used by the plan endpoints. class PlanContentTypes { @@ -22,14 +24,17 @@ class PlanContentTypes { static const String sourceReference = 'SOURCE_REFERENCE'; static const String text = 'TEXT'; + static const String image = 'IMAGE'; /// Map a raw API value to a [PlanItemContentType], or null if unknown. static PlanItemContentType? parse(String? raw) { - switch (raw) { + switch (raw?.trim().toUpperCase()) { case sourceReference: return PlanItemContentType.sourceReference; case text: return PlanItemContentType.inlineText; + case image: + return PlanItemContentType.inlineImage; default: return null; } @@ -38,7 +43,7 @@ class PlanContentTypes { /// Represents a navigable subtask within a plan. /// -/// Plan subtasks come in two flavours, both of which appear in the same +/// Plan subtasks come in three flavours, all of which appear in the same /// linear navigation strip ("1 of N", "2 of N", ...): /// /// - **SOURCE_REFERENCE** — opens `ReaderScreen` for [textId] and scrolls @@ -47,24 +52,31 @@ class PlanContentTypes { /// - **TEXT** — opens `PlanTextScreen` and renders [inlineContent] directly. /// Stripped-down view: title in app bar, font size control, no other /// reader features. +/// - **IMAGE** — opens `PlanTextScreen` and renders [imageUrl] directly from +/// the subtask `content` field. /// -/// Construct via [PlanTextItem.sourceReference] or [PlanTextItem.inlineText] -/// to get compile-time validation of which fields are required. +/// Construct via [PlanTextItem.sourceReference], [PlanTextItem.inlineText], or +/// [PlanTextItem.inlineImage] to get compile-time validation of which fields +/// are required. class PlanTextItem { final PlanItemContentType contentType; /// SOURCE_REFERENCE only: the remote text id used by the route - /// `/reader/:textId`. Empty string for inline TEXT items. + /// `/reader/:textId`. Empty string for inline content items. final String textId; /// SOURCE_REFERENCE only: ordered list of segments to scroll through. - /// Null/empty for inline TEXT items. + /// Null/empty for inline content items. final List? segmentIds; /// TEXT only: the inline content rendered by `PlanTextScreen`. /// Null for SOURCE_REFERENCE items. final String? inlineContent; + /// IMAGE only: the image URL rendered by `PlanTextScreen`. + /// Null for SOURCE_REFERENCE and TEXT items. + final String? imageUrl; + /// Display title used in app bars and bottom-bar progress text. final String title; @@ -103,6 +115,7 @@ class PlanTextItem { required this.title, this.segmentIds, this.inlineContent, + this.imageUrl, this.subtaskId, this.taskId, this.isCompleted = false, @@ -164,6 +177,32 @@ class PlanTextItem { ); } + /// Build an IMAGE item. Throws if [imageUrl] is blank. + factory PlanTextItem.inlineImage({ + required String imageUrl, + required String title, + String? subtaskId, + String? taskId, + bool isCompleted = false, + String? audioUrl, + int? startMs, + int? endMs, + }) { + assert(imageUrl.trim().isNotEmpty, 'inlineImage requires non-blank URL'); + return PlanTextItem._( + contentType: PlanItemContentType.inlineImage, + textId: '', + imageUrl: imageUrl, + title: title, + subtaskId: subtaskId, + taskId: taskId, + isCompleted: isCompleted, + audioUrl: audioUrl, + startMs: startMs, + endMs: endMs, + ); + } + /// True if this item is a SOURCE_REFERENCE. bool get isSourceReference => contentType == PlanItemContentType.sourceReference; @@ -171,6 +210,9 @@ class PlanTextItem { /// True if this item is an inline TEXT item. bool get isInlineText => contentType == PlanItemContentType.inlineText; + /// True if this item is an inline IMAGE item. + bool get isInlineImage => contentType == PlanItemContentType.inlineImage; + /// Get the first segment ID for initial scroll position /// (SOURCE_REFERENCE only). String? get firstSegmentId => @@ -181,6 +223,7 @@ class PlanTextItem { String? textId, List? segmentIds, String? inlineContent, + String? imageUrl, String? title, String? subtaskId, String? taskId, @@ -194,6 +237,7 @@ class PlanTextItem { textId: textId ?? this.textId, segmentIds: segmentIds ?? this.segmentIds, inlineContent: inlineContent ?? this.inlineContent, + imageUrl: imageUrl ?? this.imageUrl, title: title ?? this.title, subtaskId: subtaskId ?? this.subtaskId, taskId: taskId ?? this.taskId, @@ -212,6 +256,7 @@ class PlanTextItem { other.textId != textId || other.title != title || other.inlineContent != inlineContent || + other.imageUrl != imageUrl || other.subtaskId != subtaskId || other.taskId != taskId || other.isCompleted != isCompleted || @@ -235,6 +280,7 @@ class PlanTextItem { textId, Object.hashAll(segmentIds ?? const []), inlineContent, + imageUrl, title, subtaskId, taskId, From 763ed6c78d6eb151ee5cdb1dde184dc4abb2c06e Mon Sep 17 00:00:00 2001 From: tenzin dhakar Date: Mon, 29 Jun 2026 23:31:27 +0530 Subject: [PATCH 02/27] feat(plan_details): add thumbnail and shareable image support in plan day details - Enhanced PlanDaysModel and UserPlanDayDetailResponse to include thumbnailUrl and shareableImageUrl fields. - Updated DayCompletionBottomSheet to handle sharing of images and display thumbnails. - Refactored plan completion logic to utilize new image fields for improved user experience. --- .../plans/data/models/plan_days_model.dart | 40 +++-- .../user_plan_day_detail_response.dart | 23 ++- .../widgets/day_completion_bottom_sheet.dart | 160 +++++++++++++++++- .../widgets/plan_track/plan_details.dart | 13 +- 4 files changed, 204 insertions(+), 32 deletions(-) diff --git a/lib/features/plans/data/models/plan_days_model.dart b/lib/features/plans/data/models/plan_days_model.dart index e148b6a4..56f22c8a 100644 --- a/lib/features/plans/data/models/plan_days_model.dart +++ b/lib/features/plans/data/models/plan_days_model.dart @@ -8,6 +8,8 @@ class PlanDaysModel { final List? tasks; final String? audioUrl; final int? audioDurationMs; + final String? thumbnailUrl; + final String? shareableImageUrl; final List videos; PlanDaysModel({ @@ -17,6 +19,8 @@ class PlanDaysModel { this.tasks, this.audioUrl, this.audioDurationMs, + this.thumbnailUrl, + this.shareableImageUrl, this.videos = const [], }); @@ -27,20 +31,26 @@ class PlanDaysModel { id: json['id'] as String, dayNumber: json['day_number'] as int, title: json['title'] as String?, - tasks: json['tasks'] != null - ? (json['tasks'] as List) - .map((e) => PlanTasksModel.fromJson(e as Map)) - .toList() - : null, + tasks: + json['tasks'] != null + ? (json['tasks'] as List) + .map( + (e) => PlanTasksModel.fromJson(e as Map), + ) + .toList() + : null, audioUrl: json['audio_url'] as String?, audioDurationMs: json['audio_duration_ms'] as int?, - videos: json['videos'] != null - ? (json['videos'] as List) - .map( - (e) => PlanVideoModel.fromJson(e as Map), - ) - .toList() - : const [], + thumbnailUrl: json['thumbnail_url'] as String?, + shareableImageUrl: json['shareable_image_url'] as String?, + videos: + json['videos'] != null + ? (json['videos'] as List) + .map( + (e) => PlanVideoModel.fromJson(e as Map), + ) + .toList() + : const [], ); } @@ -52,6 +62,8 @@ class PlanDaysModel { 'tasks': tasks?.map((e) => e.toJson()).toList(), 'audio_url': audioUrl, 'audio_duration_ms': audioDurationMs, + 'thumbnail_url': thumbnailUrl, + 'shareable_image_url': shareableImageUrl, 'videos': videos.map((e) => e.toJson()).toList(), }; } @@ -63,6 +75,8 @@ class PlanDaysModel { List? tasks, String? audioUrl, int? audioDurationMs, + String? thumbnailUrl, + String? shareableImageUrl, List? videos, }) { return PlanDaysModel( @@ -72,6 +86,8 @@ class PlanDaysModel { tasks: tasks ?? this.tasks, audioUrl: audioUrl ?? this.audioUrl, audioDurationMs: audioDurationMs ?? this.audioDurationMs, + thumbnailUrl: thumbnailUrl ?? this.thumbnailUrl, + shareableImageUrl: shareableImageUrl ?? this.shareableImageUrl, videos: videos ?? this.videos, ); } diff --git a/lib/features/plans/data/models/response/user_plan_day_detail_response.dart b/lib/features/plans/data/models/response/user_plan_day_detail_response.dart index 9c0ae459..5bf80ec7 100644 --- a/lib/features/plans/data/models/response/user_plan_day_detail_response.dart +++ b/lib/features/plans/data/models/response/user_plan_day_detail_response.dart @@ -8,6 +8,8 @@ class UserPlanDayDetailResponse { final bool isCompleted; final String? audioUrl; final int? audioDurationMs; + final String? thumbnailUrl; + final String? shareableImageUrl; final List videos; UserPlanDayDetailResponse({ @@ -17,6 +19,8 @@ class UserPlanDayDetailResponse { required this.isCompleted, this.audioUrl, this.audioDurationMs, + this.thumbnailUrl, + this.shareableImageUrl, this.videos = const [], }); @@ -33,13 +37,16 @@ class UserPlanDayDetailResponse { isCompleted: json['is_completed'] as bool, audioUrl: json['audio_url'] as String?, audioDurationMs: json['audio_duration_ms'] as int?, - videos: json['videos'] != null - ? (json['videos'] as List) - .map( - (e) => PlanVideoModel.fromJson(e as Map), - ) - .toList() - : const [], + thumbnailUrl: json['thumbnail_url'] as String?, + shareableImageUrl: json['shareable_image_url'] as String?, + videos: + json['videos'] != null + ? (json['videos'] as List) + .map( + (e) => PlanVideoModel.fromJson(e as Map), + ) + .toList() + : const [], ); } @@ -51,6 +58,8 @@ class UserPlanDayDetailResponse { 'is_completed': isCompleted, 'audio_url': audioUrl, 'audio_duration_ms': audioDurationMs, + 'thumbnail_url': thumbnailUrl, + 'shareable_image_url': shareableImageUrl, 'videos': videos.map((e) => e.toJson()).toList(), }; } diff --git a/lib/features/plans/presentation/widgets/day_completion_bottom_sheet.dart b/lib/features/plans/presentation/widgets/day_completion_bottom_sheet.dart index d5e782bf..d575e239 100644 --- a/lib/features/plans/presentation/widgets/day_completion_bottom_sheet.dart +++ b/lib/features/plans/presentation/widgets/day_completion_bottom_sheet.dart @@ -1,11 +1,21 @@ +import 'dart:io'; + 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/widgets/cached_network_image_widget.dart'; +import 'package:flutter_pecha/shared/utils/helper_functions.dart'; +import 'package:http/http.dart' as http; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; -class DayCompletionBottomSheet extends StatelessWidget { +class DayCompletionBottomSheet extends StatefulWidget { final int dayNumber; final int totalDays; final int completedDays; - final String? imageUrl; + final String? fallbackImageUrl; + final String? thumbnailUrl; + final String? shareableImageUrl; final String planTitle; const DayCompletionBottomSheet({ @@ -13,13 +23,28 @@ class DayCompletionBottomSheet extends StatelessWidget { required this.dayNumber, required this.totalDays, required this.completedDays, - required this.imageUrl, + required this.fallbackImageUrl, + this.thumbnailUrl, + this.shareableImageUrl, required this.planTitle, }); + @override + State createState() => + _DayCompletionBottomSheetState(); +} + +class _DayCompletionBottomSheetState extends State { + final GlobalKey _shareButtonKey = GlobalKey(); + bool _isSharing = false; + + bool get _hasShareableImage => + widget.shareableImageUrl?.trim().isNotEmpty == true; + @override Widget build(BuildContext context) { - final progress = totalDays > 0 ? completedDays / totalDays : 0.0; + final progress = + widget.totalDays > 0 ? widget.completedDays / widget.totalDays : 0.0; return Container( width: double.infinity, @@ -39,7 +64,9 @@ class DayCompletionBottomSheet extends StatelessWidget { const SizedBox(height: 20), _buildPlanImageCard(context), const SizedBox(height: 30), - _buildProgressBar(context, progress), + _hasShareableImage + ? _buildShareButton(context) + : _buildProgressBar(context, progress), const SizedBox(height: 25), ], ), @@ -80,7 +107,7 @@ class DayCompletionBottomSheet extends StatelessWidget { Widget _buildDayText(BuildContext context) { return Text( - 'Day $dayNumber of $totalDays', + 'Day ${widget.dayNumber} of ${widget.totalDays}', style: TextStyle( fontSize: 24, fontWeight: FontWeight.bold, @@ -92,13 +119,17 @@ class DayCompletionBottomSheet extends StatelessWidget { Widget _buildPlanImageCard(BuildContext context) { final imageWidth = MediaQuery.of(context).size.width - 80; + final displayImageUrl = + widget.thumbnailUrl?.trim().isNotEmpty == true + ? widget.thumbnailUrl!.trim() + : widget.fallbackImageUrl?.trim(); - if (imageUrl == null || imageUrl!.isEmpty) { + if (displayImageUrl == null || displayImageUrl.isEmpty) { return _buildPlaceholderImage(context, imageWidth); } return CachedNetworkImageWidget( - imageUrl: imageUrl!, + imageUrl: displayImageUrl, width: imageWidth, height: 180, fit: BoxFit.cover, @@ -131,6 +162,119 @@ class DayCompletionBottomSheet extends StatelessWidget { ); } + Widget _buildShareButton(BuildContext context) { + final buttonWidth = MediaQuery.of(context).size.width - 48; + + return SizedBox( + width: buttonWidth, + height: 56, + child: FilledButton.icon( + key: _shareButtonKey, + onPressed: _isSharing ? null : _shareImage, + icon: + _isSharing + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(AppAssets.readerShare, size: 22), + label: Text( + context.l10n.share, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700), + ), + style: FilledButton.styleFrom( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + disabledBackgroundColor: Colors.black.withValues(alpha: 0.65), + disabledForegroundColor: Colors.white.withValues(alpha: 0.85), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ); + } + + Future _shareImage() async { + final url = widget.shareableImageUrl?.trim(); + if (url == null || url.isEmpty || _isSharing) return; + + setState(() => _isSharing = true); + + File? tempFile; + try { + final uri = Uri.parse(url); + final response = await http.get(uri); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw HttpException( + 'Failed to download share image (${response.statusCode})', + uri: uri, + ); + } + + final directory = await getTemporaryDirectory(); + final extension = _imageExtensionFromUrl(uri); + tempFile = File( + '${directory.path}/plan_day_${widget.dayNumber}_${DateTime.now().millisecondsSinceEpoch}.$extension', + ); + await tempFile.writeAsBytes(response.bodyBytes); + + if (!mounted) return; + + final sharePositionOrigin = getSharePositionOrigin( + context: context, + globalKey: _shareButtonKey, + ); + + await SharePlus.instance.share( + ShareParams( + files: [XFile(tempFile.path)], + sharePositionOrigin: sharePositionOrigin, + ), + ); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.create_image_share_error), + behavior: SnackBarBehavior.floating, + ), + ); + } + } finally { + if (tempFile != null && await tempFile.exists()) { + try { + await tempFile.delete(); + } catch (_) { + // Best-effort temp cleanup. + } + } + + if (mounted) { + setState(() => _isSharing = false); + } + } + } + + String _imageExtensionFromUrl(Uri uri) { + final lastSegment = + uri.pathSegments.isNotEmpty ? uri.pathSegments.last : ''; + final extension = + lastSegment.contains('.') + ? lastSegment.split('.').last.toLowerCase() + : ''; + switch (extension) { + case 'jpg': + case 'jpeg': + case 'png': + case 'webp': + return extension; + default: + return 'png'; + } + } + Widget _buildProgressBar(BuildContext context, double progress) { final barWidth = MediaQuery.of(context).size.width - 80; diff --git a/lib/features/plans/presentation/widgets/plan_track/plan_details.dart b/lib/features/plans/presentation/widgets/plan_track/plan_details.dart index 8ceb164d..ad4baf1c 100644 --- a/lib/features/plans/presentation/widgets/plan_track/plan_details.dart +++ b/lib/features/plans/presentation/widgets/plan_track/plan_details.dart @@ -24,6 +24,7 @@ import 'package:flutter_pecha/features/plans/data/models/user/user_tasks_dto.dar import 'package:flutter_pecha/features/plans/domain/subtask_navigation.dart'; import 'package:flutter_pecha/features/plans/presentation/widgets/plan_navigation/plan_navigator.dart'; import 'package:flutter_pecha/core/extensions/context_ext.dart'; +import 'package:flutter_pecha/features/plans/data/models/response/user_plan_day_detail_response.dart'; import 'package:flutter_pecha/features/reader/data/models/navigation_context.dart'; import 'package:flutter_pecha/shared/utils/helper_functions.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -126,7 +127,7 @@ class _PlanDetailsState extends ConsumerState { if (_dayCompletionTracker.containsKey(day)) { final wasCompleted = _dayCompletionTracker[day]!; if (!wasCompleted && dayContent.isCompleted) { - _onDayCompleted(day); + _onDayCompleted(dayContent); } } _dayCompletionTracker[day] = dayContent.isCompleted; @@ -149,7 +150,7 @@ class _PlanDetailsState extends ConsumerState { }); } - Future _onDayCompleted(int dayNumber) async { + Future _onDayCompleted(UserPlanDayDetailResponse dayContent) async { try { final completionStatusEither = await ref.read( userPlanDaysCompletionStatusProvider(widget.plan.id).future, @@ -170,7 +171,7 @@ class _PlanDetailsState extends ConsumerState { properties: { AnalyticsProperties.planId: widget.plan.id, AnalyticsProperties.planName: widget.plan.title, - AnalyticsProperties.dayNumber: dayNumber, + AnalyticsProperties.dayNumber: dayContent.dayNumber, AnalyticsProperties.totalDays: widget.plan.totalDays, AnalyticsProperties.completedDays: completedDays, }, @@ -185,10 +186,12 @@ class _PlanDetailsState extends ConsumerState { backgroundColor: Colors.transparent, builder: (_) => DayCompletionBottomSheet( - dayNumber: dayNumber, + dayNumber: dayContent.dayNumber, totalDays: widget.plan.totalDays, completedDays: completedDays, - imageUrl: widget.plan.imageUrl, + fallbackImageUrl: widget.plan.imageUrl, + thumbnailUrl: dayContent.thumbnailUrl, + shareableImageUrl: dayContent.shareableImageUrl, planTitle: widget.plan.title, ), ); From e8c681aebcc76f29037376e6b83bd16858c9a69e Mon Sep 17 00:00:00 2001 From: tenzin dhakar Date: Tue, 30 Jun 2026 23:42:24 +0530 Subject: [PATCH 03/27] feat: add deep link support for series sharing and enhance routing --- lib/core/config/router/app_router.dart | 10 ++++++++++ lib/core/deep_linking/deep_link_router.dart | 10 ++++++++++ .../deep_linking/deep_link_url_builder.dart | 8 ++++++++ .../screens/series_detail_screen.dart | 10 ++++++++++ .../widgets/series_more_bottom_sheet.dart | 17 +++++++++++++++++ 5 files changed, 55 insertions(+) diff --git a/lib/core/config/router/app_router.dart b/lib/core/config/router/app_router.dart index c8171eda..027e0414 100644 --- a/lib/core/config/router/app_router.dart +++ b/lib/core/config/router/app_router.dart @@ -121,6 +121,16 @@ final appRouterProvider = Provider((ref) { redirect: (_, __) => AppRoutes.home, ), + // Compatibility fallback for series deep links delivered directly to + // go_router instead of through AppLinksDeepLinkService. + GoRoute( + path: '/open/series/:seriesId', + name: 'open-series', + redirect: (_, state) { + final seriesId = state.pathParameters['seriesId'] ?? ''; + return '/home/series/$seriesId'; + }, + ), // Compatibility fallback in case a platform sends the first-party app // link directly to go_router instead of through AppLinksDeepLinkService. GoRoute( diff --git a/lib/core/deep_linking/deep_link_router.dart b/lib/core/deep_linking/deep_link_router.dart index 026569c5..9ff47728 100644 --- a/lib/core/deep_linking/deep_link_router.dart +++ b/lib/core/deep_linking/deep_link_router.dart @@ -81,6 +81,16 @@ class DeepLinkRouter { static _DeepLinkDestination _resolveFirstPartyAppLink(Uri uri) { final segments = uri.pathSegments; + if (segments.length >= 3 && + segments[0] == 'open' && + segments[1] == 'series') { + final seriesId = segments[2]; + return _DeepLinkDestination( + '/home/series/${Uri.encodeComponent(seriesId)}', + opensOnTop: true, + ); + } + if (segments.length >= 3 && segments[0] == 'open' && segments[1] == 'reader') { diff --git a/lib/core/deep_linking/deep_link_url_builder.dart b/lib/core/deep_linking/deep_link_url_builder.dart index faa74364..fa2a1428 100644 --- a/lib/core/deep_linking/deep_link_url_builder.dart +++ b/lib/core/deep_linking/deep_link_url_builder.dart @@ -3,6 +3,14 @@ class DeepLinkUrlBuilder { static const String _host = 'webuddhist.com'; + static Uri seriesLink({required String seriesId}) { + return Uri( + scheme: 'https', + host: _host, + pathSegments: ['open', 'series', seriesId], + ); + } + static Uri readerSegmentLink({ required String textId, required String segmentId, diff --git a/lib/features/home/presentation/screens/series_detail_screen.dart b/lib/features/home/presentation/screens/series_detail_screen.dart index 8bcdde1c..5646f443 100644 --- a/lib/features/home/presentation/screens/series_detail_screen.dart +++ b/lib/features/home/presentation/screens/series_detail_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/deep_linking/deep_link_url_builder.dart'; import 'package:flutter_pecha/core/l10n/generated/app_localizations.dart'; import 'package:flutter_pecha/core/widgets/error_state_widget.dart'; import 'package:flutter_pecha/core/widgets/skeletons/skeletons.dart'; @@ -15,6 +16,7 @@ import 'package:flutter_pecha/features/practice/presentation/providers/bookmark_ 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:share_plus/share_plus.dart'; class SeriesDetailScreen extends ConsumerWidget { final String seriesId; @@ -167,9 +169,17 @@ class SeriesDetailScreen extends ConsumerWidget { seriesId: series.id, seriesName: series.title, onAddToPractices: () => _onAddToPractices(context, ref, series), + onShare: () => _onShare(series), ); } + Future _onShare(Series series) async { + final url = DeepLinkUrlBuilder.seriesLink(seriesId: series.id).toString(); + final message = + 'Join me in practicing ${series.title} on WeBuddhist.\n\n$url'; + await SharePlus.instance.share(ShareParams(text: message)); + } + /// Adds the series to the user's practice routine. /// /// Opens the routine editor with the already-loaded [series] injected. Adding 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 3be113ce..d02e4591 100644 --- a/lib/features/home/presentation/widgets/series_more_bottom_sheet.dart +++ b/lib/features/home/presentation/widgets/series_more_bottom_sheet.dart @@ -13,17 +13,20 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; /// Contains: /// • "+ Add to my practices" action /// • Bookmark toggle action +/// • Share action class SeriesMoreBottomSheet extends ConsumerStatefulWidget { const SeriesMoreBottomSheet({ super.key, required this.seriesId, required this.seriesName, this.onAddToPractices, + this.onShare, }); final String seriesId; final String seriesName; final VoidCallback? onAddToPractices; + final VoidCallback? onShare; @override ConsumerState createState() => @@ -118,6 +121,18 @@ class _SeriesMoreBottomSheetState extends ConsumerState { }, ), + // ── Share ────────────────────────────────────────────────────── + _SectionDivider(theme: theme), + ListTile( + leading: Icon(AppAssets.share, color: theme.colorScheme.onSurface), + title: Text('Share', style: theme.textTheme.bodyLarge), + onTap: () { + HapticFeedback.lightImpact(); + Navigator.of(context).pop(); + widget.onShare?.call(); + }, + ), + const SizedBox(height: 8), ], ), @@ -140,6 +155,7 @@ void showSeriesMoreBottomSheet( required String seriesId, required String seriesName, VoidCallback? onAddToPractices, + VoidCallback? onShare, }) { showModalBottomSheet( context: context, @@ -153,6 +169,7 @@ void showSeriesMoreBottomSheet( seriesId: seriesId, seriesName: seriesName, onAddToPractices: onAddToPractices, + onShare: onShare, ), ); } From 8b12cc5b876f2b6f438f2e84c6f22089cd3cb02c Mon Sep 17 00:00:00 2001 From: TenzDelek Date: Wed, 1 Jul 2026 11:27:16 +0530 Subject: [PATCH 04/27] image streah fix --- .../presentation/widgets/group_profile_members_tab.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/features/group_profile/presentation/widgets/group_profile_members_tab.dart b/lib/features/group_profile/presentation/widgets/group_profile_members_tab.dart index 8db80c9b..2714f183 100644 --- a/lib/features/group_profile/presentation/widgets/group_profile_members_tab.dart +++ b/lib/features/group_profile/presentation/widgets/group_profile_members_tab.dart @@ -227,8 +227,6 @@ class _GroupMemberRow extends StatelessWidget { ? CachedNetworkImageWidget( key: ValueKey(member.avatarUrl), imageUrl: member.avatarUrl, - width: 44, - height: 44, fit: BoxFit.cover, errorWidget: _buildAvatarFallback(isDark), ) From e6171f4bdc8b890e2a387689eb7f16f7be68cf6c Mon Sep 17 00:00:00 2001 From: TenzDelek Date: Wed, 1 Jul 2026 11:30:25 +0530 Subject: [PATCH 05/27] p with s --- lib/core/l10n/app_en.arb | 2 +- lib/core/l10n/generated/app_localizations.dart | 2 +- lib/core/l10n/generated/app_localizations_en.dart | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/core/l10n/app_en.arb b/lib/core/l10n/app_en.arb index 3d241281..6b9efad2 100644 --- a/lib/core/l10n/app_en.arb +++ b/lib/core/l10n/app_en.arb @@ -108,7 +108,7 @@ "nav_home": "Home", "nav_explore": "Explore", "nav_learn": "Learn", - "nav_practice": "Practice", + "nav_practice": "Practices", "nav_settings": "Settings", "nav_connect": "Connect", "nav_me": "Me", diff --git a/lib/core/l10n/generated/app_localizations.dart b/lib/core/l10n/generated/app_localizations.dart index f4a10855..268b3288 100644 --- a/lib/core/l10n/generated/app_localizations.dart +++ b/lib/core/l10n/generated/app_localizations.dart @@ -511,7 +511,7 @@ abstract class AppLocalizations { /// No description provided for @nav_practice. /// /// In en, this message translates to: - /// **'Practice'** + /// **'Practices'** String get nav_practice; /// No description provided for @nav_settings. diff --git a/lib/core/l10n/generated/app_localizations_en.dart b/lib/core/l10n/generated/app_localizations_en.dart index a01ad149..3bb7fda5 100644 --- a/lib/core/l10n/generated/app_localizations_en.dart +++ b/lib/core/l10n/generated/app_localizations_en.dart @@ -236,7 +236,7 @@ class AppLocalizationsEn extends AppLocalizations { String get nav_learn => 'Learn'; @override - String get nav_practice => 'Practice'; + String get nav_practice => 'Practices'; @override String get nav_settings => 'Settings'; From 47c5e57812360dee06dbefd46bc3a9546bd5faf6 Mon Sep 17 00:00:00 2001 From: TenzDelek Date: Wed, 1 Jul 2026 11:32:32 +0530 Subject: [PATCH 06/27] padding on about --- .../group_profile/presentation/widgets/group_profile_body.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 03189dbe..2ba7c19c 100644 --- a/lib/features/group_profile/presentation/widgets/group_profile_body.dart +++ b/lib/features/group_profile/presentation/widgets/group_profile_body.dart @@ -391,7 +391,7 @@ class _GroupProfileBodyState extends ConsumerState } return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: 2), child: ClipRRect( borderRadius: BorderRadius.circular(16), child: AspectRatio( From c88c29edaa7356d816de06fe208b4ed2a50a72aa Mon Sep 17 00:00:00 2001 From: TenzDelek Date: Wed, 1 Jul 2026 11:58:45 +0530 Subject: [PATCH 07/27] padding --- .../presentation/widgets/group_profile_body.dart | 5 ++++- lib/features/home/presentation/widgets/home_header.dart | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) 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 2ba7c19c..018f4a71 100644 --- a/lib/features/group_profile/presentation/widgets/group_profile_body.dart +++ b/lib/features/group_profile/presentation/widgets/group_profile_body.dart @@ -126,7 +126,10 @@ class _GroupProfileBodyState extends ConsumerState crossAxisAlignment: CrossAxisAlignment.start, children: [ if (!_isCommunityGroup(profile) && _hasBanner(profile)) ...[ - _buildProfileBanner(profile, isDark), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: _buildProfileBanner(profile, isDark), + ), const SizedBox(height: 16), ], _buildProfileHeader(profile, isDark, lineHeight), diff --git a/lib/features/home/presentation/widgets/home_header.dart b/lib/features/home/presentation/widgets/home_header.dart index b4a5f17f..08a9d737 100644 --- a/lib/features/home/presentation/widgets/home_header.dart +++ b/lib/features/home/presentation/widgets/home_header.dart @@ -128,7 +128,7 @@ class _Greeting extends StatelessWidget { width: maxWidth, child: _buildLine( context: context, - text: withTibetanLineBreakOpportunities(greeting), + text: greeting, style: greetingStyle, fontSize: greetingFontSize, ), From 6b362246bc061302c296b1916668f3485f35f03b Mon Sep 17 00:00:00 2001 From: tenzin dhakar Date: Wed, 1 Jul 2026 12:24:58 +0530 Subject: [PATCH 08/27] refactor: streamline recitation navigation by removing redundant parameters and introducing a dedicated navigation utility. the recitation text reader screen was inconsistent from the home and practice screen paths. --- .../widgets/home_shortcuts_row.dart | 16 +------------- .../screens/all_recitations_screen.dart | 10 ++++----- .../screens/recitations_search_screen.dart | 8 +++---- .../utils/recitation_reader_navigation.dart | 13 ++++++++++++ .../widgets/practice_chants_section.dart | 21 ++++--------------- pubspec.lock | 16 +++++++------- 6 files changed, 33 insertions(+), 51 deletions(-) create mode 100644 lib/features/practice/presentation/utils/recitation_reader_navigation.dart diff --git a/lib/features/home/presentation/widgets/home_shortcuts_row.dart b/lib/features/home/presentation/widgets/home_shortcuts_row.dart index ae5e042b..fe34d56c 100644 --- a/lib/features/home/presentation/widgets/home_shortcuts_row.dart +++ b/lib/features/home/presentation/widgets/home_shortcuts_row.dart @@ -6,8 +6,6 @@ import 'package:flutter_pecha/features/home/domain/entities/series.dart'; import 'package:flutter_pecha/features/practice/presentation/providers/practice_explore_providers.dart'; import 'package:flutter_pecha/features/practice/presentation/screens/all_plans_screen.dart'; import 'package:flutter_pecha/features/practice/presentation/screens/all_recitations_screen.dart'; -import 'package:flutter_pecha/features/reader/data/models/navigation_context.dart'; -import 'package:flutter_pecha/features/recitation/data/models/recitation_model.dart'; import 'package:flutter_pecha/shared/utils/helper_functions.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -54,21 +52,9 @@ class HomeShortcutsRow extends ConsumerWidget { }); } - void _navigateToRecitation(BuildContext context, RecitationModel recitation) { - final navigationContext = NavigationContext( - source: NavigationSource.normal, - ); - context.push('/reader/${recitation.textId}', extra: navigationContext); - } - void _onChantsTap(BuildContext context, WidgetRef ref) { Navigator.of(context).push( - MaterialPageRoute( - builder: - (_) => AllRecitationsScreen( - onTap: (r) => _navigateToRecitation(context, r), - ), - ), + MaterialPageRoute(builder: (_) => const AllRecitationsScreen()), ); } diff --git a/lib/features/practice/presentation/screens/all_recitations_screen.dart b/lib/features/practice/presentation/screens/all_recitations_screen.dart index bca78763..dff20133 100644 --- a/lib/features/practice/presentation/screens/all_recitations_screen.dart +++ b/lib/features/practice/presentation/screens/all_recitations_screen.dart @@ -3,15 +3,13 @@ import 'package:flutter_pecha/core/extensions/context_ext.dart'; import 'package:flutter_pecha/core/widgets/error_state_widget.dart'; import 'package:flutter_pecha/features/practice/presentation/providers/practice_recitations_paginated_provider.dart'; import 'package:flutter_pecha/features/practice/presentation/screens/recitations_search_screen.dart'; +import 'package:flutter_pecha/features/practice/presentation/utils/recitation_reader_navigation.dart'; import 'package:flutter_pecha/features/practice/presentation/widgets/practice_chant_list_tile.dart'; -import 'package:flutter_pecha/features/recitation/data/models/recitation_model.dart'; import 'package:flutter_pecha/features/recitation/presentation/widgets/recitation_list_skeleton.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; class AllRecitationsScreen extends ConsumerStatefulWidget { - const AllRecitationsScreen({super.key, required this.onTap}); - - final ValueChanged onTap; + const AllRecitationsScreen({super.key}); @override ConsumerState createState() => @@ -43,7 +41,7 @@ class _AllRecitationsScreenState extends ConsumerState { void _openSearch(BuildContext context) { Navigator.of(context).push( - MaterialPageRoute(builder: (_) => RecitationsSearchScreen(onTap: widget.onTap)), + MaterialPageRoute(builder: (_) => const RecitationsSearchScreen()), ); } @@ -111,7 +109,7 @@ class _AllRecitationsScreenState extends ConsumerState { final recitation = state.recitations[index]; return PracticeChantListTile( recitation: recitation, - onTap: () => widget.onTap(recitation), + onTap: () => openRecitationReader(context, recitation), ); }, ); diff --git a/lib/features/practice/presentation/screens/recitations_search_screen.dart b/lib/features/practice/presentation/screens/recitations_search_screen.dart index 7ce959d0..270dde56 100644 --- a/lib/features/practice/presentation/screens/recitations_search_screen.dart +++ b/lib/features/practice/presentation/screens/recitations_search_screen.dart @@ -1,17 +1,15 @@ import 'package:flutter/material.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/presentation/utils/recitation_reader_navigation.dart'; import 'package:flutter_pecha/features/practice/presentation/widgets/practice_chant_list_tile.dart'; -import 'package:flutter_pecha/features/recitation/data/models/recitation_model.dart'; import 'package:flutter_pecha/features/recitation/presentation/providers/recitation_search_provider.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_riverpod/flutter_riverpod.dart'; class RecitationsSearchScreen extends ConsumerStatefulWidget { - const RecitationsSearchScreen({super.key, required this.onTap}); - - final ValueChanged onTap; + const RecitationsSearchScreen({super.key}); @override ConsumerState createState() => @@ -205,7 +203,7 @@ class _RecitationsSearchScreenState final recitation = searchState.results[index]; return PracticeChantListTile( recitation: recitation, - onTap: () => widget.onTap(recitation), + onTap: () => openRecitationReader(context, recitation), ); }, ); diff --git a/lib/features/practice/presentation/utils/recitation_reader_navigation.dart b/lib/features/practice/presentation/utils/recitation_reader_navigation.dart new file mode 100644 index 00000000..ed909a76 --- /dev/null +++ b/lib/features/practice/presentation/utils/recitation_reader_navigation.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_pecha/features/reader/data/models/navigation_context.dart'; +import 'package:flutter_pecha/features/recitation/data/models/recitation_model.dart'; +import 'package:go_router/go_router.dart'; + +/// Opens a chant/recitation in the shared reader with practice-context actions +/// (e.g. "Add to my practices" in the more menu). +void openRecitationReader(BuildContext context, RecitationModel recitation) { + context.push( + '/reader/${recitation.textId}', + extra: const NavigationContext(source: NavigationSource.recitationList), + ); +} diff --git a/lib/features/practice/presentation/widgets/practice_chants_section.dart b/lib/features/practice/presentation/widgets/practice_chants_section.dart index bbf3f2da..04fd98f6 100644 --- a/lib/features/practice/presentation/widgets/practice_chants_section.dart +++ b/lib/features/practice/presentation/widgets/practice_chants_section.dart @@ -2,13 +2,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_pecha/core/extensions/context_ext.dart'; import 'package:flutter_pecha/features/practice/presentation/providers/practice_explore_providers.dart'; import 'package:flutter_pecha/features/practice/presentation/screens/all_recitations_screen.dart'; +import 'package:flutter_pecha/features/practice/presentation/utils/recitation_reader_navigation.dart'; import 'package:flutter_pecha/features/practice/presentation/widgets/practice_chant_list_tile.dart'; import 'package:flutter_pecha/features/practice/presentation/widgets/practice_section_container.dart'; import 'package:flutter_pecha/features/practice/presentation/widgets/practice_section_skeleton.dart'; -import 'package:flutter_pecha/features/reader/data/models/navigation_context.dart'; -import 'package:flutter_pecha/features/recitation/data/models/recitation_model.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; class PracticeChantsSection extends ConsumerWidget { const PracticeChantsSection({super.key}); @@ -38,7 +36,8 @@ class PracticeChantsSection extends ConsumerWidget { .map( (r) => PracticeChantListTile( recitation: r, - onTap: () => _navigateToRecitation(context, r), + onTap: + () => openRecitationReader(context, r), ), ) .toList(), @@ -57,21 +56,9 @@ class PracticeChantsSection extends ConsumerWidget { ); } - void _navigateToRecitation(BuildContext context, RecitationModel recitation) { - final navigationContext = NavigationContext( - source: NavigationSource.recitationList, - ); - context.push('/reader/${recitation.textId}', extra: navigationContext); - } - void _showAllRecitations(BuildContext context) { Navigator.of(context).push( - MaterialPageRoute( - builder: - (_) => AllRecitationsScreen( - onTap: (r) => _navigateToRecitation(context, r), - ), - ), + MaterialPageRoute(builder: (_) => const AllRecitationsScreen()), ); } } diff --git a/pubspec.lock b/pubspec.lock index 888db383..8feaa84c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -221,10 +221,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -1145,18 +1145,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: @@ -1687,10 +1687,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.7" tibetan_calendar: dependency: "direct main" description: From 2223efcaf17c488cefa0e64caa3e9ad8fb9d8c30 Mon Sep 17 00:00:00 2001 From: tentamdin Date: Wed, 1 Jul 2026 13:17:21 +0530 Subject: [PATCH 09/27] refactor: remove unused user plans provider and enhance series enrollment handling in plan list view --- .../screens/series_detail_screen.dart | 4 - .../presentation/widgets/plan_list_view.dart | 323 +++++++++++------- 2 files changed, 204 insertions(+), 123 deletions(-) diff --git a/lib/features/home/presentation/screens/series_detail_screen.dart b/lib/features/home/presentation/screens/series_detail_screen.dart index 92ddbadc..7819b947 100644 --- a/lib/features/home/presentation/screens/series_detail_screen.dart +++ b/lib/features/home/presentation/screens/series_detail_screen.dart @@ -11,7 +11,6 @@ import 'package:flutter_pecha/features/home/domain/entities/series.dart'; import 'package:flutter_pecha/features/home/presentation/providers/series_provider.dart'; import 'package:flutter_pecha/features/home/presentation/widgets/plan_list_view.dart'; import 'package:flutter_pecha/features/home/presentation/widgets/series_more_bottom_sheet.dart'; -import 'package:flutter_pecha/features/plans/presentation/providers/user_plans_provider.dart'; import 'package:flutter_pecha/features/practice/data/datasource/bookmark_remote_datasource.dart'; import 'package:flutter_pecha/features/practice/presentation/providers/bookmark_providers.dart'; import 'package:flutter_pecha/shared/utils/helper_functions.dart'; @@ -41,9 +40,6 @@ class SeriesDetailScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - ref.watch( - myPlansPaginatedProvider, - ); // pre-warm so enrolled state is ready when list items render final seriesAsync = ref.watch(seriesByIdProvider(seriesId)); final localizations = AppLocalizations.of(context)!; diff --git a/lib/features/home/presentation/widgets/plan_list_view.dart b/lib/features/home/presentation/widgets/plan_list_view.dart index ebd0261c..f655783c 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/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'; import 'package:flutter_pecha/features/plans/presentation/providers/user_plans_provider.dart'; @@ -24,13 +25,47 @@ import 'package:flutter_pecha/shared/utils/helper_functions.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +/// Series-scoped enrollment derived from `GET /users/me/series` only. +/// When enrolled, every plan in the series is treated as enrolled. +@immutable +class SeriesListEnrollmentState { + const SeriesListEnrollmentState({ + required this.isSeriesEnrolled, + this.isLoading = false, + }); + + final bool isSeriesEnrolled; + final bool isLoading; + + static const none = SeriesListEnrollmentState(isSeriesEnrolled: false); +} + +SeriesListEnrollmentState _watchSeriesListEnrollment( + WidgetRef ref, + String? seriesId, +) { + if (seriesId == null) return SeriesListEnrollmentState.none; + + final auth = ref.watch(authProvider); + if (auth.isGuest || !auth.isLoggedIn) { + return SeriesListEnrollmentState.none; + } + + final enrollmentsAsync = ref.watch(userSeriesEnrollmentsProvider); + return SeriesListEnrollmentState( + isSeriesEnrolled: + enrollmentsAsync.valueOrNull?.contains(seriesId) ?? false, + isLoading: enrollmentsAsync.isLoading, + ); +} + /// Renders a non-empty list of [Plan]s as a featured card on top and a scrolling /// list below — used by both `PlanListScreen` (tag-filtered) and /// `SeriesDetailScreen` (series-filtered). Caller must guard against empty input. /// /// When [groupId] is provided (navigated from a group profile), the featured /// card shows "Practice with us" until the user enrolls through that group. -class PlanListView extends StatelessWidget { +class PlanListView extends ConsumerWidget { final List plans; final String? seriesId; final Series? series; @@ -49,7 +84,8 @@ class PlanListView extends StatelessWidget { }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final seriesEnrollment = _watchSeriesListEnrollment(ref, seriesId); final sorted = [...plans]..sort((a, b) { if (a.displayOrder != null && b.displayOrder != null) { return a.displayOrder!.compareTo(b.displayOrder!); @@ -74,6 +110,7 @@ class PlanListView extends StatelessWidget { groupId: groupId, groupType: groupType, isGroupEnrolled: isGroupEnrolled, + seriesEnrollment: seriesEnrollment, ), ), ), @@ -82,8 +119,11 @@ class PlanListView extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16.0), sliver: SliverList( delegate: SliverChildBuilderDelegate( - (context, index) => - PlanListItem(plan: sorted[index], seriesId: seriesId), + (context, index) => PlanListItem( + plan: sorted[index], + seriesId: seriesId, + seriesEnrollment: seriesEnrollment, + ), childCount: sorted.length, ), ), @@ -110,6 +150,7 @@ class FeaturedPlanCard extends ConsumerWidget { final String? groupId; final GroupType? groupType; final bool isGroupEnrolled; + final SeriesListEnrollmentState seriesEnrollment; const FeaturedPlanCard({ super.key, @@ -119,6 +160,7 @@ class FeaturedPlanCard extends ConsumerWidget { this.groupId, this.groupType, this.isGroupEnrolled = false, + this.seriesEnrollment = SeriesListEnrollmentState.none, }); @override @@ -131,12 +173,25 @@ class FeaturedPlanCard extends ConsumerWidget { final displayImage = series?.coverImage ?? plan.coverImage; final localizations = AppLocalizations.of(context)!; - final myPlansState = ref.watch(myPlansPaginatedProvider); final isGuest = ref.watch(authProvider).isGuest; - final isEnrolled = !isGuest && _isPlanEnrolled(ref, plan.id); - final enrolledInfo = isEnrolled ? _getEnrolledInfo(ref, plan.id) : null; + final isSeriesContext = seriesId != null; + final isSeriesEnrolled = + isSeriesContext && !isGuest && seriesEnrollment.isSeriesEnrolled; + + 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 isEnrolledInfoPending = - isEnrolled && enrolledInfo == null && myPlansState.isLoading; + !isSeriesContext && + isPlanEnrolled && + enrolledInfo == null && + myPlansState!.isLoading; final hasDescription = displayDescription.trim().isNotEmpty; final enrollmentState = @@ -145,25 +200,16 @@ class FeaturedPlanCard extends ConsumerWidget { : null; final isEnrolling = enrollmentState is SeriesEnrollmentLoading; - final seriesEnrollmentAsync = - seriesId != null ? ref.watch(userSeriesEnrollmentsProvider) : null; - final isSeriesEnrolled = - seriesId != null && - (seriesEnrollmentAsync?.valueOrNull?.contains(seriesId!) ?? false); - final isGroupPracticeFlow = groupId != null && !isGroupEnrolled; - // Hide button during initial load to prevent flickering - final isLoadingEnrollmentData = - myPlansState.isLoading || - (seriesId != null && - !isGroupPracticeFlow && - seriesEnrollmentAsync?.isLoading == true); + final isLoadingEnrollmentData = isSeriesContext + ? (!isGroupPracticeFlow && seriesEnrollment.isLoading) + : myPlansState!.isLoading; final hideEnrollButton = isGroupEnrolled || isLoadingEnrollmentData || - (isGroupPracticeFlow ? false : (isEnrolled || isSeriesEnrolled)); + (isGroupPracticeFlow ? false : isPlanEnrolled); final isDark = Theme.of(context).brightness == Brightness.dark; final enrollBackgroundColor = isDark ? AppColors.surfaceWhite : AppColors.scaffoldBackgroundDark; @@ -296,7 +342,12 @@ class FeaturedPlanCard extends ConsumerWidget { final id = seriesId; if (id == null) { - _navigateToPlan(context, plan, null); + _navigateToPlan( + context, + plan: plan, + isEnrolled: _isPlanEnrolledFromMyData(ref, plan.id), + enrolledInfo: _getEnrolledInfoFromMyPlans(ref, plan.id), + ); return; } @@ -381,8 +432,14 @@ class FeaturedPlanCard extends ConsumerWidget { class PlanListItem extends ConsumerWidget { final Plan plan; final String? seriesId; + final SeriesListEnrollmentState seriesEnrollment; - const PlanListItem({super.key, required this.plan, this.seriesId}); + const PlanListItem({ + super.key, + required this.plan, + this.seriesId, + this.seriesEnrollment = SeriesListEnrollmentState.none, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -390,19 +447,30 @@ class PlanListItem extends ConsumerWidget { final lineHeight = getLineHeight(locale.languageCode); final titleFontSize = getLocalizedFontSize(AppTextSize.body); - final myPlansState = ref.watch(myPlansPaginatedProvider); final isGuest = ref.watch(authProvider).isGuest; - final isEnrolled = !isGuest && _isPlanEnrolled(ref, plan.id); - final enrolledInfo = isEnrolled ? _getEnrolledInfo(ref, plan.id) : null; + final isSeriesContext = seriesId != null; + final isSeriesEnrolled = + isSeriesContext && !isGuest && seriesEnrollment.isSeriesEnrolled; + + 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 isEnrolledInfoPending = - isEnrolled && enrolledInfo == null && myPlansState.isLoading; + !isSeriesContext && + isPlanEnrolled && + enrolledInfo == null && + myPlansState!.isLoading; final dateRange = PlanDateRange.tryCreate( startDate: plan.startDate, totalDays: plan.totalDays, ); - final userPlan = - isEnrolled ? _findUserPlan(myPlansState.plans, plan.id) : null; - final canShowStatus = isEnrolled && userPlan != null && dateRange != null; + final canShowStatus = isPlanEnrolled && dateRange != null; final isLocked = plan.startDate != null && plan.startDate!.isAfter(DateTime.now()); @@ -417,9 +485,10 @@ class PlanListItem extends ConsumerWidget { ? null : () => _navigateToPlan( context, - plan, - enrolledInfo, + plan: plan, + isEnrolled: isPlanEnrolled, seriesId: seriesId, + enrolledInfo: enrolledInfo, ), borderRadius: BorderRadius.circular(12), child: Row( @@ -463,7 +532,7 @@ class PlanListItem extends ConsumerWidget { context, plan: plan, dateRange: dateRange, - isEnrolled: isEnrolled, + isEnrolled: isPlanEnrolled, lineHeight: lineHeight, ), ), @@ -519,15 +588,109 @@ class PlanListItem extends ConsumerWidget { } } -/// Returns the [UserPlansModel] for [planId] from the user's plans list, -/// or null if the plan isn't enrolled / not yet hydrated. Pulled out so the -/// list item can share the lookup with the status indicator without doing -/// it twice. -UserPlansModel? _findUserPlan(List plans, String planId) { - for (final p in plans) { - if (p.id == planId) return p; +class _EnrolledPlanInfo { + final UserPlansModel userPlan; + final int selectedDay; + final DateTime startDate; + + const _EnrolledPlanInfo({ + required this.userPlan, + required this.selectedDay, + required this.startDate, + }); +} + +/// Navigates to practice details when enrolled, otherwise plan preview. +/// +/// On series screens enrollment comes from `GET /users/me/series`; catalog +/// [Plan] data from `GET /series/{id}` is enough to open practice details. +/// Tag-filtered lists still hydrate navigation from [myPlansPaginatedProvider]. +void _navigateToPlan( + BuildContext context, { + required Plan plan, + required bool isEnrolled, + String? seriesId, + _EnrolledPlanInfo? enrolledInfo, +}) { + if (isEnrolled) { + final userPlan = enrolledInfo?.userPlan ?? _userPlanFromCatalogPlan(plan); + final startDate = enrolledInfo?.startDate ?? userPlan.effectiveStartDate; + final selectedDay = + enrolledInfo?.selectedDay ?? + _selectedDayForStart(startDate, userPlan.totalDays); + + context.push( + '/practice/details', + extra: { + 'plan': userPlan, + 'selectedDay': selectedDay, + 'startDate': startDate, + }, + ); + return; + } + + context.push( + '/practice/plans/preview', + extra: {'plan': plan, if (seriesId != null) 'seriesId': seriesId}, + ); +} + +UserPlansModel _userPlanFromCatalogPlan(Plan plan) { + return UserPlansModel( + id: plan.id, + title: plan.title, + description: plan.description, + language: plan.language, + difficultyLevel: plan.difficulty.name, + image: + plan.coverImage != null + ? ImageModel.fromResponsiveImage(plan.coverImage!) + : null, + startedAt: plan.startDate ?? DateTime.now(), + totalDays: plan.totalDays, + tags: null, + startDate: plan.startDate, + ); +} + +int _selectedDayForStart(DateTime startDate, int totalDays) { + final daysSinceStart = + DateTime.now().difference(DateUtils.dateOnly(startDate)).inDays; + return (daysSinceStart + 1).clamp(1, totalDays); +} + +/// Tag-filtered plan lists only — checks my plans and routine membership. +bool _isPlanEnrolledFromMyData(WidgetRef ref, String planId) { + final myPlansState = ref.watch(myPlansPaginatedProvider); + if (myPlansState.plans.any((p) => p.id == planId)) return true; + + final routineData = ref.watch(userRoutineProvider).valueOrNull; + if (routineData == null) return false; + return routineData.blocks.any( + (block) => block.items.any( + (item) => item.id == planId && item.type == RoutineItemType.series, + ), + ); +} + +_EnrolledPlanInfo? _getEnrolledInfoFromMyPlans(WidgetRef ref, String planId) { + final myPlansState = ref.watch(myPlansPaginatedProvider); + UserPlansModel? userPlan; + for (final p in myPlansState.plans) { + if (p.id == planId) { + userPlan = p; + break; + } } - return null; + if (userPlan == null) return null; + + final startDate = userPlan.startDate ?? userPlan.startedAt; + return _EnrolledPlanInfo( + userPlan: userPlan, + selectedDay: _selectedDayForStart(startDate, userPlan.totalDays), + startDate: startDate, + ); } class _PlanCoverImage extends StatelessWidget { @@ -579,81 +742,3 @@ class _PlanCoverImage extends StatelessWidget { ); } } - -class _EnrolledPlanInfo { - final UserPlansModel userPlan; - final int selectedDay; - final DateTime startDate; - - const _EnrolledPlanInfo({ - required this.userPlan, - required this.selectedDay, - required this.startDate, - }); -} - -void _navigateToPlan( - BuildContext context, - Plan plan, - _EnrolledPlanInfo? enrolledInfo, { - String? seriesId, -}) { - if (enrolledInfo != null) { - context.push( - '/practice/details', - extra: { - 'plan': enrolledInfo.userPlan, - 'selectedDay': enrolledInfo.selectedDay, - 'startDate': enrolledInfo.startDate, - }, - ); - } else { - context.push( - '/practice/plans/preview', - extra: {'plan': plan, if (seriesId != null) 'seriesId': seriesId}, - ); - } -} - -/// A plan is enrolled if it appears in either the user's plans list or in -/// their routine. Either source independently is enough to mark the plan as -/// enrolled — the providers load lazily, and the user may have removed an -/// enrolled plan from their routine without unenrolling. -bool _isPlanEnrolled(WidgetRef ref, String planId) { - final myPlansState = ref.watch(myPlansPaginatedProvider); - if (myPlansState.plans.any((p) => p.id == planId)) return true; - - final routineData = ref.watch(userRoutineProvider).valueOrNull; - if (routineData == null) return false; - return routineData.blocks.any( - (block) => block.items.any( - (item) => item.id == planId && item.type == RoutineItemType.series, - ), - ); -} - -/// Returns the data needed to navigate to `/practice/details`. -/// The plan must be present in [myPlansPaginatedProvider]; routine membership -/// is not required (a user may be enrolled without adding the plan to a routine). -_EnrolledPlanInfo? _getEnrolledInfo(WidgetRef ref, String planId) { - final myPlansState = ref.watch(myPlansPaginatedProvider); - UserPlansModel? userPlan; - for (final p in myPlansState.plans) { - if (p.id == planId) { - userPlan = p; - break; - } - } - if (userPlan == null) return null; - - final startDate = userPlan.startDate ?? userPlan.startedAt; - final daysSinceEnrollment = - DateTime.now().difference(DateUtils.dateOnly(startDate)).inDays; - final selectedDay = (daysSinceEnrollment + 1).clamp(1, userPlan.totalDays); - - return _EnrolledPlanInfo( - userPlan: userPlan, - selectedDay: selectedDay, - startDate: startDate, - ); -} From e175aa7fe8a5af2d4265334720e63a8472cf2e42 Mon Sep 17 00:00:00 2001 From: tenzin dhakar Date: Wed, 1 Jul 2026 15:40:42 +0530 Subject: [PATCH 10/27] feat: add sharing functionality to the reader bottom sheet and implement deep link generation for sharing text IDs --- .../deep_linking/deep_link_url_builder.dart | 10 +++++ .../reader_more_bottom_sheet.dart | 42 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/lib/core/deep_linking/deep_link_url_builder.dart b/lib/core/deep_linking/deep_link_url_builder.dart index faa74364..1e2f9e9f 100644 --- a/lib/core/deep_linking/deep_link_url_builder.dart +++ b/lib/core/deep_linking/deep_link_url_builder.dart @@ -3,6 +3,16 @@ class DeepLinkUrlBuilder { static const String _host = 'webuddhist.com'; + /// Returns a link that opens the reader at the very first segment — + /// i.e. no segment/lang params so the app starts from the top. + static Uri readerLink({required String textId}) { + return Uri( + scheme: 'https', + host: _host, + pathSegments: ['open', 'reader', textId], + ); + } + static Uri readerSegmentLink({ required String textId, required String segmentId, 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 4ea62773..772e9ef1 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 @@ -1,13 +1,16 @@ 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/reader/presentation/widgets/reader_app_bar/reader_font_size_bottom_sheet.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'; +import 'package:share_plus/share_plus.dart'; /// Bottom sheet opened from the reader's three-dot (⋮) button. /// @@ -34,6 +37,7 @@ class ReaderMoreBottomSheet extends ConsumerStatefulWidget { class _ReaderMoreBottomSheetState extends ConsumerState { bool _isBookmarking = false; + bool _isSharing = false; BookmarkTarget get _bookmarkTarget => BookmarkTarget( type: BookmarkType.text, @@ -55,6 +59,23 @@ class _ReaderMoreBottomSheetState extends ConsumerState { } } + Future _share() async { + if (_isSharing) return; + setState(() => _isSharing = true); + try { + final shareUrl = + DeepLinkUrlBuilder.readerLink(textId: widget.textId).toString(); + if (!mounted) return; + final sharePositionOrigin = getSharePositionOrigin(context: context); + await SharePlus.instance.share( + ShareParams(text: shareUrl, sharePositionOrigin: sharePositionOrigin), + ); + if (mounted) Navigator.of(context).pop(); + } finally { + if (mounted) setState(() => _isSharing = false); + } + } + // ── font size helpers ────────────────────────────────────────────────────── int _stepIndex(double fontSize) { @@ -196,6 +217,27 @@ class _ReaderMoreBottomSheetState extends ConsumerState { }, ), + // ── Share ────────────────────────────────────────────────────── + _SectionDivider(theme: theme), + ListTile( + leading: + _isSharing + ? SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.onSurface, + ), + ) + : Icon(Icons.share, color: theme.colorScheme.onSurface), + title: Text('Share', style: theme.textTheme.bodyLarge), + onTap: () { + HapticFeedback.lightImpact(); + _share(); + }, + ), + const SizedBox(height: 8), ], ), From edc72a45cb4ea3713baabc95000ea4096d052b79 Mon Sep 17 00:00:00 2001 From: tentamdin Date: Wed, 1 Jul 2026 16:03:57 +0530 Subject: [PATCH 11/27] refactor: update locale handling and simplify home screen layout --- lib/core/config/locale/locale_notifier.dart | 4 +- lib/core/constants/app_config.dart | 11 +-- .../presentation/screens/home_screen.dart | 96 ++++++------------- 3 files changed, 31 insertions(+), 80 deletions(-) diff --git a/lib/core/config/locale/locale_notifier.dart b/lib/core/config/locale/locale_notifier.dart index 7c9a6801..0b6e0b77 100644 --- a/lib/core/config/locale/locale_notifier.dart +++ b/lib/core/config/locale/locale_notifier.dart @@ -99,8 +99,8 @@ final localeProvider = StateNotifierProvider((ref) { /// Language code sent to backend APIs for translatable content. /// -/// UI may be hi/mn/ne, but the API only serves en/zh/bo — unsupported locales -/// fall back to English via [AppConfig.resolveContentLanguage]. +/// Mirrors the user's selected app locale when supported; unknown codes fall +/// back to English via [AppConfig.resolveContentLanguage]. final contentLanguageProvider = Provider((ref) { return AppConfig.resolveContentLanguage( ref.watch(localeProvider).languageCode, diff --git a/lib/core/constants/app_config.dart b/lib/core/constants/app_config.dart index 8d080dbf..dd920610 100644 --- a/lib/core/constants/app_config.dart +++ b/lib/core/constants/app_config.dart @@ -40,18 +40,11 @@ class AppConfig { 'ne', ]; - /// Languages the backend can serve translatable content in. - static const List backendContentLanguages = [ - englishLanguageCode, - chineseLanguageCode, - tibetanLanguageCode, - ]; - /// Maps a UI locale to the language code sent to content APIs. - /// Falls back to English when the backend does not support the locale. + /// Returns the locale when it is a supported app language; otherwise English. static String resolveContentLanguage(String localeCode) { final code = localeCode.toLowerCase(); - if (backendContentLanguages.contains(code)) return code; + if (supportedLanguages.contains(code)) return code; return englishLanguageCode; } diff --git a/lib/features/home/presentation/screens/home_screen.dart b/lib/features/home/presentation/screens/home_screen.dart index d6d5f83b..c27a68cb 100644 --- a/lib/features/home/presentation/screens/home_screen.dart +++ b/lib/features/home/presentation/screens/home_screen.dart @@ -303,79 +303,37 @@ class _HomeScreenState extends ConsumerState { } Widget _buildBody(BuildContext context, AppLocalizations localizations) { - final seriesAsync = ref.watch(seriesListFutureProvider); - final fontSize = getLocalizedFontSize(AppTextSize.title); - return Expanded( child: RefreshIndicator( onRefresh: _onRefresh, - child: seriesAsync.when( - data: (seriesEither) { - return seriesEither.fold( - (failure) => _buildScrollableMessage( - ErrorStateWidget(error: failure, onRetry: _refetchSeries), - ), - (seriesList) { - if (seriesList.isEmpty) { - return _buildScrollableMessage( - Padding( - padding: const EdgeInsets.all( - HomeScreenConstants.emptyStatePadding, - ), - child: Text( - localizations.no_feature_content, - style: TextStyle(fontSize: fontSize), - textAlign: TextAlign.center, - ), - ), - ); - } - - return CustomScrollView( - physics: const AlwaysScrollableScrollPhysics(), - slivers: [ - SliverToBoxAdapter( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox( - height: HomeScreenConstants.cardSpacing, - ), - _buildVerseOfDaySection(), - const SizedBox( - height: HomeScreenConstants.cardSpacing, - ), - HomeShortcutsRow( - onTimerTap: () => _openGatedFeature('/home/timers'), - onMalaTap: () => _openGatedFeature('/mala'), - ), - const SizedBox( - height: HomeScreenConstants.cardSpacing, - ), - _buildMyPracticesSection(), - const SizedBox( - height: HomeScreenConstants.cardSpacing, - ), - FeaturedPlanSection( - onSeriesTap: (series) { - _log.info('Featured series tapped: ${series.id}'); - _navigateToSeries(series); - }, - ), - ], - ), - ), - const SliverToBoxAdapter(child: HomeSharePrompt()), - ], - ); - }, - ); - }, - loading: () => const SizedBox.shrink(), - error: - (error, stackTrace) => _buildScrollableMessage( - ErrorStateWidget(error: error, onRetry: _refetchSeries), + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: HomeScreenConstants.cardSpacing), + _buildVerseOfDaySection(), + const SizedBox(height: HomeScreenConstants.cardSpacing), + HomeShortcutsRow( + onTimerTap: () => _openGatedFeature('/home/timers'), + onMalaTap: () => _openGatedFeature('/mala'), + ), + const SizedBox(height: HomeScreenConstants.cardSpacing), + _buildMyPracticesSection(), + const SizedBox(height: HomeScreenConstants.cardSpacing), + FeaturedPlanSection( + onSeriesTap: (series) { + _log.info('Featured series tapped: ${series.id}'); + _navigateToSeries(series); + }, + ), + ], ), + ), + const SliverToBoxAdapter(child: HomeSharePrompt()), + ], ), ), ); From 7f4d3c8941422500edf4466795ef948bf1e27d14 Mon Sep 17 00:00:00 2001 From: TenzDelek Date: Wed, 1 Jul 2026 16:08:25 +0530 Subject: [PATCH 12/27] base --- lib/core/config/protected_routes.dart | 3 + lib/core/config/router/app_router.dart | 16 + lib/core/l10n/app_en.arb | 15 + .../l10n/generated/app_localizations.dart | 48 ++ .../l10n/generated/app_localizations_bo.dart | 28 + .../l10n/generated/app_localizations_en.dart | 28 + .../l10n/generated/app_localizations_hi.dart | 28 + .../l10n/generated/app_localizations_mn.dart | 28 + .../l10n/generated/app_localizations_ne.dart | 28 + .../l10n/generated/app_localizations_zh.dart | 28 + .../group_accumulator_remote_datasource.dart | 139 ++++ .../data/models/group_accumulator_model.dart | 259 +++++++ .../group_accumulator_repository_impl.dart | 114 +++ .../domain/entities/group_accumulator.dart | 173 +++++ .../group_accumulator_repository.dart | 24 + .../group_accumulator_providers.dart | 189 +++++ .../screens/group_accumulator_screen.dart | 731 ++++++++++++++++++ .../widgets/group_accumulator_card.dart | 176 +++++ .../widgets/group_profile_body.dart | 151 +++- 19 files changed, 2189 insertions(+), 17 deletions(-) create mode 100644 lib/features/group_profile/data/datasource/group_accumulator_remote_datasource.dart create mode 100644 lib/features/group_profile/data/models/group_accumulator_model.dart create mode 100644 lib/features/group_profile/data/repositories/group_accumulator_repository_impl.dart create mode 100644 lib/features/group_profile/domain/entities/group_accumulator.dart create mode 100644 lib/features/group_profile/domain/repositories/group_accumulator_repository.dart create mode 100644 lib/features/group_profile/presentation/providers/group_accumulator_providers.dart create mode 100644 lib/features/group_profile/presentation/screens/group_accumulator_screen.dart create mode 100644 lib/features/group_profile/presentation/widgets/group_accumulator_card.dart diff --git a/lib/core/config/protected_routes.dart b/lib/core/config/protected_routes.dart index 62f70eb3..bd0c8ba1 100644 --- a/lib/core/config/protected_routes.dart +++ b/lib/core/config/protected_routes.dart @@ -66,6 +66,9 @@ class ProtectedRoutes { '/author/groups/{groupId}/join', '/author/groups/{groupId}/follow', + // Group accumulators (group prayer accumulations) + '/group-accumulators/', + // Plans (public endpoints but may need auth for user-specific data) '/plans/{planId}', '/plans/{planId}/days/{dayNumber}', diff --git a/lib/core/config/router/app_router.dart b/lib/core/config/router/app_router.dart index b62ed997..a3f9f1f5 100644 --- a/lib/core/config/router/app_router.dart +++ b/lib/core/config/router/app_router.dart @@ -12,6 +12,7 @@ import 'package:flutter_pecha/features/auth/presentation/screens/login_page.dart import 'package:flutter_pecha/features/auth/presentation/screens/splash_screen.dart'; import 'package:flutter_pecha/features/calendar/presentation/screens/tibetan_calendar_screen.dart'; import 'package:flutter_pecha/features/group_profile/domain/entities/group_profile.dart'; +import 'package:flutter_pecha/features/group_profile/presentation/screens/group_accumulator_screen.dart'; import 'package:flutter_pecha/features/group_profile/presentation/screens/group_profile_screen.dart'; import 'package:flutter_pecha/features/home/domain/entities/series.dart'; import 'package:flutter_pecha/features/home/presentation/screens/main_navigation_screen.dart'; @@ -241,6 +242,21 @@ final appRouterProvider = Provider((ref) { return GroupProfileScreen(groupId: groupId); }, ), + GoRoute( + path: "group-accumulator/:accumulatorId", + name: "home-group-accumulator", + parentNavigatorKey: rootNavigatorKey, + builder: (context, state) { + final accumulatorId = + state.pathParameters['accumulatorId'] ?? ''; + final extra = state.extra as Map?; + final groupTitle = extra?['groupTitle'] as String?; + return GroupAccumulatorScreen( + accumulatorId: accumulatorId, + groupTitle: groupTitle, + ); + }, + ), GoRoute( path: "timers", name: "home-timers", diff --git a/lib/core/l10n/app_en.arb b/lib/core/l10n/app_en.arb index 3d241281..c83265c7 100644 --- a/lib/core/l10n/app_en.arb +++ b/lib/core/l10n/app_en.arb @@ -785,6 +785,21 @@ "group_practice_with_us": "Practice with us", "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", + "group_accumulator_join_error": "Unable to join accumulation. Please try again.", + "group_accumulator_participants": "{count} participants", + "@group_accumulator_participants": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "group_accumulator_leaderboard": "Leaderboard", + "group_accumulator_my_contributions": "My Contributions", + "group_accumulator_recited": "Recited", + "group_accumulator_total": "Total", + "group_accumulator_contributions_empty": "Join this accumulation to track your contributions.", "@group_and_more_links": { "placeholders": { "count": { diff --git a/lib/core/l10n/generated/app_localizations.dart b/lib/core/l10n/generated/app_localizations.dart index f4a10855..f5357e2a 100644 --- a/lib/core/l10n/generated/app_localizations.dart +++ b/lib/core/l10n/generated/app_localizations.dart @@ -2956,6 +2956,54 @@ abstract class AppLocalizations { /// **'You are already practicing this plan with another group. Would you like to change your practice group?'** String get group_change_practice_message; + /// No description provided for @group_join_to_contribute. + /// + /// In en, this message translates to: + /// **'Join to contribute'** + String get group_join_to_contribute; + + /// No description provided for @group_accumulator_join_error. + /// + /// In en, this message translates to: + /// **'Unable to join accumulation. Please try again.'** + String get group_accumulator_join_error; + + /// No description provided for @group_accumulator_participants. + /// + /// In en, this message translates to: + /// **'{count} participants'** + String group_accumulator_participants(int count); + + /// No description provided for @group_accumulator_leaderboard. + /// + /// In en, this message translates to: + /// **'Leaderboard'** + String get group_accumulator_leaderboard; + + /// No description provided for @group_accumulator_my_contributions. + /// + /// In en, this message translates to: + /// **'My Contributions'** + String get group_accumulator_my_contributions; + + /// No description provided for @group_accumulator_recited. + /// + /// In en, this message translates to: + /// **'Recited'** + String get group_accumulator_recited; + + /// No description provided for @group_accumulator_total. + /// + /// In en, this message translates to: + /// **'Total'** + String get group_accumulator_total; + + /// No description provided for @group_accumulator_contributions_empty. + /// + /// In en, this message translates to: + /// **'Join this accumulation to track your contributions.'** + String get group_accumulator_contributions_empty; + /// No description provided for @share_this_quote. /// /// 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 71efb61c..7e0a47c6 100644 --- a/lib/core/l10n/generated/app_localizations_bo.dart +++ b/lib/core/l10n/generated/app_localizations_bo.dart @@ -1633,6 +1633,34 @@ class AppLocalizationsBo extends AppLocalizations { String get group_change_practice_message => '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'; + + @override + String get group_accumulator_join_error => + 'Unable to join accumulation. Please try again.'; + + @override + String group_accumulator_participants(int count) { + return '$count participants'; + } + + @override + String get group_accumulator_leaderboard => 'Leaderboard'; + + @override + String get group_accumulator_my_contributions => 'My Contributions'; + + @override + String get group_accumulator_recited => 'Recited'; + + @override + String get group_accumulator_total => 'Total'; + + @override + String get group_accumulator_contributions_empty => + 'Join this accumulation to track your contributions.'; + @override String get share_this_quote => 'གསུང་ཚིག་འདི་སྤེལ།'; diff --git a/lib/core/l10n/generated/app_localizations_en.dart b/lib/core/l10n/generated/app_localizations_en.dart index a01ad149..98a9e423 100644 --- a/lib/core/l10n/generated/app_localizations_en.dart +++ b/lib/core/l10n/generated/app_localizations_en.dart @@ -1612,6 +1612,34 @@ class AppLocalizationsEn extends AppLocalizations { String get group_change_practice_message => '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'; + + @override + String get group_accumulator_join_error => + 'Unable to join accumulation. Please try again.'; + + @override + String group_accumulator_participants(int count) { + return '$count participants'; + } + + @override + String get group_accumulator_leaderboard => 'Leaderboard'; + + @override + String get group_accumulator_my_contributions => 'My Contributions'; + + @override + String get group_accumulator_recited => 'Recited'; + + @override + String get group_accumulator_total => 'Total'; + + @override + String get group_accumulator_contributions_empty => + 'Join this accumulation to track your contributions.'; + @override String get share_this_quote => 'Share this quote'; diff --git a/lib/core/l10n/generated/app_localizations_hi.dart b/lib/core/l10n/generated/app_localizations_hi.dart index 72448463..5b35de11 100644 --- a/lib/core/l10n/generated/app_localizations_hi.dart +++ b/lib/core/l10n/generated/app_localizations_hi.dart @@ -1625,6 +1625,34 @@ class AppLocalizationsHi extends AppLocalizations { String get group_change_practice_message => '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'; + + @override + String get group_accumulator_join_error => + 'Unable to join accumulation. Please try again.'; + + @override + String group_accumulator_participants(int count) { + return '$count participants'; + } + + @override + String get group_accumulator_leaderboard => 'Leaderboard'; + + @override + String get group_accumulator_my_contributions => 'My Contributions'; + + @override + String get group_accumulator_recited => 'Recited'; + + @override + String get group_accumulator_total => 'Total'; + + @override + String get group_accumulator_contributions_empty => + 'Join this accumulation to track your contributions.'; + @override String get share_this_quote => 'यह उद्धरण शेयर करें'; diff --git a/lib/core/l10n/generated/app_localizations_mn.dart b/lib/core/l10n/generated/app_localizations_mn.dart index 89209900..a31ce8b6 100644 --- a/lib/core/l10n/generated/app_localizations_mn.dart +++ b/lib/core/l10n/generated/app_localizations_mn.dart @@ -1629,6 +1629,34 @@ class AppLocalizationsMn extends AppLocalizations { String get group_change_practice_message => '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'; + + @override + String get group_accumulator_join_error => + 'Unable to join accumulation. Please try again.'; + + @override + String group_accumulator_participants(int count) { + return '$count participants'; + } + + @override + String get group_accumulator_leaderboard => 'Leaderboard'; + + @override + String get group_accumulator_my_contributions => 'My Contributions'; + + @override + String get group_accumulator_recited => 'Recited'; + + @override + String get group_accumulator_total => 'Total'; + + @override + String get group_accumulator_contributions_empty => + 'Join this accumulation to track your contributions.'; + @override String get share_this_quote => 'Энэ ишлэлийг хуваалцах'; diff --git a/lib/core/l10n/generated/app_localizations_ne.dart b/lib/core/l10n/generated/app_localizations_ne.dart index 81a61d2a..957a0cdd 100644 --- a/lib/core/l10n/generated/app_localizations_ne.dart +++ b/lib/core/l10n/generated/app_localizations_ne.dart @@ -1633,6 +1633,34 @@ class AppLocalizationsNe extends AppLocalizations { String get group_change_practice_message => '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'; + + @override + String get group_accumulator_join_error => + 'Unable to join accumulation. Please try again.'; + + @override + String group_accumulator_participants(int count) { + return '$count participants'; + } + + @override + String get group_accumulator_leaderboard => 'Leaderboard'; + + @override + String get group_accumulator_my_contributions => 'My Contributions'; + + @override + String get group_accumulator_recited => 'Recited'; + + @override + String get group_accumulator_total => 'Total'; + + @override + String get group_accumulator_contributions_empty => + 'Join this accumulation to track your contributions.'; + @override String get share_this_quote => 'यो उद्धरण साझा गर्नुहोस्'; diff --git a/lib/core/l10n/generated/app_localizations_zh.dart b/lib/core/l10n/generated/app_localizations_zh.dart index 3dc3017f..9cebcd81 100644 --- a/lib/core/l10n/generated/app_localizations_zh.dart +++ b/lib/core/l10n/generated/app_localizations_zh.dart @@ -1538,6 +1538,34 @@ class AppLocalizationsZh extends AppLocalizations { String get group_change_practice_message => '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'; + + @override + String get group_accumulator_join_error => + 'Unable to join accumulation. Please try again.'; + + @override + String group_accumulator_participants(int count) { + return '$count participants'; + } + + @override + String get group_accumulator_leaderboard => 'Leaderboard'; + + @override + String get group_accumulator_my_contributions => 'My Contributions'; + + @override + String get group_accumulator_recited => 'Recited'; + + @override + String get group_accumulator_total => 'Total'; + + @override + String get group_accumulator_contributions_empty => + 'Join this accumulation to track your contributions.'; + @override String get share_this_quote => '分享这句话'; diff --git a/lib/features/group_profile/data/datasource/group_accumulator_remote_datasource.dart b/lib/features/group_profile/data/datasource/group_accumulator_remote_datasource.dart new file mode 100644 index 00000000..e46a503e --- /dev/null +++ b/lib/features/group_profile/data/datasource/group_accumulator_remote_datasource.dart @@ -0,0 +1,139 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_pecha/core/error/exceptions.dart'; +import 'package:flutter_pecha/core/utils/app_logger.dart'; +import 'package:flutter_pecha/features/group_profile/data/models/group_accumulator_model.dart'; +import 'package:flutter_pecha/features/group_profile/domain/entities/group_accumulator.dart'; + +class GroupAccumulatorRemoteDatasource { + final Dio dio; + final _logger = AppLogger('GroupAccumulatorRemoteDatasource'); + + GroupAccumulatorRemoteDatasource({required this.dio}); + + Future fetchGroupAccumulators( + String groupId, { + required int skip, + required int limit, + }) async { + try { + final response = await dio.get( + '/group-accumulators/$groupId/accumulators', + queryParameters: {'skip': skip, 'limit': limit}, + options: Options(extra: {'no_cache': true}), + ); + + if (response.statusCode == 200) { + return GroupAccumulatorsPageModel.fromJson( + response.data as Map, + ); + } + + throw _statusToException( + response.statusCode, + 'Failed to load group accumulators', + ); + } on DioException catch (e) { + _logger.error('Dio error in fetchGroupAccumulators', e); + throw _dioToException(e, 'Failed to load group accumulators'); + } + } + + Future fetchGroupAccumulator(String accumulatorId) async { + try { + final response = await dio.get( + '/group-accumulators/$accumulatorId', + options: Options(extra: {'no_cache': true}), + ); + + if (response.statusCode == 200) { + return GroupAccumulatorModel.fromJson( + response.data as Map, + ); + } + + throw _statusToException( + response.statusCode, + 'Failed to load group accumulator', + ); + } on DioException catch (e) { + _logger.error('Dio error in fetchGroupAccumulator', e); + throw _dioToException(e, 'Failed to load group accumulator'); + } + } + + Future joinGroupAccumulator(String accumulatorId) async { + try { + final response = await dio.post('/group-accumulators/$accumulatorId/join'); + if (response.statusCode != 200 && + response.statusCode != 201 && + response.statusCode != 204) { + throw _statusToException( + response.statusCode, + 'Failed to join group accumulator', + ); + } + } on DioException catch (e) { + _logger.error('Dio error in joinGroupAccumulator', e); + throw _dioToException(e, 'Failed to join group accumulator'); + } + } + + Future fetchGroupAccumulatorMembers( + String accumulatorId, { + required int skip, + required int limit, + required GroupAccumulatorMemberSort sortBy, + }) async { + try { + final response = await dio.get( + '/group-accumulators/$accumulatorId/members', + queryParameters: { + 'skip': skip, + 'limit': limit, + 'sort_by': sortBy == GroupAccumulatorMemberSort.today ? 'today' : 'total', + }, + options: Options(extra: {'no_cache': true}), + ); + + if (response.statusCode == 200) { + return GroupAccumulatorMembersPageModel.fromJson( + response.data as Map, + ); + } + + throw _statusToException( + response.statusCode, + 'Failed to load group accumulator members', + ); + } on DioException catch (e) { + _logger.error('Dio error in fetchGroupAccumulatorMembers', e); + throw _dioToException(e, 'Failed to load group accumulator members'); + } + } + + Exception _statusToException(int? statusCode, String label) { + if (statusCode == 401) { + return const AuthenticationException('Unauthorized'); + } else if (statusCode == 404) { + return const NotFoundException('Group accumulator not found'); + } else if (statusCode == 429) { + return const RateLimitException('Too many requests'); + } else { + return ServerException('$label: $statusCode'); + } + } + + Exception _dioToException(DioException e, String label) { + if (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.sendTimeout || + e.type == DioExceptionType.receiveTimeout) { + return const NetworkException('Connection timeout'); + } else if (e.type == DioExceptionType.connectionError) { + return const NetworkException('No internet connection'); + } else if (e.response?.statusCode != null) { + return _statusToException(e.response!.statusCode, label); + } else { + return const NetworkException('Network error'); + } + } +} diff --git a/lib/features/group_profile/data/models/group_accumulator_model.dart b/lib/features/group_profile/data/models/group_accumulator_model.dart new file mode 100644 index 00000000..693af8cb --- /dev/null +++ b/lib/features/group_profile/data/models/group_accumulator_model.dart @@ -0,0 +1,259 @@ +import 'package:flutter_pecha/features/group_profile/domain/entities/group_accumulator.dart'; +import 'package:flutter_pecha/shared/domain/value_objects/responsive_image.dart'; + +class GroupAccumulatorModel { + final String id; + final String presetAccumulatorId; + final String groupId; + final String title; + final Map? imageJson; + final int targetCount; + final DateTime? startDate; + final DateTime? endDate; + final bool? isJoined; + final int memberCount; + final int totalCount; + final int totalTodayCount; + final Map? userJson; + + GroupAccumulatorModel({ + required this.id, + required this.presetAccumulatorId, + required this.groupId, + required this.title, + this.imageJson, + this.targetCount = 0, + this.startDate, + this.endDate, + this.isJoined, + this.memberCount = 0, + this.totalCount = 0, + this.totalTodayCount = 0, + this.userJson, + }); + + factory GroupAccumulatorModel.fromJson(Map json) { + return GroupAccumulatorModel( + id: json['id'] as String? ?? '', + presetAccumulatorId: json['preset_accumulator_id'] as String? ?? '', + groupId: json['group_id'] as String? ?? '', + title: json['title'] as String? ?? '', + imageJson: json['image'] as Map?, + targetCount: (json['target_count'] as num?)?.toInt() ?? 0, + startDate: _parseDate(json['start_date']), + endDate: _parseDate(json['end_date']), + isJoined: json['is_joined'] as bool?, + memberCount: (json['member_count'] as num?)?.toInt() ?? 0, + totalCount: (json['total_count'] as num?)?.toInt() ?? 0, + totalTodayCount: (json['total_today_count'] as num?)?.toInt() ?? 0, + userJson: json['user'] as Map?, + ); + } + + GroupAccumulator toEntity() { + return GroupAccumulator( + id: id, + presetAccumulatorId: presetAccumulatorId, + groupId: groupId, + title: title, + image: + imageJson != null ? ResponsiveImage.fromJson(imageJson) : null, + targetCount: targetCount, + startDate: startDate, + endDate: endDate, + isJoined: isJoined, + memberCount: memberCount, + ); + } + + GroupAccumulatorDetail toDetailEntity() { + return GroupAccumulatorDetail( + id: id, + presetAccumulatorId: presetAccumulatorId, + groupId: groupId, + title: title, + image: + imageJson != null ? ResponsiveImage.fromJson(imageJson) : null, + targetCount: targetCount, + startDate: startDate, + endDate: endDate, + isJoined: isJoined, + memberCount: memberCount, + totalCount: totalCount, + totalTodayCount: totalTodayCount, + user: + userJson != null + ? GroupAccumulatorUserContributionModel.fromJson( + userJson!, + ).toEntity() + : null, + ); + } + + static DateTime? _parseDate(Object? value) { + if (value is! String || value.isEmpty) return null; + return DateTime.tryParse(value); + } +} + +class GroupAccumulatorUserContributionModel { + final int totalCount; + final int todayCount; + final String username; + final String? imageUrl; + final String fullname; + + GroupAccumulatorUserContributionModel({ + this.totalCount = 0, + this.todayCount = 0, + this.username = '', + this.imageUrl, + this.fullname = '', + }); + + factory GroupAccumulatorUserContributionModel.fromJson( + Map json, + ) { + return GroupAccumulatorUserContributionModel( + totalCount: (json['total_count'] as num?)?.toInt() ?? 0, + todayCount: (json['today_count'] as num?)?.toInt() ?? 0, + username: json['username'] as String? ?? '', + imageUrl: json['image'] as String?, + fullname: json['fullname'] as String? ?? '', + ); + } + + GroupAccumulatorUserContribution toEntity() { + return GroupAccumulatorUserContribution( + totalCount: totalCount, + todayCount: todayCount, + username: username, + imageUrl: imageUrl, + fullname: fullname, + ); + } +} + +class GroupAccumulatorsPageModel { + final List accumulators; + final int total; + final int skip; + final int limit; + + GroupAccumulatorsPageModel({ + required this.accumulators, + required this.total, + required this.skip, + required this.limit, + }); + + factory GroupAccumulatorsPageModel.fromJson(Map json) { + return GroupAccumulatorsPageModel( + accumulators: + (json['accumulators'] as List?) + ?.whereType>() + .map(GroupAccumulatorModel.fromJson) + .toList() ?? + const [], + total: (json['total'] as num?)?.toInt() ?? 0, + skip: (json['skip'] as num?)?.toInt() ?? 0, + limit: (json['limit'] as num?)?.toInt() ?? 0, + ); + } + + GroupAccumulatorsPage toEntity() { + return GroupAccumulatorsPage( + accumulators: accumulators.map((item) => item.toEntity()).toList(), + total: total, + skip: skip, + limit: limit, + ); + } +} + +class GroupAccumulatorMemberModel { + final String userId; + final String username; + final String fullname; + final String? avatarUrl; + final DateTime? joinedAt; + final int totalCount; + final int todayCount; + + GroupAccumulatorMemberModel({ + required this.userId, + required this.username, + required this.fullname, + this.avatarUrl, + this.joinedAt, + this.totalCount = 0, + this.todayCount = 0, + }); + + factory GroupAccumulatorMemberModel.fromJson(Map json) { + return GroupAccumulatorMemberModel( + userId: json['user_id'] as String? ?? '', + username: json['username'] as String? ?? '', + fullname: json['fullname'] as String? ?? '', + avatarUrl: json['avatar_url'] as String?, + joinedAt: GroupAccumulatorModel._parseDate(json['joined_at']), + totalCount: (json['total_count'] as num?)?.toInt() ?? 0, + todayCount: (json['today_count'] as num?)?.toInt() ?? 0, + ); + } + + GroupAccumulatorMember toEntity() { + return GroupAccumulatorMember( + userId: userId, + username: username, + fullname: fullname, + avatarUrl: avatarUrl, + joinedAt: joinedAt, + totalCount: totalCount, + todayCount: todayCount, + ); + } +} + +class GroupAccumulatorMembersPageModel { + final List members; + final int memberCount; + final int total; + final int skip; + final int limit; + + GroupAccumulatorMembersPageModel({ + required this.members, + required this.memberCount, + required this.total, + required this.skip, + required this.limit, + }); + + factory GroupAccumulatorMembersPageModel.fromJson( + Map json, + ) { + return GroupAccumulatorMembersPageModel( + members: + (json['members'] as List?) + ?.whereType>() + .map(GroupAccumulatorMemberModel.fromJson) + .toList() ?? + const [], + memberCount: (json['member_count'] as num?)?.toInt() ?? 0, + total: (json['total'] as num?)?.toInt() ?? 0, + skip: (json['skip'] as num?)?.toInt() ?? 0, + limit: (json['limit'] as num?)?.toInt() ?? 0, + ); + } + + GroupAccumulatorMembersPage toEntity() { + return GroupAccumulatorMembersPage( + members: members.map((item) => item.toEntity()).toList(), + memberCount: memberCount, + total: total, + skip: skip, + limit: limit, + ); + } +} diff --git a/lib/features/group_profile/data/repositories/group_accumulator_repository_impl.dart b/lib/features/group_profile/data/repositories/group_accumulator_repository_impl.dart new file mode 100644 index 00000000..0b7e52ec --- /dev/null +++ b/lib/features/group_profile/data/repositories/group_accumulator_repository_impl.dart @@ -0,0 +1,114 @@ +import 'package:fpdart/fpdart.dart'; +import 'package:flutter_pecha/core/error/exceptions.dart'; +import 'package:flutter_pecha/core/error/failures.dart'; +import 'package:flutter_pecha/features/group_profile/data/datasource/group_accumulator_remote_datasource.dart'; +import 'package:flutter_pecha/features/group_profile/domain/entities/group_accumulator.dart'; +import 'package:flutter_pecha/features/group_profile/domain/repositories/group_accumulator_repository.dart'; + +class GroupAccumulatorRepositoryImpl + implements GroupAccumulatorRepositoryInterface { + final GroupAccumulatorRemoteDatasource remote; + + GroupAccumulatorRepositoryImpl({required this.remote}); + + @override + Future> getGroupAccumulators( + String groupId, { + required int skip, + required int limit, + }) async { + try { + final model = await remote.fetchGroupAccumulators( + groupId, + skip: skip, + limit: limit, + ); + return Right(model.toEntity()); + } on ServerException catch (e) { + return Left(ServerFailure(e.message)); + } on NetworkException catch (e) { + return Left(NetworkFailure(e.message)); + } on AuthenticationException catch (e) { + return Left(AuthenticationFailure(e.message)); + } on NotFoundException catch (e) { + return Left(NotFoundFailure(e.message)); + } on RateLimitException catch (e) { + return Left(RateLimitFailure(e.message)); + } catch (e) { + return Left(UnknownFailure('Failed to load group accumulators: $e')); + } + } + + @override + Future> getGroupAccumulator( + String accumulatorId, + ) async { + try { + final model = await remote.fetchGroupAccumulator(accumulatorId); + return Right(model.toDetailEntity()); + } on ServerException catch (e) { + return Left(ServerFailure(e.message)); + } on NetworkException catch (e) { + return Left(NetworkFailure(e.message)); + } on AuthenticationException catch (e) { + return Left(AuthenticationFailure(e.message)); + } on NotFoundException catch (e) { + return Left(NotFoundFailure(e.message)); + } on RateLimitException catch (e) { + return Left(RateLimitFailure(e.message)); + } catch (e) { + return Left(UnknownFailure('Failed to load group accumulator: $e')); + } + } + + @override + Future> joinGroupAccumulator( + String accumulatorId, + ) async { + try { + await remote.joinGroupAccumulator(accumulatorId); + return const Right(null); + } on ServerException catch (e) { + return Left(ServerFailure(e.message)); + } on NetworkException catch (e) { + return Left(NetworkFailure(e.message)); + } on AuthenticationException catch (e) { + return Left(AuthenticationFailure(e.message)); + } catch (e) { + return Left(UnknownFailure('Failed to join group accumulator: $e')); + } + } + + @override + Future> + getGroupAccumulatorMembers( + String accumulatorId, { + required int skip, + required int limit, + required GroupAccumulatorMemberSort sortBy, + }) async { + try { + final model = await remote.fetchGroupAccumulatorMembers( + accumulatorId, + skip: skip, + limit: limit, + sortBy: sortBy, + ); + return Right(model.toEntity()); + } on ServerException catch (e) { + return Left(ServerFailure(e.message)); + } on NetworkException catch (e) { + return Left(NetworkFailure(e.message)); + } on AuthenticationException catch (e) { + return Left(AuthenticationFailure(e.message)); + } on NotFoundException catch (e) { + return Left(NotFoundFailure(e.message)); + } on RateLimitException catch (e) { + return Left(RateLimitFailure(e.message)); + } catch (e) { + return Left( + UnknownFailure('Failed to load group accumulator members: $e'), + ); + } + } +} diff --git a/lib/features/group_profile/domain/entities/group_accumulator.dart b/lib/features/group_profile/domain/entities/group_accumulator.dart new file mode 100644 index 00000000..e6241e82 --- /dev/null +++ b/lib/features/group_profile/domain/entities/group_accumulator.dart @@ -0,0 +1,173 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_pecha/shared/domain/value_objects/responsive_image.dart'; + +class GroupAccumulator extends Equatable { + final String id; + final String presetAccumulatorId; + final String groupId; + final String title; + final ResponsiveImage? image; + final int targetCount; + final DateTime? startDate; + final DateTime? endDate; + final bool? isJoined; + final int memberCount; + + const GroupAccumulator({ + required this.id, + required this.presetAccumulatorId, + required this.groupId, + required this.title, + this.image, + this.targetCount = 0, + this.startDate, + this.endDate, + this.isJoined, + this.memberCount = 0, + }); + + bool get hasJoined => isJoined == true; + + @override + List get props => [ + id, + presetAccumulatorId, + groupId, + title, + image, + targetCount, + startDate, + endDate, + isJoined, + memberCount, + ]; +} + +class GroupAccumulatorDetail extends GroupAccumulator { + final int totalCount; + final int totalTodayCount; + final GroupAccumulatorUserContribution? user; + + const GroupAccumulatorDetail({ + required super.id, + required super.presetAccumulatorId, + required super.groupId, + required super.title, + super.image, + super.targetCount = 0, + super.startDate, + super.endDate, + super.isJoined, + super.memberCount = 0, + this.totalCount = 0, + this.totalTodayCount = 0, + this.user, + }); + + double get progressFraction { + if (targetCount <= 0) return 0; + return (totalCount / targetCount).clamp(0.0, 1.0); + } + + int get progressPercent => (progressFraction * 100).round(); +} + +class GroupAccumulatorUserContribution extends Equatable { + final int totalCount; + final int todayCount; + final String username; + final String? imageUrl; + final String fullname; + + const GroupAccumulatorUserContribution({ + this.totalCount = 0, + this.todayCount = 0, + this.username = '', + this.imageUrl, + this.fullname = '', + }); + + String get displayName => + fullname.trim().isNotEmpty ? fullname.trim() : username; + + @override + List get props => [ + totalCount, + todayCount, + username, + imageUrl, + fullname, + ]; +} + +class GroupAccumulatorMember extends Equatable { + final String userId; + final String username; + final String fullname; + final String? avatarUrl; + final DateTime? joinedAt; + final int totalCount; + final int todayCount; + + const GroupAccumulatorMember({ + required this.userId, + required this.username, + required this.fullname, + this.avatarUrl, + this.joinedAt, + this.totalCount = 0, + this.todayCount = 0, + }); + + String get displayName => + fullname.trim().isNotEmpty ? fullname.trim() : username; + + @override + List get props => [ + userId, + username, + fullname, + avatarUrl, + joinedAt, + totalCount, + todayCount, + ]; +} + +enum GroupAccumulatorMemberSort { today, total } + +class GroupAccumulatorsPage extends Equatable { + final List accumulators; + final int total; + final int skip; + final int limit; + + const GroupAccumulatorsPage({ + required this.accumulators, + required this.total, + required this.skip, + required this.limit, + }); + + @override + List get props => [accumulators, total, skip, limit]; +} + +class GroupAccumulatorMembersPage extends Equatable { + final List members; + final int memberCount; + final int total; + final int skip; + final int limit; + + const GroupAccumulatorMembersPage({ + required this.members, + required this.memberCount, + required this.total, + required this.skip, + required this.limit, + }); + + @override + List get props => [members, memberCount, total, skip, limit]; +} diff --git a/lib/features/group_profile/domain/repositories/group_accumulator_repository.dart b/lib/features/group_profile/domain/repositories/group_accumulator_repository.dart new file mode 100644 index 00000000..eac74952 --- /dev/null +++ b/lib/features/group_profile/domain/repositories/group_accumulator_repository.dart @@ -0,0 +1,24 @@ +import 'package:fpdart/fpdart.dart'; +import 'package:flutter_pecha/core/error/failures.dart'; +import 'package:flutter_pecha/features/group_profile/domain/entities/group_accumulator.dart'; + +abstract class GroupAccumulatorRepositoryInterface { + Future> getGroupAccumulators( + String groupId, { + required int skip, + required int limit, + }); + + Future> getGroupAccumulator( + String accumulatorId, + ); + + Future> joinGroupAccumulator(String accumulatorId); + + Future> getGroupAccumulatorMembers( + String accumulatorId, { + required int skip, + required int limit, + required GroupAccumulatorMemberSort sortBy, + }); +} diff --git a/lib/features/group_profile/presentation/providers/group_accumulator_providers.dart b/lib/features/group_profile/presentation/providers/group_accumulator_providers.dart new file mode 100644 index 00000000..2fab8c29 --- /dev/null +++ b/lib/features/group_profile/presentation/providers/group_accumulator_providers.dart @@ -0,0 +1,189 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_pecha/core/di/core_providers.dart'; +import 'package:flutter_pecha/core/error/failures.dart'; +import 'package:flutter_pecha/features/auth/presentation/providers/state_providers.dart'; +import 'package:flutter_pecha/features/group_profile/data/datasource/group_accumulator_remote_datasource.dart'; +import 'package:flutter_pecha/features/group_profile/data/repositories/group_accumulator_repository_impl.dart'; +import 'package:flutter_pecha/features/group_profile/domain/entities/group_accumulator.dart'; +import 'package:flutter_pecha/features/group_profile/domain/repositories/group_accumulator_repository.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fpdart/fpdart.dart'; + +final groupAccumulatorRemoteDatasourceProvider = + Provider((ref) { + return GroupAccumulatorRemoteDatasource(dio: ref.watch(dioProvider)); + }); + +final groupAccumulatorRepositoryProvider = + Provider((ref) { + return GroupAccumulatorRepositoryImpl( + remote: ref.watch(groupAccumulatorRemoteDatasourceProvider), + ); + }); + +final groupAccumulatorsProvider = FutureProvider.autoDispose + .family, String>(( + ref, + groupId, + ) async { + ref.watch(authProvider); + final repository = ref.watch(groupAccumulatorRepositoryProvider); + return repository.getGroupAccumulators(groupId, skip: 0, limit: 20); + }); + +final groupAccumulatorDetailProvider = FutureProvider.autoDispose + .family, String>(( + ref, + accumulatorId, + ) async { + ref.watch(authProvider); + final repository = ref.watch(groupAccumulatorRepositoryProvider); + return repository.getGroupAccumulator(accumulatorId); + }); + +@immutable +class GroupAccumulatorMembersKey { + final String accumulatorId; + final GroupAccumulatorMemberSort sortBy; + + const GroupAccumulatorMembersKey({ + required this.accumulatorId, + required this.sortBy, + }); + + @override + bool operator ==(Object other) { + return other is GroupAccumulatorMembersKey && + other.accumulatorId == accumulatorId && + other.sortBy == sortBy; + } + + @override + int get hashCode => Object.hash(accumulatorId, sortBy); +} + +class GroupAccumulatorMembersState { + final List members; + final int total; + final bool isLoading; + final bool isLoadingMore; + final Failure? error; + + const GroupAccumulatorMembersState({ + this.members = const [], + this.total = 0, + this.isLoading = false, + this.isLoadingMore = false, + this.error, + }); + + bool get hasMore => members.length < total; + + GroupAccumulatorMembersState copyWith({ + List? members, + int? total, + bool? isLoading, + bool? isLoadingMore, + Failure? error, + bool clearError = false, + }) { + return GroupAccumulatorMembersState( + members: members ?? this.members, + total: total ?? this.total, + isLoading: isLoading ?? this.isLoading, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, + error: clearError ? null : (error ?? this.error), + ); + } +} + +class GroupAccumulatorMembersNotifier + extends StateNotifier { + GroupAccumulatorMembersNotifier({ + required GroupAccumulatorRepositoryInterface repository, + required this.accumulatorId, + required this.sortBy, + }) : _repository = repository, + super(const GroupAccumulatorMembersState()); + + final GroupAccumulatorRepositoryInterface _repository; + final String accumulatorId; + final GroupAccumulatorMemberSort sortBy; + static const _pageSize = 20; + bool _hasLoaded = false; + + Future loadInitial({bool force = false}) async { + if (_hasLoaded && !force) return; + _hasLoaded = true; + state = state.copyWith(isLoading: true, clearError: true); + + final result = await _repository.getGroupAccumulatorMembers( + accumulatorId, + skip: 0, + limit: _pageSize, + sortBy: sortBy, + ); + + result.fold( + (failure) => state = state.copyWith(isLoading: false, error: failure), + (page) => state = state.copyWith( + isLoading: false, + members: page.members, + total: page.total, + ), + ); + } + + Future loadMore() async { + if (state.isLoading || state.isLoadingMore || !state.hasMore) return; + + state = state.copyWith(isLoadingMore: true, clearError: true); + final result = await _repository.getGroupAccumulatorMembers( + accumulatorId, + skip: state.members.length, + limit: _pageSize, + sortBy: sortBy, + ); + + result.fold( + (failure) => state = state.copyWith(isLoadingMore: false, error: failure), + (page) => state = state.copyWith( + isLoadingMore: false, + members: [...state.members, ...page.members], + total: page.total, + ), + ); + } + + Future retry() async { + _hasLoaded = false; + await loadInitial(force: true); + } +} + +final groupAccumulatorMembersProvider = StateNotifierProvider.autoDispose + .family< + GroupAccumulatorMembersNotifier, + GroupAccumulatorMembersState, + GroupAccumulatorMembersKey + >((ref, key) { + return GroupAccumulatorMembersNotifier( + repository: ref.watch(groupAccumulatorRepositoryProvider), + accumulatorId: key.accumulatorId, + sortBy: key.sortBy, + ); + }); + +Future joinGroupAccumulator({ + required WidgetRef ref, + required String accumulatorId, + required String groupId, +}) async { + final repository = ref.read(groupAccumulatorRepositoryProvider); + final result = await repository.joinGroupAccumulator(accumulatorId); + return result.fold((_) => false, (_) { + ref.invalidate(groupAccumulatorsProvider(groupId)); + ref.invalidate(groupAccumulatorDetailProvider(accumulatorId)); + return true; + }); +} diff --git a/lib/features/group_profile/presentation/screens/group_accumulator_screen.dart b/lib/features/group_profile/presentation/screens/group_accumulator_screen.dart new file mode 100644 index 00000000..76282eb6 --- /dev/null +++ b/lib/features/group_profile/presentation/screens/group_accumulator_screen.dart @@ -0,0 +1,731 @@ +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/widgets/cached_network_image_widget.dart'; +import 'package:flutter_pecha/core/widgets/error_state_widget.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:flutter_pecha/features/group_profile/presentation/providers/group_accumulator_providers.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; + +class GroupAccumulatorScreen extends ConsumerStatefulWidget { + final String accumulatorId; + final String? groupTitle; + + const GroupAccumulatorScreen({ + super.key, + required this.accumulatorId, + this.groupTitle, + }); + + @override + ConsumerState createState() => + _GroupAccumulatorScreenState(); +} + +class _GroupAccumulatorScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + late final TabController _tabController; + GroupAccumulatorMemberSort _memberSort = GroupAccumulatorMemberSort.total; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final detailAsync = ref.watch( + groupAccumulatorDetailProvider(widget.accumulatorId), + ); + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Scaffold( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + body: SafeArea( + bottom: false, + child: Column( + children: [ + _buildAppBar(context), + Expanded( + child: detailAsync.when( + data: + (either) => either.fold( + (failure) => Center( + child: ErrorStateWidget( + error: failure, + onRetry: + () => ref.invalidate( + groupAccumulatorDetailProvider( + widget.accumulatorId, + ), + ), + ), + ), + (detail) => _buildContent(context, detail, isDark), + ), + loading: () => const Center(child: CircularProgressIndicator()), + error: + (error, _) => Center( + child: ErrorStateWidget( + error: error, + onRetry: + () => ref.invalidate( + groupAccumulatorDetailProvider( + widget.accumulatorId, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildAppBar(BuildContext context) { + final title = widget.groupTitle?.trim(); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + children: [ + IconButton( + icon: const Icon(AppAssets.arrowLeft), + onPressed: () => context.pop(), + ), + Expanded( + child: Text( + title != null && title.isNotEmpty ? title : '', + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 48, height: 48), + ], + ), + ); + } + + Widget _buildContent( + BuildContext context, + GroupAccumulatorDetail detail, + bool isDark, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: _AccumulatorHeroCard(detail: detail, isDark: isDark), + ), + TabBar( + controller: _tabController, + labelColor: isDark ? AppColors.surfaceWhite : AppColors.textPrimary, + unselectedLabelColor: + isDark ? AppColors.textTertiaryDark : AppColors.textSecondary, + indicatorColor: + isDark ? AppColors.surfaceWhite : AppColors.textPrimary, + indicatorWeight: 2, + dividerHeight: 1, + dividerColor: isDark ? AppColors.cardBorderDark : AppColors.grey300, + tabs: [ + Tab(text: context.l10n.group_accumulator_leaderboard), + Tab(text: context.l10n.group_accumulator_my_contributions), + ], + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + _LeaderboardTab( + accumulatorId: widget.accumulatorId, + sort: _memberSort, + isDark: isDark, + onSortChanged: (sort) { + setState(() => _memberSort = sort); + }, + ), + _MyContributionsTab(detail: detail, isDark: isDark), + ], + ), + ), + ], + ); + } +} + +class _MyContributionsTab extends StatefulWidget { + final GroupAccumulatorDetail detail; + final bool isDark; + + const _MyContributionsTab({required this.detail, required this.isDark}); + + @override + State<_MyContributionsTab> createState() => _MyContributionsTabState(); +} + +class _MyContributionsTabState extends State<_MyContributionsTab> { + GroupAccumulatorMemberSort _sort = GroupAccumulatorMemberSort.total; + + @override + Widget build(BuildContext context) { + final user = widget.detail.user; + final secondaryColor = + widget.isDark ? AppColors.textTertiaryDark : AppColors.textSecondary; + final locale = Localizations.localeOf(context).toString(); + final numberFormat = NumberFormat.decimalPattern(locale); + + if (user == null) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text( + context.l10n.group_accumulator_contributions_empty, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 15, color: secondaryColor), + ), + ), + ); + } + + final count = + _sort == GroupAccumulatorMemberSort.today + ? user.todayCount + : user.totalCount; + + return ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), + children: [ + Row( + children: [ + Text( + context.l10n.group_accumulator_recited, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: + widget.isDark + ? AppColors.textPrimaryDark + : AppColors.textPrimary, + ), + ), + const Spacer(), + _SortToggle( + sort: _sort, + isDark: widget.isDark, + onChanged: (sort) => setState(() => _sort = sort), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + _MemberAvatar(avatarUrl: user.imageUrl, isDark: widget.isDark), + const SizedBox(width: 12), + Expanded( + child: Text( + user.displayName, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: + widget.isDark + ? AppColors.textPrimaryDark + : AppColors.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + numberFormat.format(count), + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: + widget.isDark + ? AppColors.textPrimaryDark + : AppColors.textPrimary, + ), + ), + ], + ), + ], + ); + } +} + +class _AccumulatorHeroCard extends StatelessWidget { + final GroupAccumulatorDetail detail; + final bool isDark; + + const _AccumulatorHeroCard({required this.detail, required this.isDark}); + + @override + Widget build(BuildContext context) { + final locale = Localizations.localeOf(context).toString(); + final numberFormat = NumberFormat.decimalPattern(locale); + final progressText = + '${numberFormat.format(detail.totalCount)} / ${numberFormat.format(detail.targetCount)}'; + + return GestureDetector( + onTap: () { + final presetId = detail.presetAccumulatorId; + if (presetId.isEmpty) return; + context.push('/mala', extra: {'presetId': presetId}); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: SizedBox( + height: 220, + width: double.infinity, + child: Stack( + fit: StackFit.expand, + children: [ + if (detail.image != null && !detail.image!.isEmpty) + ResponsiveCoverImage(image: detail.image, fit: BoxFit.cover) + else + ColoredBox( + color: + isDark ? AppColors.surfaceVariantDark : AppColors.grey100, + ), + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0.05), + Colors.black.withValues(alpha: 0.75), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.group_accumulator_participants( + detail.memberCount, + ), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + const Spacer(), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Text( + detail.title, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: Colors.white, + height: 1.2, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 12), + Container( + width: 40, + height: 40, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: const Icon( + AppAssets.caretRight, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: Text( + progressText, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + Text( + '${detail.progressPercent}%', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ], + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + value: detail.progressFraction, + minHeight: 6, + backgroundColor: Colors.white.withValues(alpha: 0.35), + color: AppColors.primary, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +class _LeaderboardTab extends ConsumerStatefulWidget { + final String accumulatorId; + final GroupAccumulatorMemberSort sort; + final bool isDark; + final ValueChanged onSortChanged; + + const _LeaderboardTab({ + required this.accumulatorId, + required this.sort, + required this.isDark, + required this.onSortChanged, + }); + + @override + ConsumerState<_LeaderboardTab> createState() => _LeaderboardTabState(); +} + +class _LeaderboardTabState extends ConsumerState<_LeaderboardTab> { + final ScrollController _scrollController = ScrollController(); + bool _hasRequestedInitialLoad = false; + + GroupAccumulatorMembersKey get _membersKey => GroupAccumulatorMembersKey( + accumulatorId: widget.accumulatorId, + sortBy: widget.sort, + ); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + _loadInitialIfNeeded(); + } + + @override + void didUpdateWidget(covariant _LeaderboardTab oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.sort != widget.sort) { + _hasRequestedInitialLoad = false; + _loadInitialIfNeeded(force: true); + } + } + + @override + void dispose() { + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + super.dispose(); + } + + void _loadInitialIfNeeded({bool force = false}) { + if (_hasRequestedInitialLoad && !force) return; + _hasRequestedInitialLoad = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ref + .read(groupAccumulatorMembersProvider(_membersKey).notifier) + .loadInitial(force: force); + }); + } + + void _onScroll() { + if (!_scrollController.hasClients) return; + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent - 200) { + ref + .read(groupAccumulatorMembersProvider(_membersKey).notifier) + .loadMore(); + } + } + + @override + Widget build(BuildContext context) { + final membersState = ref.watch( + groupAccumulatorMembersProvider(_membersKey), + ); + final secondaryColor = + widget.isDark ? AppColors.textTertiaryDark : AppColors.textSecondary; + final locale = Localizations.localeOf(context).toString(); + final numberFormat = NumberFormat.decimalPattern(locale); + + if (membersState.isLoading && membersState.members.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + if (membersState.error != null && membersState.members.isEmpty) { + return Center( + child: ErrorStateWidget( + error: membersState.error!, + onRetry: + () => + ref + .read( + groupAccumulatorMembersProvider(_membersKey).notifier, + ) + .retry(), + ), + ); + } + + return ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), + itemCount: + 1 + + membersState.members.length + + (membersState.isLoadingMore ? 1 : 0), + itemBuilder: (context, index) { + if (index == 0) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + children: [ + Text( + context.l10n.group_accumulator_recited, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: + widget.isDark + ? AppColors.textPrimaryDark + : AppColors.textPrimary, + ), + ), + const Spacer(), + _SortToggle( + sort: widget.sort, + isDark: widget.isDark, + onChanged: widget.onSortChanged, + ), + ], + ), + ); + } + + final memberIndex = index - 1; + if (memberIndex < membersState.members.length) { + final member = membersState.members[memberIndex]; + final count = + widget.sort == GroupAccumulatorMemberSort.today + ? member.todayCount + : member.totalCount; + + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + children: [ + SizedBox( + width: 24, + child: Text( + '${memberIndex + 1}', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: secondaryColor, + ), + ), + ), + const SizedBox(width: 12), + _MemberAvatar( + avatarUrl: member.avatarUrl, + isDark: widget.isDark, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + member.displayName, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: + widget.isDark + ? AppColors.textPrimaryDark + : AppColors.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + numberFormat.format(count), + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: + widget.isDark + ? AppColors.textPrimaryDark + : AppColors.textPrimary, + ), + ), + ], + ), + ); + } + + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center(child: CircularProgressIndicator()), + ); + }, + ); + } +} + +class _SortToggle extends StatelessWidget { + final GroupAccumulatorMemberSort sort; + final bool isDark; + final ValueChanged onChanged; + + const _SortToggle({ + required this.sort, + required this.isDark, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: isDark ? AppColors.cardBorderDark : AppColors.grey300, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _SortToggleChip( + label: context.l10n.home_today, + isSelected: sort == GroupAccumulatorMemberSort.today, + isDark: isDark, + onTap: () => onChanged(GroupAccumulatorMemberSort.today), + ), + _SortToggleChip( + label: context.l10n.group_accumulator_total, + isSelected: sort == GroupAccumulatorMemberSort.total, + isDark: isDark, + onTap: () => onChanged(GroupAccumulatorMemberSort.total), + ), + ], + ), + ); + } +} + +class _SortToggleChip extends StatelessWidget { + final String label; + final bool isSelected; + final bool isDark; + final VoidCallback onTap; + + const _SortToggleChip({ + required this.label, + required this.isSelected, + required this.isDark, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: + isSelected + ? (isDark ? AppColors.surfaceWhite : AppColors.textPrimary) + : Colors.transparent, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: + isSelected + ? (isDark ? AppColors.textPrimary : AppColors.surfaceWhite) + : (isDark + ? AppColors.textTertiaryDark + : AppColors.textSecondary), + ), + ), + ), + ); + } +} + +class _MemberAvatar extends StatelessWidget { + final String? avatarUrl; + final bool isDark; + + const _MemberAvatar({required this.avatarUrl, required this.isDark}); + + @override + Widget build(BuildContext context) { + return ClipOval( + child: SizedBox( + width: 40, + height: 40, + child: + avatarUrl != null && avatarUrl!.isNotEmpty + ? CachedNetworkImageWidget( + imageUrl: avatarUrl!, + fit: BoxFit.cover, + errorWidget: _placeholder(), + ) + : _placeholder(), + ), + ); + } + + Widget _placeholder() { + return ColoredBox( + color: isDark ? AppColors.surfaceVariantDark : AppColors.grey100, + child: Icon( + AppAssets.profile, + color: isDark ? AppColors.grey500 : AppColors.grey600, + ), + ); + } +} diff --git a/lib/features/group_profile/presentation/widgets/group_accumulator_card.dart b/lib/features/group_profile/presentation/widgets/group_accumulator_card.dart new file mode 100644 index 00000000..80cd3050 --- /dev/null +++ b/lib/features/group_profile/presentation/widgets/group_accumulator_card.dart @@ -0,0 +1,176 @@ +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/widgets/responsive_cover_image.dart'; +import 'package:flutter_pecha/features/group_profile/domain/entities/group_accumulator.dart'; +import 'package:intl/intl.dart'; + +class GroupAccumulatorCard extends StatelessWidget { + final GroupAccumulator accumulator; + final bool isDark; + final double? lineHeight; + final bool isJoining; + final VoidCallback? onTap; + final VoidCallback? onJoinTap; + + const GroupAccumulatorCard({ + super.key, + required this.accumulator, + required this.isDark, + this.lineHeight, + this.isJoining = false, + this.onTap, + this.onJoinTap, + }); + + @override + Widget build(BuildContext context) { + final dateRange = _formatDateRange(accumulator); + final secondaryColor = + isDark ? AppColors.textTertiaryDark : AppColors.textSecondary; + final cardColor = + isDark ? AppColors.cardBackgroundDark : AppColors.surfaceWhite; + final showJoinOverlay = !accumulator.hasJoined; + + return Material( + color: cardColor, + elevation: isDark ? 0 : 1, + shadowColor: Colors.black.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(16), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: showJoinOverlay || isJoining ? null : onTap, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 140, + width: double.infinity, + child: Stack( + fit: StackFit.expand, + children: [ + accumulator.image != null && !accumulator.image!.isEmpty + ? ResponsiveCoverImage( + image: accumulator.image, + fit: BoxFit.cover, + ) + : ColoredBox( + color: + isDark + ? AppColors.surfaceVariantDark + : AppColors.grey100, + child: Icon( + AppAssets.bookOpenText, + size: 40, + color: isDark ? AppColors.grey500 : AppColors.grey600, + ), + ), + if (showJoinOverlay) + Container( + color: Colors.black.withValues(alpha: 0.55), + alignment: Alignment.center, + child: + isJoining + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onJoinTap, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + context.l10n.group_join_to_contribute, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + accumulator.title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + height: lineHeight, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (dateRange != null || accumulator.memberCount > 0) ...[ + const SizedBox(height: 4), + Row( + children: [ + if (dateRange != null) + Expanded( + child: Text( + dateRange, + style: TextStyle( + fontSize: 14, + color: secondaryColor, + height: lineHeight, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (accumulator.memberCount > 0) ...[ + Icon( + AppAssets.usersThree, + size: 16, + color: secondaryColor, + ), + const SizedBox(width: 4), + Text( + '+${accumulator.memberCount}', + style: TextStyle( + fontSize: 14, + color: secondaryColor, + height: lineHeight, + ), + ), + ], + ], + ), + ], + ], + ), + ), + ], + ), + ), + ); + } + + 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())}'; + } +} 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 03189dbe..8f6e8a5c 100644 --- a/lib/features/group_profile/presentation/widgets/group_profile_body.dart +++ b/lib/features/group_profile/presentation/widgets/group_profile_body.dart @@ -6,8 +6,11 @@ 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/auth/presentation/widgets/login_drawer.dart'; +import 'package:flutter_pecha/features/group_profile/domain/entities/group_accumulator.dart'; import 'package:flutter_pecha/features/group_profile/domain/entities/group_profile.dart'; +import 'package:flutter_pecha/features/group_profile/presentation/providers/group_accumulator_providers.dart'; import 'package:flutter_pecha/features/group_profile/presentation/providers/group_profile_providers.dart'; +import 'package:flutter_pecha/features/group_profile/presentation/widgets/group_accumulator_card.dart'; import 'package:flutter_pecha/features/group_profile/presentation/widgets/group_profile_links_drawer.dart'; import 'package:flutter_pecha/features/group_profile/presentation/widgets/group_profile_members_tab.dart'; import 'package:flutter_pecha/features/home/presentation/providers/series_enrollment_provider.dart'; @@ -38,6 +41,7 @@ class _GroupProfileBodyState extends ConsumerState with SingleTickerProviderStateMixin { TabController? _tabController; String? _enrollingSeriesId; + String? _joiningAccumulatorId; final Set _localGroupEnrolledSeriesIds = {}; bool _isCommunityGroup(GroupProfile profile) => !profile.groupType.isPage; @@ -362,29 +366,142 @@ class _GroupProfileBodyState extends ConsumerState bool isDark, double? lineHeight, ) { - if (profile.series.isEmpty) { - return const SizedBox.shrink(); - } + final accumulatorsAsync = ref.watch(groupAccumulatorsProvider(profile.id)); + final series = profile.series; + + return accumulatorsAsync.when( + data: (either) { + final accumulators = either.fold( + (_) => [], + (page) => page.accumulators, + ); - return ListView.builder( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), - itemCount: profile.series.length, - itemBuilder: (context, index) { - return Padding( - padding: EdgeInsets.only( - bottom: index == profile.series.length - 1 ? 0 : 16, - ), - child: _buildSeriesCard( - profile, - profile.series[index], - isDark, - lineHeight, - ), + if (series.isEmpty && accumulators.isEmpty) { + return const SizedBox.shrink(); + } + + final itemCount = series.length + accumulators.length; + return ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), + itemCount: itemCount, + itemBuilder: (context, index) { + final isLast = index == itemCount - 1; + if (index < series.length) { + return Padding( + padding: EdgeInsets.only(bottom: isLast ? 0 : 16), + child: _buildSeriesCard( + profile, + series[index], + isDark, + lineHeight, + ), + ); + } + + final accumulator = accumulators[index - series.length]; + return Padding( + padding: EdgeInsets.only(bottom: isLast ? 0 : 16), + child: GroupAccumulatorCard( + accumulator: accumulator, + isDark: isDark, + lineHeight: lineHeight, + isJoining: _joiningAccumulatorId == accumulator.id, + onTap: () => _navigateToAccumulatorDetail(accumulator.id), + onJoinTap: () => _onJoinAccumulatorTap(profile, accumulator), + ), + ); + }, + ); + }, + loading: () { + if (series.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + return ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), + itemCount: series.length, + itemBuilder: (context, index) { + return Padding( + padding: EdgeInsets.only( + bottom: index == series.length - 1 ? 0 : 16, + ), + child: _buildSeriesCard( + profile, + series[index], + isDark, + lineHeight, + ), + ); + }, + ); + }, + error: (_, __) { + if (series.isEmpty) { + return const SizedBox.shrink(); + } + + return ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), + itemCount: series.length, + itemBuilder: (context, index) { + return Padding( + padding: EdgeInsets.only( + bottom: index == series.length - 1 ? 0 : 16, + ), + child: _buildSeriesCard( + profile, + series[index], + isDark, + lineHeight, + ), + ); + }, ); }, ); } + void _navigateToAccumulatorDetail(String accumulatorId) { + context.push( + '/home/group-accumulator/$accumulatorId', + extra: {'groupTitle': _resolveProfile().title}, + ); + } + + Future _onJoinAccumulatorTap( + GroupProfile profile, + GroupAccumulator accumulator, + ) async { + final authState = ref.read(authProvider); + if (authState.isGuest || !authState.isLoggedIn) { + LoginDrawer.show(context, ref); + return; + } + + setState(() => _joiningAccumulatorId = accumulator.id); + final ok = await joinGroupAccumulator( + ref: ref, + accumulatorId: accumulator.id, + groupId: profile.id, + ); + + if (!mounted) return; + setState(() => _joiningAccumulatorId = null); + + if (ok) { + _navigateToAccumulatorDetail(accumulator.id); + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.group_accumulator_join_error), + backgroundColor: Colors.red, + ), + ); + } + Widget _buildProfileBanner(GroupProfile profile, bool isDark) { if (!_hasBanner(profile)) { return const SizedBox.shrink(); From e0722b65466c41326792a38c836f1dde0fe7ed0d Mon Sep 17 00:00:00 2001 From: tenzin dhakar Date: Wed, 1 Jul 2026 16:20:01 +0530 Subject: [PATCH 13/27] feat: add home deep link generation and enhance verse sharing functionality with share text --- .../deep_linking/deep_link_url_builder.dart | 4 +++ .../widgets/verse_share_sheet.dart | 29 ++++++++++++++----- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/lib/core/deep_linking/deep_link_url_builder.dart b/lib/core/deep_linking/deep_link_url_builder.dart index 1e2f9e9f..d26267de 100644 --- a/lib/core/deep_linking/deep_link_url_builder.dart +++ b/lib/core/deep_linking/deep_link_url_builder.dart @@ -3,6 +3,10 @@ class DeepLinkUrlBuilder { static const String _host = 'webuddhist.com'; + static Uri homeLink() { + return Uri(scheme: 'https', host: _host, pathSegments: ['open']); + } + /// Returns a link that opens the reader at the very first segment — /// i.e. no segment/lang params so the app starts from the top. static Uri readerLink({required String textId}) { diff --git a/lib/features/home/presentation/widgets/verse_share_sheet.dart b/lib/features/home/presentation/widgets/verse_share_sheet.dart index 0e888978..c397c0ea 100644 --- a/lib/features/home/presentation/widgets/verse_share_sheet.dart +++ b/lib/features/home/presentation/widgets/verse_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'; @@ -82,13 +83,16 @@ Future shareVerseOfDayQuote( }) async { File? tempFile; try { - final Uint8List? imageBytes = - screenshotController != null - ? await screenshotController.capture( - delay: const Duration(milliseconds: 100), - pixelRatio: 3.0, - ) - : await _captureVerseShareImage(context, verseOfDay); + final Uint8List? imageBytes; + if (screenshotController != null) { + imageBytes = await screenshotController.capture( + delay: const Duration(milliseconds: 100), + pixelRatio: 3.0, + ); + } else { + if (!context.mounted) return; + imageBytes = await _captureVerseShareImage(context, verseOfDay); + } if (imageBytes == null || !context.mounted) return; @@ -104,10 +108,12 @@ Future shareVerseOfDayQuote( context: context, globalKey: shareOriginKey, ); + final shareText = _verseOfDayShareText(); await SharePlus.instance.share( ShareParams( files: [XFile(tempFile.path)], + text: shareText, sharePositionOrigin: sharePositionOrigin, ), ); @@ -130,6 +136,14 @@ Future shareVerseOfDayQuote( } } +String _verseOfDayShareText() { + 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'; +} + Future _captureVerseShareImage( BuildContext context, VerseOfDay verseOfDay, @@ -212,7 +226,6 @@ class _VerseShareSheetState extends State { final languageCode = Localizations.localeOf(context).languageCode; final locale = Localizations.localeOf(context); final shareLabelFontSize = getLocalizedFontSize(AppTextSize.body); - final isTibetan = AppFontConfig.isTibetanLanguage(languageCode); return Container( decoration: BoxDecoration( From 8540cbb1eeaa3db7067530dc6fd7074091291702 Mon Sep 17 00:00:00 2001 From: tentamdin Date: Wed, 1 Jul 2026 16:25:35 +0530 Subject: [PATCH 14/27] refactor: enhance Mala screen layout with improved padding and visual structure --- .../presentation/screens/mala_screen.dart | 190 ++++++++++++------ .../presentation/widgets/mantra_switcher.dart | 17 +- 2 files changed, 140 insertions(+), 67 deletions(-) diff --git a/lib/features/mala/presentation/screens/mala_screen.dart b/lib/features/mala/presentation/screens/mala_screen.dart index dcadbe7b..31d31f11 100644 --- a/lib/features/mala/presentation/screens/mala_screen.dart +++ b/lib/features/mala/presentation/screens/mala_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_pecha/core/analytics/analytics_events.dart'; import 'package:flutter_pecha/core/analytics/analytics_providers.dart'; import 'package:flutter_pecha/core/core.dart'; +import 'package:flutter_pecha/core/theme/app_colors.dart'; import 'package:flutter_pecha/core/extensions/context_ext.dart'; import 'package:flutter_pecha/features/mala/domain/entities/mantra.dart'; import 'package:flutter_pecha/features/mala/presentation/providers/mala_providers.dart'; @@ -136,75 +137,78 @@ class _MalaScreenState extends ConsumerState { ), Expanded( child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Mantra + transliteration switcher: 40% of the space below - // the header. An infinite looping carousel — swipe or tap the - // chevrons; the text is centered between them. Expanded( - flex: 40, - child: MantraSwitcher( - mantras: mantras, - language: language, - tibetanFontFamily: AppConfig.tibetanContentFont, - index: _index, - onIndexChanged: (next) => _switch(mantras, next), + flex: 36, + child: DecoratedBox( + decoration: BoxDecoration( + color: AppColors.surfaceWhite, + borderRadius: BorderRadius.circular(16), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: MantraSwitcher( + mantras: mantras, + language: language, + tibetanFontFamily: AppConfig.tibetanContentFont, + index: _index, + onIndexChanged: (next) => _switch(mantras, next), + ), + ), ), ), - // Counter + bead arc: the remaining 60%. + const SizedBox(height: 16), + _CounterBlock( + beadInRound: counter.beadInRound, + beadsPerRound: counter.beadsPerRound, + rounds: counter.rounds, + dimmed: counter.isSeeding, + ), + const SizedBox(height: 8), Expanded( - flex: 60, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Counter block (left-aligned). - _CounterBlock( - beadInRound: counter.beadInRound, - beadsPerRound: counter.beadsPerRound, - rounds: counter.rounds, - dimmed: counter.isSeeding, - ), - const SizedBox(height: 16), - // Bead arc. - Expanded( - child: - counter.seedFailed - ? _ErrorView( - message: 'Could not load your count', - onRetry: notifier.seed, - ) - : MalaBeads( - // Per-mantra identity: switching mantras gives - // a fresh state (no carried-over slide), so the - // strand snaps to the new count instead of - // animating between mantras. - key: ValueKey(mantra.presetId), - total: counter.total, - beadInRound: counter.beadInRound, - beadsPerRound: counter.beadsPerRound, - enabled: !counter.isSeeding, - // Per-user image from the accumulator detail - // wins; fall back to the preset's image. - beadImageUrl: - counter.beadImageUrl ?? - mantra.beadImageUrl, - beadImageBytes: counter.beadImageBytes, - beadColor: const Color(0xFF8D6E63), - threadColor: const Color(0xFFC62828), - onTap: - () => notifier.incrementBead( - soundEnabled: settings.soundEnabled, - vibrationEnabled: - settings.vibrationEnabled, - ), - ), - ), - const SizedBox(height: 24), - ], + flex: 42, + child: LayoutBuilder( + builder: (context, constraints) { + return Align( + alignment: Alignment.topCenter, + child: SizedBox( + height: constraints.maxHeight * 0.85, + width: double.infinity, + child: + counter.seedFailed + ? _ErrorView( + message: 'Could not load your count', + onRetry: notifier.seed, + ) + : MalaBeads( + key: ValueKey(mantra.presetId), + total: counter.total, + beadInRound: counter.beadInRound, + beadsPerRound: counter.beadsPerRound, + enabled: !counter.isSeeding, + beadImageUrl: + counter.beadImageUrl ?? + mantra.beadImageUrl, + beadImageBytes: counter.beadImageBytes, + beadColor: const Color(0xFF8D6E63), + threadColor: const Color(0xFFC62828), + onTap: + () => notifier.incrementBead( + soundEnabled: settings.soundEnabled, + vibrationEnabled: + settings.vibrationEnabled, + ), + ), + ), + ); + }, ), ), + const SizedBox(height: 12), + const _GroupAccumulationsBar(), ], ), ), @@ -214,6 +218,72 @@ class _MalaScreenState extends ConsumerState { } } +class _GroupAccumulationsBar extends StatelessWidget { + const _GroupAccumulationsBar(); + + static const _avatarSize = 28.0; + static const _avatarOverlap = 10.0; + + @override + Widget build(BuildContext context) { + final iconColor = Theme.of(context).colorScheme.onSurfaceVariant; + + return Align( + alignment: Alignment.centerLeft, + child: Container( + height: 40, + padding: const EdgeInsets.fromLTRB(6, 6, 8, 6), + decoration: BoxDecoration( + color: AppColors.grey100, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: _avatarSize + _avatarOverlap, + height: _avatarSize, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: 0, + child: _GroupAvatarPlaceholder(size: _avatarSize), + ), + Positioned( + left: _avatarOverlap, + child: _GroupAvatarPlaceholder(size: _avatarSize), + ), + ], + ), + ), + Icon(Icons.chevron_right, size: 20, color: iconColor), + ], + ), + ), + ); + } +} + +class _GroupAvatarPlaceholder extends StatelessWidget { + const _GroupAvatarPlaceholder({required this.size}); + + final double size; + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context).colorScheme.surfaceContainerHighest, + border: Border.all(color: AppColors.surfaceWhite, width: 1.5), + ), + ); + } +} + class _CounterBlock extends StatelessWidget { const _CounterBlock({ required this.beadInRound, diff --git a/lib/features/mala/presentation/widgets/mantra_switcher.dart b/lib/features/mala/presentation/widgets/mantra_switcher.dart index c1e1e72c..a8bac6ec 100644 --- a/lib/features/mala/presentation/widgets/mantra_switcher.dart +++ b/lib/features/mala/presentation/widgets/mantra_switcher.dart @@ -72,9 +72,10 @@ class _MantraSwitcherState extends State { /// Animate the carousel by [delta] pages (loops via the page → mantra map). void _animateBy(int delta) { if (!_canLoop) return; - final current = _controller.hasClients && _controller.page != null - ? _controller.page!.round() - : _controller.initialPage; + final current = + _controller.hasClients && _controller.page != null + ? _controller.page!.round() + : _controller.initialPage; _controller.animateToPage( current + delta, duration: const Duration(milliseconds: 300), @@ -95,9 +96,10 @@ class _MantraSwitcherState extends State { Expanded( child: PageView.builder( controller: _controller, - physics: _canLoop - ? const PageScrollPhysics() - : const NeverScrollableScrollPhysics(), + physics: + _canLoop + ? const PageScrollPhysics() + : const NeverScrollableScrollPhysics(), onPageChanged: (page) => widget.onIndexChanged(_logical(page)), // Null itemCount = unbounded forward; bounded at 0 backward, but the // large initial page keeps that out of reach. @@ -107,7 +109,8 @@ class _MantraSwitcherState extends State { return _MantraPage( tibetan: mantra.tibetan, tibetanFontFamily: widget.tibetanFontFamily, - transliteration: mantra.transliteration(widget.language) ?? + transliteration: + mantra.transliteration(widget.language) ?? mantra.localizedName(widget.language), theme: theme, ); From 4942abf2e95627760eca327a55f6576ec3554c8f Mon Sep 17 00:00:00 2001 From: TenzDelek Date: Wed, 1 Jul 2026 16:49:02 +0530 Subject: [PATCH 15/27] almost done --- lib/core/constants/app_assets.dart | 1 + .../data/models/group_accumulator_model.dart | 1 + .../domain/entities/group_accumulator.dart | 20 +- .../group_accumulator_providers.dart | 131 ++++++-- .../screens/group_accumulator_screen.dart | 295 +++++++++--------- .../widgets/group_accumulator_card.dart | 10 +- .../widgets/group_accumulator_hero_card.dart | 240 ++++++++++++++ .../widgets/group_profile_body.dart | 29 +- 8 files changed, 542 insertions(+), 185 deletions(-) create mode 100644 lib/features/group_profile/presentation/widgets/group_accumulator_hero_card.dart diff --git a/lib/core/constants/app_assets.dart b/lib/core/constants/app_assets.dart index 9639d9f6..78653f42 100644 --- a/lib/core/constants/app_assets.dart +++ b/lib/core/constants/app_assets.dart @@ -122,6 +122,7 @@ class AppAssets { // ========== GROUP & SERIES ICONS ========== static const IconData usersThree = PhosphorIconsRegular.usersThree; + static const IconData usercard = PhosphorIconsRegular.users; static const IconData bookOpenText = PhosphorIconsRegular.bookOpenText; static const IconData calendarDots = PhosphorIconsRegular.calendarDots; static const IconData arrowRight = PhosphorIconsRegular.arrowRight; diff --git a/lib/features/group_profile/data/models/group_accumulator_model.dart b/lib/features/group_profile/data/models/group_accumulator_model.dart index 693af8cb..2e2ee118 100644 --- a/lib/features/group_profile/data/models/group_accumulator_model.dart +++ b/lib/features/group_profile/data/models/group_accumulator_model.dart @@ -63,6 +63,7 @@ class GroupAccumulatorModel { endDate: endDate, isJoined: isJoined, memberCount: memberCount, + totalCount: totalCount, ); } diff --git a/lib/features/group_profile/domain/entities/group_accumulator.dart b/lib/features/group_profile/domain/entities/group_accumulator.dart index e6241e82..2858c5fe 100644 --- a/lib/features/group_profile/domain/entities/group_accumulator.dart +++ b/lib/features/group_profile/domain/entities/group_accumulator.dart @@ -12,6 +12,7 @@ class GroupAccumulator extends Equatable { final DateTime? endDate; final bool? isJoined; final int memberCount; + final int totalCount; const GroupAccumulator({ required this.id, @@ -24,10 +25,18 @@ class GroupAccumulator extends Equatable { this.endDate, this.isJoined, this.memberCount = 0, + this.totalCount = 0, }); bool get hasJoined => isJoined == true; + double get progressFraction { + if (targetCount <= 0) return 0; + return (totalCount / targetCount).clamp(0.0, 1.0); + } + + int get progressPercent => (progressFraction * 100).round(); + @override List get props => [ id, @@ -40,11 +49,11 @@ class GroupAccumulator extends Equatable { endDate, isJoined, memberCount, + totalCount, ]; } class GroupAccumulatorDetail extends GroupAccumulator { - final int totalCount; final int totalTodayCount; final GroupAccumulatorUserContribution? user; @@ -59,17 +68,10 @@ class GroupAccumulatorDetail extends GroupAccumulator { super.endDate, super.isJoined, super.memberCount = 0, - this.totalCount = 0, + super.totalCount = 0, this.totalTodayCount = 0, this.user, }); - - double get progressFraction { - if (targetCount <= 0) return 0; - return (totalCount / targetCount).clamp(0.0, 1.0); - } - - int get progressPercent => (progressFraction * 100).round(); } class GroupAccumulatorUserContribution extends Equatable { diff --git a/lib/features/group_profile/presentation/providers/group_accumulator_providers.dart b/lib/features/group_profile/presentation/providers/group_accumulator_providers.dart index 2fab8c29..5081f854 100644 --- a/lib/features/group_profile/presentation/providers/group_accumulator_providers.dart +++ b/lib/features/group_profile/presentation/providers/group_accumulator_providers.dart @@ -5,7 +5,9 @@ import 'package:flutter_pecha/features/auth/presentation/providers/state_provide import 'package:flutter_pecha/features/group_profile/data/datasource/group_accumulator_remote_datasource.dart'; import 'package:flutter_pecha/features/group_profile/data/repositories/group_accumulator_repository_impl.dart'; import 'package:flutter_pecha/features/group_profile/domain/entities/group_accumulator.dart'; +import 'package:flutter_pecha/features/group_profile/domain/entities/group_profile.dart'; import 'package:flutter_pecha/features/group_profile/domain/repositories/group_accumulator_repository.dart'; +import 'package:flutter_pecha/features/group_profile/presentation/providers/group_profile_providers.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fpdart/fpdart.dart'; @@ -44,22 +46,32 @@ final groupAccumulatorDetailProvider = FutureProvider.autoDispose @immutable class GroupAccumulatorMembersKey { final String accumulatorId; - final GroupAccumulatorMemberSort sortBy; - const GroupAccumulatorMembersKey({ - required this.accumulatorId, - required this.sortBy, - }); + const GroupAccumulatorMembersKey({required this.accumulatorId}); @override bool operator ==(Object other) { return other is GroupAccumulatorMembersKey && - other.accumulatorId == accumulatorId && - other.sortBy == sortBy; + other.accumulatorId == accumulatorId; } @override - int get hashCode => Object.hash(accumulatorId, sortBy); + int get hashCode => accumulatorId.hashCode; +} + +List sortAccumulatorMembers( + List members, + GroupAccumulatorMemberSort sort, +) { + final sorted = List.from(members); + sorted.sort((a, b) { + final aCount = + sort == GroupAccumulatorMemberSort.today ? a.todayCount : a.totalCount; + final bCount = + sort == GroupAccumulatorMemberSort.today ? b.todayCount : b.totalCount; + return bCount.compareTo(aCount); + }); + return sorted; } class GroupAccumulatorMembersState { @@ -102,26 +114,27 @@ class GroupAccumulatorMembersNotifier GroupAccumulatorMembersNotifier({ required GroupAccumulatorRepositoryInterface repository, required this.accumulatorId, - required this.sortBy, }) : _repository = repository, super(const GroupAccumulatorMembersState()); final GroupAccumulatorRepositoryInterface _repository; final String accumulatorId; - final GroupAccumulatorMemberSort sortBy; static const _pageSize = 20; bool _hasLoaded = false; Future loadInitial({bool force = false}) async { if (_hasLoaded && !force) return; _hasLoaded = true; - state = state.copyWith(isLoading: true, clearError: true); + state = state.copyWith( + isLoading: state.members.isEmpty, + clearError: true, + ); final result = await _repository.getGroupAccumulatorMembers( accumulatorId, skip: 0, limit: _pageSize, - sortBy: sortBy, + sortBy: GroupAccumulatorMemberSort.total, ); result.fold( @@ -142,7 +155,7 @@ class GroupAccumulatorMembersNotifier accumulatorId, skip: state.members.length, limit: _pageSize, - sortBy: sortBy, + sortBy: GroupAccumulatorMemberSort.total, ); result.fold( @@ -170,20 +183,100 @@ final groupAccumulatorMembersProvider = StateNotifierProvider.autoDispose return GroupAccumulatorMembersNotifier( repository: ref.watch(groupAccumulatorRepositoryProvider), accumulatorId: key.accumulatorId, - sortBy: key.sortBy, ); }); +class GroupAccumulatorJoinCacheNotifier extends StateNotifier> { + GroupAccumulatorJoinCacheNotifier() : super(const {}); + + void markJoined(String accumulatorId) { + state = {...state, accumulatorId}; + } + + void markUnjoined(String accumulatorId) { + if (!state.contains(accumulatorId)) return; + state = {...state}..remove(accumulatorId); + } + + void clear() => state = const {}; + + void syncFromApi(List accumulators) { + final joinedIds = + accumulators + .where((accumulator) => accumulator.isJoined == true) + .map((accumulator) => accumulator.id) + .toSet(); + final notJoinedIds = + accumulators + .where((accumulator) => accumulator.isJoined == false) + .map((accumulator) => accumulator.id) + .toSet(); + + state = {...state, ...joinedIds}..removeAll(notJoinedIds); + } +} + +final groupAccumulatorJoinCacheProvider = StateNotifierProvider.autoDispose + .family, String>(( + ref, + groupId, + ) { + return GroupAccumulatorJoinCacheNotifier(); + }); + +bool accumulatorHasJoined( + GroupAccumulator accumulator, { + Set localJoinedIds = const {}, +}) { + if (localJoinedIds.contains(accumulator.id)) return true; + return accumulator.isJoined == true; +} + Future joinGroupAccumulator({ required WidgetRef ref, required String accumulatorId, required String groupId, + GroupProfile? group, }) async { final repository = ref.read(groupAccumulatorRepositoryProvider); final result = await repository.joinGroupAccumulator(accumulatorId); - return result.fold((_) => false, (_) { - ref.invalidate(groupAccumulatorsProvider(groupId)); - ref.invalidate(groupAccumulatorDetailProvider(accumulatorId)); - return true; - }); + + if (result.isLeft()) return false; + + ref + .read(groupAccumulatorJoinCacheProvider(groupId).notifier) + .markJoined(accumulatorId); + + GroupProfile? resolvedGroup = group; + if (resolvedGroup == null) { + final profileResult = await ref.read(groupProfileProvider(groupId).future); + resolvedGroup = profileResult.fold((_) => null, (profile) => profile); + } + + if (resolvedGroup != null) { + final followKey = GroupFollowKey( + groupId: groupId, + groupType: resolvedGroup.groupType, + ); + ref + .read(groupFollowProvider(followKey).notifier) + .markAutoJoinedFromPracticeEnrollment(group: resolvedGroup); + } + + ref.invalidate(groupAccumulatorsProvider(groupId)); + ref.invalidate(groupAccumulatorDetailProvider(accumulatorId)); + + await Future.wait([ + ref.read(groupAccumulatorDetailProvider(accumulatorId).future), + ref.read(groupAccumulatorsProvider(groupId).future), + ref + .read( + groupAccumulatorMembersProvider( + GroupAccumulatorMembersKey(accumulatorId: accumulatorId), + ).notifier, + ) + .loadInitial(force: true), + ]); + + return true; } 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 76282eb6..f9af1913 100644 --- a/lib/features/group_profile/presentation/screens/group_accumulator_screen.dart +++ b/lib/features/group_profile/presentation/screens/group_accumulator_screen.dart @@ -4,9 +4,13 @@ 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'; import 'package:flutter_pecha/core/widgets/error_state_widget.dart'; -import 'package:flutter_pecha/core/widgets/responsive_cover_image.dart'; +import 'package:flutter_pecha/features/group_profile/presentation/widgets/group_accumulator_hero_card.dart'; +import 'package:flutter_pecha/features/auth/presentation/providers/state_providers.dart'; +import 'package:flutter_pecha/features/auth/presentation/widgets/login_drawer.dart'; import 'package:flutter_pecha/features/group_profile/domain/entities/group_accumulator.dart'; +import 'package:flutter_pecha/features/group_profile/domain/entities/group_profile.dart'; import 'package:flutter_pecha/features/group_profile/presentation/providers/group_accumulator_providers.dart'; +import 'package:flutter_pecha/features/group_profile/presentation/providers/group_profile_providers.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; @@ -29,7 +33,7 @@ class GroupAccumulatorScreen extends ConsumerStatefulWidget { class _GroupAccumulatorScreenState extends ConsumerState with SingleTickerProviderStateMixin { late final TabController _tabController; - GroupAccumulatorMemberSort _memberSort = GroupAccumulatorMemberSort.total; + bool _isJoining = false; @override void initState() { @@ -50,6 +54,50 @@ class _GroupAccumulatorScreenState extends ConsumerState ); final isDark = Theme.of(context).brightness == Brightness.dark; + ref.listen(groupAccumulatorDetailProvider(widget.accumulatorId), ( + previous, + next, + ) { + next.whenData((either) { + either.fold((_) {}, (detail) { + final cacheNotifier = ref.read( + groupAccumulatorJoinCacheProvider(detail.groupId).notifier, + ); + if (detail.isJoined == true) { + cacheNotifier.markJoined(detail.id); + } else if (detail.isJoined == false) { + cacheNotifier.markUnjoined(detail.id); + } + }); + }); + }); + + final resolvedDetail = detailAsync.whenOrNull( + data: (either) => either.fold((_) => null, (detail) => detail), + ); + if (resolvedDetail != null) { + ref.listen( + groupFollowProvider( + GroupFollowKey( + groupId: resolvedDetail.groupId, + groupType: _resolveGroupType(ref, resolvedDetail.groupId), + ), + ), + (previous, next) { + if (next case GroupFollowSuccess(isFollowing: false)) { + ref + .read( + groupAccumulatorJoinCacheProvider(resolvedDetail.groupId) + .notifier, + ) + .clear(); + ref.invalidate(groupAccumulatorDetailProvider(widget.accumulatorId)); + ref.invalidate(groupAccumulatorsProvider(resolvedDetail.groupId)); + } + }, + ); + } + return Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, body: SafeArea( @@ -125,12 +173,26 @@ class _GroupAccumulatorScreenState extends ConsumerState GroupAccumulatorDetail detail, bool isDark, ) { + final localJoinedIds = ref.watch( + groupAccumulatorJoinCacheProvider(detail.groupId), + ); + final hasJoined = accumulatorHasJoined( + detail, + localJoinedIds: localJoinedIds, + ); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: _AccumulatorHeroCard(detail: detail, isDark: isDark), + child: _AccumulatorHeroCard( + detail: detail, + hasJoined: hasJoined, + isDark: isDark, + isJoining: _isJoining, + onJoinTap: () => _onJoinTap(detail), + ), ), TabBar( controller: _tabController, @@ -153,11 +215,7 @@ class _GroupAccumulatorScreenState extends ConsumerState children: [ _LeaderboardTab( accumulatorId: widget.accumulatorId, - sort: _memberSort, isDark: isDark, - onSortChanged: (sort) { - setState(() => _memberSort = sort); - }, ), _MyContributionsTab(detail: detail, isDark: isDark), ], @@ -166,6 +224,50 @@ class _GroupAccumulatorScreenState extends ConsumerState ], ); } + + Future _onJoinTap(GroupAccumulatorDetail detail) async { + final authState = ref.read(authProvider); + if (authState.isGuest || !authState.isLoggedIn) { + LoginDrawer.show(context, ref); + return; + } + + setState(() => _isJoining = true); + final ok = await joinGroupAccumulator( + ref: ref, + accumulatorId: detail.id, + groupId: detail.groupId, + ); + + if (!mounted) return; + setState(() { + _isJoining = false; + if (ok) { + ref + .read(groupAccumulatorJoinCacheProvider(detail.groupId).notifier) + .markJoined(detail.id); + } + }); + + if (ok) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.group_accumulator_join_error), + backgroundColor: Colors.red, + ), + ); + } +} + +GroupType _resolveGroupType(WidgetRef ref, String groupId) { + final profileAsync = ref.read(groupProfileProvider(groupId)); + return profileAsync.maybeWhen( + data: + (either) => + either.fold((_) => GroupType.community, (profile) => profile.groupType), + orElse: () => GroupType.community, + ); } class _MyContributionsTab extends StatefulWidget { @@ -271,152 +373,45 @@ class _MyContributionsTabState extends State<_MyContributionsTab> { class _AccumulatorHeroCard extends StatelessWidget { final GroupAccumulatorDetail detail; + final bool hasJoined; final bool isDark; + final bool isJoining; + final VoidCallback? onJoinTap; - const _AccumulatorHeroCard({required this.detail, required this.isDark}); + const _AccumulatorHeroCard({ + required this.detail, + required this.hasJoined, + required this.isDark, + this.isJoining = false, + this.onJoinTap, + }); @override Widget build(BuildContext context) { - final locale = Localizations.localeOf(context).toString(); - final numberFormat = NumberFormat.decimalPattern(locale); - final progressText = - '${numberFormat.format(detail.totalCount)} / ${numberFormat.format(detail.targetCount)}'; + void navigateToMala() { + final presetId = detail.presetAccumulatorId; + if (presetId.isEmpty) return; + context.push('/mala', extra: {'presetId': presetId}); + } - return GestureDetector( - onTap: () { - final presetId = detail.presetAccumulatorId; - if (presetId.isEmpty) return; - context.push('/mala', extra: {'presetId': presetId}); - }, - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: SizedBox( - height: 220, - width: double.infinity, - child: Stack( - fit: StackFit.expand, - children: [ - if (detail.image != null && !detail.image!.isEmpty) - ResponsiveCoverImage(image: detail.image, fit: BoxFit.cover) - else - ColoredBox( - color: - isDark ? AppColors.surfaceVariantDark : AppColors.grey100, - ), - Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.black.withValues(alpha: 0.05), - Colors.black.withValues(alpha: 0.75), - ], - ), - ), - ), - Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.group_accumulator_participants( - detail.memberCount, - ), - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, - color: Colors.white, - ), - ), - const Spacer(), - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: Text( - detail.title, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.w700, - color: Colors.white, - height: 1.2, - ), - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 12), - Container( - width: 40, - height: 40, - decoration: const BoxDecoration( - color: Colors.white, - shape: BoxShape.circle, - ), - child: const Icon( - AppAssets.caretRight, - color: AppColors.textPrimary, - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: Text( - progressText, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - ), - Text( - '${detail.progressPercent}%', - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - ], - ), - const SizedBox(height: 8), - ClipRRect( - borderRadius: BorderRadius.circular(999), - child: LinearProgressIndicator( - value: detail.progressFraction, - minHeight: 6, - backgroundColor: Colors.white.withValues(alpha: 0.35), - color: AppColors.primary, - ), - ), - ], - ), - ), - ], - ), - ), - ), + return GroupAccumulatorHeroCard( + detail: detail, + hasJoined: hasJoined, + isDark: isDark, + isJoining: isJoining, + onJoinTap: onJoinTap, + onActionTap: hasJoined ? navigateToMala : null, ); } } class _LeaderboardTab extends ConsumerStatefulWidget { final String accumulatorId; - final GroupAccumulatorMemberSort sort; final bool isDark; - final ValueChanged onSortChanged; const _LeaderboardTab({ required this.accumulatorId, - required this.sort, required this.isDark, - required this.onSortChanged, }); @override @@ -426,10 +421,10 @@ class _LeaderboardTab extends ConsumerStatefulWidget { class _LeaderboardTabState extends ConsumerState<_LeaderboardTab> { final ScrollController _scrollController = ScrollController(); bool _hasRequestedInitialLoad = false; + GroupAccumulatorMemberSort _sort = GroupAccumulatorMemberSort.total; GroupAccumulatorMembersKey get _membersKey => GroupAccumulatorMembersKey( accumulatorId: widget.accumulatorId, - sortBy: widget.sort, ); @override @@ -439,15 +434,6 @@ class _LeaderboardTabState extends ConsumerState<_LeaderboardTab> { _loadInitialIfNeeded(); } - @override - void didUpdateWidget(covariant _LeaderboardTab oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.sort != widget.sort) { - _hasRequestedInitialLoad = false; - _loadInitialIfNeeded(force: true); - } - } - @override void dispose() { _scrollController.removeListener(_onScroll); @@ -505,12 +491,17 @@ class _LeaderboardTabState extends ConsumerState<_LeaderboardTab> { ); } + final sortedMembers = sortAccumulatorMembers( + membersState.members, + _sort, + ); + return ListView.builder( controller: _scrollController, padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), itemCount: 1 + - membersState.members.length + + sortedMembers.length + (membersState.isLoadingMore ? 1 : 0), itemBuilder: (context, index) { if (index == 0) { @@ -531,9 +522,9 @@ class _LeaderboardTabState extends ConsumerState<_LeaderboardTab> { ), const Spacer(), _SortToggle( - sort: widget.sort, + sort: _sort, isDark: widget.isDark, - onChanged: widget.onSortChanged, + onChanged: (sort) => setState(() => _sort = sort), ), ], ), @@ -541,10 +532,10 @@ class _LeaderboardTabState extends ConsumerState<_LeaderboardTab> { } final memberIndex = index - 1; - if (memberIndex < membersState.members.length) { - final member = membersState.members[memberIndex]; + if (memberIndex < sortedMembers.length) { + final member = sortedMembers[memberIndex]; final count = - widget.sort == GroupAccumulatorMemberSort.today + _sort == GroupAccumulatorMemberSort.today ? member.todayCount : member.totalCount; 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 80cd3050..fc49d170 100644 --- a/lib/features/group_profile/presentation/widgets/group_accumulator_card.dart +++ b/lib/features/group_profile/presentation/widgets/group_accumulator_card.dart @@ -8,6 +8,7 @@ import 'package:intl/intl.dart'; class GroupAccumulatorCard extends StatelessWidget { final GroupAccumulator accumulator; + final bool hasJoined; final bool isDark; final double? lineHeight; final bool isJoining; @@ -17,6 +18,7 @@ class GroupAccumulatorCard extends StatelessWidget { const GroupAccumulatorCard({ super.key, required this.accumulator, + required this.hasJoined, required this.isDark, this.lineHeight, this.isJoining = false, @@ -31,7 +33,7 @@ class GroupAccumulatorCard extends StatelessWidget { isDark ? AppColors.textTertiaryDark : AppColors.textSecondary; final cardColor = isDark ? AppColors.cardBackgroundDark : AppColors.surfaceWhite; - final showJoinOverlay = !accumulator.hasJoined; + final showJoinOverlay = !hasJoined; return Material( color: cardColor, @@ -40,7 +42,7 @@ class GroupAccumulatorCard extends StatelessWidget { borderRadius: BorderRadius.circular(16), clipBehavior: Clip.antiAlias, child: InkWell( - onTap: showJoinOverlay || isJoining ? null : onTap, + onTap: isJoining ? null : onTap, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -140,13 +142,13 @@ class GroupAccumulatorCard extends StatelessWidget { ), if (accumulator.memberCount > 0) ...[ Icon( - AppAssets.usersThree, + AppAssets.usercard, size: 16, color: secondaryColor, ), const SizedBox(width: 4), Text( - '+${accumulator.memberCount}', + '${accumulator.memberCount}', style: TextStyle( fontSize: 14, color: secondaryColor, 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 new file mode 100644 index 00000000..dafb0ddf --- /dev/null +++ b/lib/features/group_profile/presentation/widgets/group_accumulator_hero_card.dart @@ -0,0 +1,240 @@ +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/widgets/responsive_cover_image.dart'; +import 'package:flutter_pecha/features/group_profile/domain/entities/group_accumulator.dart'; +import 'package:intl/intl.dart'; + +class GroupAccumulatorHeroCard extends StatelessWidget { + final GroupAccumulatorDetail detail; + final bool hasJoined; + final bool isDark; + final bool isJoining; + final VoidCallback? onJoinTap; + final VoidCallback? onActionTap; + + const GroupAccumulatorHeroCard({ + super.key, + required this.detail, + required this.hasJoined, + required this.isDark, + this.isJoining = false, + this.onJoinTap, + this.onActionTap, + }); + + @override + Widget build(BuildContext context) { + final locale = Localizations.localeOf(context).toString(); + final numberFormat = NumberFormat.decimalPattern(locale); + final progressText = + '${numberFormat.format(detail.totalCount)} / ${numberFormat.format(detail.targetCount)}'; + final showJoinButton = !hasJoined; + final cardColor = + isDark ? AppColors.cardBackgroundDark : AppColors.surfaceWhite; + final primaryTextColor = + isDark ? AppColors.textPrimaryDark : AppColors.textPrimary; + final secondaryColor = + isDark ? AppColors.textTertiaryDark : AppColors.textSecondary; + final progressTrackColor = + isDark ? AppColors.surfaceVariantDark : AppColors.grey50; + + return Material( + color: cardColor, + elevation: isDark ? 0 : 2, + shadowColor: Colors.black.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(24), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 180, + width: double.infinity, + child: + detail.image != null && !detail.image!.isEmpty + ? ResponsiveCoverImage( + image: detail.image, + fit: BoxFit.cover, + ) + : ColoredBox( + color: + isDark + ? AppColors.surfaceVariantDark + : AppColors.grey100, + child: Icon( + AppAssets.bookOpenText, + size: 40, + color: isDark ? AppColors.grey500 : AppColors.grey600, + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.group_accumulator_participants( + detail.memberCount, + ), + style: TextStyle( + fontSize: 20, + 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), + ], + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: Text( + progressText, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w400, + color: secondaryColor, + ), + ), + ), + Text( + '${detail.progressPercent}%', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w400, + color: secondaryColor, + ), + ), + ], + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + value: detail.progressFraction, + minHeight: 8, + backgroundColor: + isDark ? AppColors.cardBorderDark : progressTrackColor, + color: AppColors.primary, + ), + ), + if (showJoinButton) ...[ + const SizedBox(height: 16), + _JoinButton( + isDark: isDark, + isJoining: isJoining, + onTap: onJoinTap, + ), + ], + ], + ), + ), + ], + ), + ); + } +} + +class _ActionButton extends StatelessWidget { + final bool isDark; + final VoidCallback? onTap; + + const _ActionButton({required this.isDark, this.onTap}); + + @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, + ), + ), + child: Icon( + AppAssets.caretRight, + size: 18, + color: isDark ? AppColors.textTertiaryDark : AppColors.grey800, + ), + ); + + if (onTap == null) return button; + + return GestureDetector(onTap: onTap, child: button); + } +} + +class _JoinButton extends StatelessWidget { + final bool isDark; + final bool isJoining; + final VoidCallback? onTap; + + const _JoinButton({ + required this.isDark, + required this.isJoining, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: isJoining ? null : onTap, + child: Container( + height: 44, + width: double.infinity, + alignment: Alignment.center, + decoration: BoxDecoration( + color: isDark ? AppColors.cardBorderDark : AppColors.backgroundDark, + borderRadius: BorderRadius.circular(8), + ), + child: + isJoining + ? SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + color: + isDark + ? AppColors.textPrimaryDark + : AppColors.textPrimary, + ), + ) + : Text( + context.l10n.group_join_to_contribute, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppColors.textPrimaryDark, + ), + ), + ), + ); + } +} 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 8f6e8a5c..77f611a3 100644 --- a/lib/features/group_profile/presentation/widgets/group_profile_body.dart +++ b/lib/features/group_profile/presentation/widgets/group_profile_body.dart @@ -89,8 +89,19 @@ class _GroupProfileBodyState extends ConsumerState ref.listen(groupFollowProvider(followKey), (previous, next) { if (next case GroupFollowSuccess(isFollowing: false)) { setState(_localGroupEnrolledSeriesIds.clear); + ref.read(groupAccumulatorJoinCacheProvider(widget.profile.id).notifier).clear(); + ref.invalidate(groupAccumulatorsProvider(widget.profile.id)); } }); + ref.listen(groupAccumulatorsProvider(widget.profile.id), (previous, next) { + next.whenData((either) { + either.fold((_) {}, (page) { + ref + .read(groupAccumulatorJoinCacheProvider(widget.profile.id).notifier) + .syncFromApi(page.accumulators); + }); + }); + }); ref.listen(groupProfileProvider(widget.profile.id), (previous, next) { next.whenData((either) { either.fold((_) {}, (profile) { @@ -399,10 +410,18 @@ class _GroupProfileBodyState extends ConsumerState } final accumulator = accumulators[index - series.length]; + final localJoinedIds = ref.watch( + groupAccumulatorJoinCacheProvider(profile.id), + ); + final hasJoined = accumulatorHasJoined( + accumulator, + localJoinedIds: localJoinedIds, + ); return Padding( padding: EdgeInsets.only(bottom: isLast ? 0 : 16), child: GroupAccumulatorCard( accumulator: accumulator, + hasJoined: hasJoined, isDark: isDark, lineHeight: lineHeight, isJoining: _joiningAccumulatorId == accumulator.id, @@ -484,10 +503,18 @@ class _GroupProfileBodyState extends ConsumerState ref: ref, accumulatorId: accumulator.id, groupId: profile.id, + group: profile, ); if (!mounted) return; - setState(() => _joiningAccumulatorId = null); + setState(() { + _joiningAccumulatorId = null; + if (ok) { + ref + .read(groupAccumulatorJoinCacheProvider(profile.id).notifier) + .markJoined(accumulator.id); + } + }); if (ok) { _navigateToAccumulatorDetail(accumulator.id); From fd29e454f814840842129c510b8d527461a8be52 Mon Sep 17 00:00:00 2001 From: tentamdin Date: Wed, 1 Jul 2026 16:53:58 +0530 Subject: [PATCH 16/27] feat: implement accumulator groups feature with data fetching and UI integration --- .../datasources/mala_remote_datasource.dart | 26 ++++ .../data/models/accumulator_group_model.dart | 63 ++++++++ .../repositories/mala_repository_impl.dart | 18 +++ .../domain/entities/accumulator_group.dart | 31 ++++ .../domain/repositories/mala_repository.dart | 7 + .../accumulator_groups_provider.dart | 13 ++ .../presentation/screens/mala_screen.dart | 69 +-------- .../widgets/group_accumulations_bar.dart | 142 ++++++++++++++++++ pubspec.lock | 20 +-- 9 files changed, 312 insertions(+), 77 deletions(-) create mode 100644 lib/features/mala/data/models/accumulator_group_model.dart create mode 100644 lib/features/mala/domain/entities/accumulator_group.dart create mode 100644 lib/features/mala/presentation/providers/accumulator_groups_provider.dart create mode 100644 lib/features/mala/presentation/widgets/group_accumulations_bar.dart diff --git a/lib/features/mala/data/datasources/mala_remote_datasource.dart b/lib/features/mala/data/datasources/mala_remote_datasource.dart index b5f8e099..370cf4d8 100644 --- a/lib/features/mala/data/datasources/mala_remote_datasource.dart +++ b/lib/features/mala/data/datasources/mala_remote_datasource.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import 'package:flutter_pecha/core/error/exceptions.dart'; import 'package:flutter_pecha/core/utils/app_logger.dart'; +import 'package:flutter_pecha/features/mala/data/models/accumulator_group_model.dart'; import 'package:flutter_pecha/features/mala/data/models/accumulator_model.dart'; class MalaRemoteDataSource { @@ -54,6 +55,31 @@ class MalaRemoteDataSource { } } + /// `GET /accumulators/{accumulator_id}/groups` — groups using this preset. + Future> fetchAccumulatorGroups( + String accumulatorId, { + bool joinedOnly = false, + }) async { + try { + final response = await dio.get( + '/accumulators/$accumulatorId/groups', + queryParameters: {'joined_only': joinedOnly}, + ); + if (response.statusCode == 200) { + return AccumulatorGroupsResponseModel.fromJson( + response.data as Map, + ).groups; + } + throw _statusToException( + response.statusCode, + 'Failed to load accumulator groups', + ); + } on DioException catch (e) { + _logger.error('Dio error in fetchAccumulatorGroups', e); + throw _dioToException(e, 'Failed to load accumulator groups'); + } + } + /// `GET /accumulators/{parent_id}` — the user's detail for one preset. /// /// Returns `null` when the user has no accumulator for this preset yet diff --git a/lib/features/mala/data/models/accumulator_group_model.dart b/lib/features/mala/data/models/accumulator_group_model.dart new file mode 100644 index 00000000..4d2637b5 --- /dev/null +++ b/lib/features/mala/data/models/accumulator_group_model.dart @@ -0,0 +1,63 @@ +import 'package:flutter_pecha/features/mala/domain/entities/accumulator_group.dart'; + +class AccumulatorGroupModel { + const AccumulatorGroupModel({ + required this.groupAccumulatorId, + required this.groupId, + required this.userTotalCount, + required this.isJoined, + this.title, + this.imageKey, + }); + + final String groupAccumulatorId; + final String groupId; + final String? title; + final String? imageKey; + final int userTotalCount; + final bool isJoined; + + factory AccumulatorGroupModel.fromJson(Map json) { + return AccumulatorGroupModel( + groupAccumulatorId: json['group_accumulator_id'] as String, + groupId: json['group_id'] as String, + title: json['title'] as String?, + imageKey: json['image_key'] as String?, + userTotalCount: (json['user_total_count'] as num?)?.toInt() ?? 0, + isJoined: json['is_joined'] as bool? ?? false, + ); + } + + AccumulatorGroup toEntity() { + return AccumulatorGroup( + groupAccumulatorId: groupAccumulatorId, + groupId: groupId, + title: title, + imageKey: imageKey, + userTotalCount: userTotalCount, + isJoined: isJoined, + ); + } +} + +class AccumulatorGroupsResponseModel { + const AccumulatorGroupsResponseModel({ + required this.groups, + required this.total, + }); + + final List groups; + final int total; + + factory AccumulatorGroupsResponseModel.fromJson(Map json) { + final rawGroups = json['groups'] as List? ?? const []; + return AccumulatorGroupsResponseModel( + groups: + rawGroups + .whereType>() + .map(AccumulatorGroupModel.fromJson) + .toList(), + total: (json['total'] as num?)?.toInt() ?? rawGroups.length, + ); + } +} diff --git a/lib/features/mala/data/repositories/mala_repository_impl.dart b/lib/features/mala/data/repositories/mala_repository_impl.dart index f229464f..4822a8f9 100644 --- a/lib/features/mala/data/repositories/mala_repository_impl.dart +++ b/lib/features/mala/data/repositories/mala_repository_impl.dart @@ -5,6 +5,7 @@ import 'package:flutter_pecha/core/error/exceptions.dart'; import 'package:flutter_pecha/core/error/failures.dart'; import 'package:flutter_pecha/features/mala/data/datasources/mala_local_datasource.dart'; import 'package:flutter_pecha/features/mala/data/datasources/mala_remote_datasource.dart'; +import 'package:flutter_pecha/features/mala/domain/entities/accumulator_group.dart'; import 'package:flutter_pecha/features/mala/domain/entities/mala_count.dart'; import 'package:flutter_pecha/features/mala/domain/entities/mantra.dart'; import 'package:flutter_pecha/features/mala/domain/repositories/mala_repository.dart'; @@ -110,6 +111,23 @@ class MalaRepositoryImpl implements MalaRepository { } } + @override + Future>> getJoinedAccumulatorGroups( + String accumulatorId, + ) async { + try { + final groups = await remote.fetchAccumulatorGroups( + accumulatorId, + joinedOnly: true, + ); + return Right(groups.map((group) => group.toEntity()).toList()); + } on AppException catch (e) { + return Left(_toFailure(e)); + } catch (e) { + return Left(UnknownFailure('Failed to load accumulator groups: $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 new file mode 100644 index 00000000..0758a99e --- /dev/null +++ b/lib/features/mala/domain/entities/accumulator_group.dart @@ -0,0 +1,31 @@ +import 'package:equatable/equatable.dart'; + +/// A group accumulator linked to a preset, from +/// `GET /accumulators/{accumulator_id}/groups`. +class AccumulatorGroup extends Equatable { + const AccumulatorGroup({ + required this.groupAccumulatorId, + required this.groupId, + required this.userTotalCount, + required this.isJoined, + this.title, + this.imageKey, + }); + + final String groupAccumulatorId; + final String groupId; + final String? title; + final String? imageKey; + final int userTotalCount; + final bool isJoined; + + @override + List get props => [ + groupAccumulatorId, + groupId, + title, + imageKey, + userTotalCount, + isJoined, + ]; +} diff --git a/lib/features/mala/domain/repositories/mala_repository.dart b/lib/features/mala/domain/repositories/mala_repository.dart index e5555897..60041fb1 100644 --- a/lib/features/mala/domain/repositories/mala_repository.dart +++ b/lib/features/mala/domain/repositories/mala_repository.dart @@ -1,5 +1,6 @@ import 'package:fpdart/fpdart.dart'; import 'package:flutter_pecha/core/error/failures.dart'; +import 'package:flutter_pecha/features/mala/domain/entities/accumulator_group.dart'; import 'package:flutter_pecha/features/mala/domain/entities/mala_count.dart'; import 'package:flutter_pecha/features/mala/domain/entities/mantra.dart'; @@ -33,4 +34,10 @@ abstract class MalaRepository { /// Used when resetting the on-screen session while preserving lifetime totals /// on the deleted record server-side. Future> deleteUserAccumulator(String accumulatorId); + + /// Joined group accumulators for a preset + /// (`GET /accumulators/{accumulator_id}/groups?joined_only=true`). + Future>> getJoinedAccumulatorGroups( + String accumulatorId, + ); } diff --git a/lib/features/mala/presentation/providers/accumulator_groups_provider.dart b/lib/features/mala/presentation/providers/accumulator_groups_provider.dart new file mode 100644 index 00000000..bd218f35 --- /dev/null +++ b/lib/features/mala/presentation/providers/accumulator_groups_provider.dart @@ -0,0 +1,13 @@ +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`). +final joinedAccumulatorGroupsProvider = FutureProvider.autoDispose + .family, String>((ref, presetId) async { + final result = await ref + .watch(malaRepositoryProvider) + .getJoinedAccumulatorGroups(presetId); + return result.fold((_) => const [], (groups) => groups); + }); diff --git a/lib/features/mala/presentation/screens/mala_screen.dart b/lib/features/mala/presentation/screens/mala_screen.dart index 31d31f11..348c2728 100644 --- a/lib/features/mala/presentation/screens/mala_screen.dart +++ b/lib/features/mala/presentation/screens/mala_screen.dart @@ -7,6 +7,7 @@ import 'package:flutter_pecha/core/extensions/context_ext.dart'; import 'package:flutter_pecha/features/mala/domain/entities/mantra.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/group_accumulations_bar.dart'; import 'package:flutter_pecha/features/mala/presentation/widgets/mala_beads.dart'; import 'package:flutter_pecha/features/mala/presentation/widgets/mala_skeleton.dart'; import 'package:flutter_pecha/features/mala/presentation/widgets/mantra_switcher.dart'; @@ -208,7 +209,7 @@ class _MalaScreenState extends ConsumerState { ), ), const SizedBox(height: 12), - const _GroupAccumulationsBar(), + GroupAccumulationsBar(presetId: mantra.presetId), ], ), ), @@ -218,72 +219,6 @@ class _MalaScreenState extends ConsumerState { } } -class _GroupAccumulationsBar extends StatelessWidget { - const _GroupAccumulationsBar(); - - static const _avatarSize = 28.0; - static const _avatarOverlap = 10.0; - - @override - Widget build(BuildContext context) { - final iconColor = Theme.of(context).colorScheme.onSurfaceVariant; - - return Align( - alignment: Alignment.centerLeft, - child: Container( - height: 40, - padding: const EdgeInsets.fromLTRB(6, 6, 8, 6), - decoration: BoxDecoration( - color: AppColors.grey100, - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: _avatarSize + _avatarOverlap, - height: _avatarSize, - child: Stack( - clipBehavior: Clip.none, - children: [ - Positioned( - left: 0, - child: _GroupAvatarPlaceholder(size: _avatarSize), - ), - Positioned( - left: _avatarOverlap, - child: _GroupAvatarPlaceholder(size: _avatarSize), - ), - ], - ), - ), - Icon(Icons.chevron_right, size: 20, color: iconColor), - ], - ), - ), - ); - } -} - -class _GroupAvatarPlaceholder extends StatelessWidget { - const _GroupAvatarPlaceholder({required this.size}); - - final double size; - - @override - Widget build(BuildContext context) { - return Container( - width: size, - height: size, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Theme.of(context).colorScheme.surfaceContainerHighest, - border: Border.all(color: AppColors.surfaceWhite, width: 1.5), - ), - ); - } -} - class _CounterBlock extends StatelessWidget { const _CounterBlock({ required this.beadInRound, diff --git a/lib/features/mala/presentation/widgets/group_accumulations_bar.dart b/lib/features/mala/presentation/widgets/group_accumulations_bar.dart new file mode 100644 index 00000000..967b4d75 --- /dev/null +++ b/lib/features/mala/presentation/widgets/group_accumulations_bar.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.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/features/mala/domain/entities/accumulator_group.dart'; +import 'package:flutter_pecha/features/mala/presentation/providers/accumulator_groups_provider.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Pill entry point for group accumulations on the mala screen. +/// +/// Visible only when `GET /accumulators/{presetId}/groups?joined_only=true` +/// returns at least one group; otherwise collapses to zero height. +class GroupAccumulationsBar extends ConsumerWidget { + const GroupAccumulationsBar({super.key, required this.presetId}); + + final String presetId; + + static const _avatarSize = 28.0; + static const _avatarOverlap = 10.0; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final groupsAsync = ref.watch(joinedAccumulatorGroupsProvider(presetId)); + + return groupsAsync.when( + data: (groups) { + if (groups.isEmpty) return const SizedBox.shrink(); + return _GroupAccumulationsBarContent( + groups: groups, + avatarSize: _avatarSize, + avatarOverlap: _avatarOverlap, + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, __) => const SizedBox.shrink(), + ); + } +} + +class _GroupAccumulationsBarContent extends StatelessWidget { + const _GroupAccumulationsBarContent({ + required this.groups, + required this.avatarSize, + required this.avatarOverlap, + }); + + final List groups; + final double avatarSize; + final double avatarOverlap; + + @override + Widget build(BuildContext context) { + final iconColor = Theme.of(context).colorScheme.onSurfaceVariant; + final preview = groups.take(2).toList(); + final stackWidth = + preview.length == 1 + ? avatarSize + : avatarSize + avatarOverlap; + + return Align( + alignment: Alignment.centerLeft, + child: Material( + color: AppColors.grey100, + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () { + // Group accumulations detail navigation will be wired separately. + }, + child: Container( + height: 40, + padding: const EdgeInsets.fromLTRB(6, 6, 8, 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: stackWidth, + height: avatarSize, + child: Stack( + clipBehavior: Clip.none, + children: [ + for (var i = 0; i < preview.length; i++) + Positioned( + left: i * avatarOverlap, + child: _GroupAvatar( + group: preview[i], + size: avatarSize, + ), + ), + ], + ), + ), + Icon(Icons.chevron_right, size: 20, color: iconColor), + ], + ), + ), + ), + ), + ); + } +} + +class _GroupAvatar extends StatelessWidget { + const _GroupAvatar({required this.group, required this.size}); + + final AccumulatorGroup group; + final double size; + + @override + Widget build(BuildContext context) { + final imageUrl = _resolveImageUrl(group.imageKey); + 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: + imageUrl != null + ? CachedNetworkImageWidget( + imageUrl: imageUrl, + width: size, + height: size, + fit: BoxFit.cover, + ) + : null, + ); + } + + String? _resolveImageUrl(String? imageKey) { + if (imageKey == null || imageKey.isEmpty) return null; + if (imageKey.startsWith('http://') || imageKey.startsWith('https://')) { + return imageKey; + } + return null; + } +} diff --git a/pubspec.lock b/pubspec.lock index 888db383..852277f7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -221,10 +221,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -1145,26 +1145,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" mime: dependency: transitive description: @@ -1687,10 +1687,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.6" tibetan_calendar: dependency: "direct main" description: From 24f7b26bd41b8ae11fd34e1f8c99f2eac87229c2 Mon Sep 17 00:00:00 2001 From: TenzDelek Date: Wed, 1 Jul 2026 16:59:38 +0530 Subject: [PATCH 17/27] last one --- .../data/models/group_profile_model.dart | 1 + .../domain/entities/group_profile.dart | 2 + .../widgets/group_profile_body.dart | 52 ++++++++++++++----- 3 files changed, 42 insertions(+), 13 deletions(-) diff --git a/lib/features/group_profile/data/models/group_profile_model.dart b/lib/features/group_profile/data/models/group_profile_model.dart index e6856799..9363e45d 100644 --- a/lib/features/group_profile/data/models/group_profile_model.dart +++ b/lib/features/group_profile/data/models/group_profile_model.dart @@ -108,6 +108,7 @@ class GroupProfileModel { totalDays: (json['total_days'] as num?)?.toInt() ?? 0, startDate: _parseDate(json['start_date']), endDate: _parseDate(json['end_date']), + enrolledCount: (json['enrolled_count'] as num?)?.toInt() ?? 0, isGroupEnrolled: _parseNullableBool(json['is_group_enrolled']), ); } diff --git a/lib/features/group_profile/domain/entities/group_profile.dart b/lib/features/group_profile/domain/entities/group_profile.dart index fcf833e9..346c3204 100644 --- a/lib/features/group_profile/domain/entities/group_profile.dart +++ b/lib/features/group_profile/domain/entities/group_profile.dart @@ -37,6 +37,7 @@ class GroupProfileSeries { final int totalDays; final DateTime? startDate; final DateTime? endDate; + final int enrolledCount; /// Whether the current user is group-enrolled for this series. /// - `true`: enrolled with this group /// - `false`: enrolled with a different group @@ -54,6 +55,7 @@ class GroupProfileSeries { this.totalDays = 0, this.startDate, this.endDate, + this.enrolledCount = 0, this.isGroupEnrolled, }); } 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 77f611a3..fb485eca 100644 --- a/lib/features/group_profile/presentation/widgets/group_profile_body.dart +++ b/lib/features/group_profile/presentation/widgets/group_profile_body.dart @@ -89,7 +89,9 @@ class _GroupProfileBodyState extends ConsumerState ref.listen(groupFollowProvider(followKey), (previous, next) { if (next case GroupFollowSuccess(isFollowing: false)) { setState(_localGroupEnrolledSeriesIds.clear); - ref.read(groupAccumulatorJoinCacheProvider(widget.profile.id).notifier).clear(); + ref + .read(groupAccumulatorJoinCacheProvider(widget.profile.id).notifier) + .clear(); ref.invalidate(groupAccumulatorsProvider(widget.profile.id)); } }); @@ -97,7 +99,9 @@ class _GroupProfileBodyState extends ConsumerState next.whenData((either) { either.fold((_) {}, (page) { ref - .read(groupAccumulatorJoinCacheProvider(widget.profile.id).notifier) + .read( + groupAccumulatorJoinCacheProvider(widget.profile.id).notifier, + ) .syncFromApi(page.accumulators); }); }); @@ -616,7 +620,6 @@ class _GroupProfileBodyState extends ConsumerState double? lineHeight, ) { final dateRange = _formatSeriesDateRange(series); - final subtitle = dateRange ?? series.subTitle?.trim(); final enrollmentStatus = _seriesGroupEnrollmentStatus(series); final showPracticeOverlay = enrollmentStatus != true; final isEnrolling = _enrollingSeriesId == series.id; @@ -715,17 +718,40 @@ class _GroupProfileBodyState extends ConsumerState maxLines: 2, overflow: TextOverflow.ellipsis, ), - if (subtitle != null && subtitle.isNotEmpty) ...[ + if (dateRange != null || series.enrolledCount > 0) ...[ const SizedBox(height: 4), - Text( - subtitle, - style: TextStyle( - fontSize: 14, - color: secondaryColor, - height: lineHeight, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + Row( + children: [ + if (dateRange != null) + Expanded( + child: Text( + dateRange, + style: TextStyle( + fontSize: 14, + color: secondaryColor, + height: lineHeight, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (series.enrolledCount > 0) ...[ + Icon( + AppAssets.usercard, + size: 16, + color: secondaryColor, + ), + const SizedBox(width: 4), + Text( + '${series.enrolledCount}', + style: TextStyle( + fontSize: 14, + color: secondaryColor, + height: lineHeight, + ), + ), + ], + ], ), ], ], From bb57c969b3f98a8b921a79066a39c9daf1eca026 Mon Sep 17 00:00:00 2001 From: TenzDelek Date: Wed, 1 Jul 2026 17:14:48 +0530 Subject: [PATCH 18/27] fix name cut of --- .../presentation/widgets/home_header.dart | 113 +++++++++++++++--- lib/shared/utils/helper_functions.dart | 19 +++ lib/shared/widgets/main_tab_app_bar.dart | 1 + 3 files changed, 116 insertions(+), 17 deletions(-) diff --git a/lib/features/home/presentation/widgets/home_header.dart b/lib/features/home/presentation/widgets/home_header.dart index 08a9d737..8b279a34 100644 --- a/lib/features/home/presentation/widgets/home_header.dart +++ b/lib/features/home/presentation/widgets/home_header.dart @@ -19,7 +19,9 @@ import 'package:go_router/go_router.dart'; class HomeTabAppBar extends ConsumerWidget implements PreferredSizeWidget { const HomeTabAppBar({super.key}); - static const double toolbarHeight = 58; + /// Tall enough for two-line Tibetan greetings without clipping ascenders. + static const double toolbarHeight = 70; + static const double tibetanGreetingTopPadding = 6; static const double _actionsReserveWidth = 148; @override @@ -29,6 +31,8 @@ class HomeTabAppBar extends ConsumerWidget implements PreferredSizeWidget { Widget build(BuildContext context, WidgetRef ref) { final user = ref.watch(userProvider).user; final firstName = user?.firstName ?? user?.username; + final hasName = firstName?.isNotEmpty ?? false; + final isTibetanGreeting = context.isTibetanLocale && hasName; final streakCount = ref .watch(streakFutureProvider) @@ -38,9 +42,11 @@ class HomeTabAppBar extends ConsumerWidget implements PreferredSizeWidget { ); return MainTabAppBar( - toolbarHeight: HomeTabAppBar.toolbarHeight, + toolbarHeight: toolbarHeight, titleWidget: _Greeting( firstName: firstName, + toolbarHeight: toolbarHeight, + alignToBottom: isTibetanGreeting, maxWidth: MediaQuery.sizeOf(context).width - MainTabAppBar.titleSpacing - @@ -91,21 +97,81 @@ class HomeEventBanner extends ConsumerWidget { } class _Greeting extends StatelessWidget { - const _Greeting({required this.firstName, required this.maxWidth}); + const _Greeting({ + required this.firstName, + required this.maxWidth, + required this.toolbarHeight, + this.alignToBottom = false, + }); final String? firstName; final double maxWidth; + 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), + strutStyle: context.tibetanStrutStyle(fontSize, compact: true), softWrap: true, + maxLines: maxLines, + overflow: TextOverflow.clip, + ); + } + + 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; + + if (context.isTibetanLocale) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + _buildLine( + context: context, + text: withTibetanLineBreakOpportunities(prefix), + style: greetingStyle, + fontSize: greetingFontSize, + ), + if (formattedName != null) ...[ + const SizedBox(height: 2), + _buildLine( + context: context, + text: formattedName, + style: greetingStyle, + fontSize: greetingFontSize, + ), + ], + ], + ); + } + + final greeting = + formattedName != null ? '$helloPrefix$formattedName' : prefix; + + return _buildLine( + context: context, + text: greeting, + style: greetingStyle, + fontSize: greetingFontSize, + maxLines: 2, ); } @@ -114,23 +180,36 @@ class _Greeting extends StatelessWidget { 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, - height: getLineHeight(Localizations.localeOf(context).languageCode), + 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, ); - final prefix = localizations.home_hello_prefix.trim(); - final greeting = - firstName != null && firstName!.isNotEmpty - ? '${localizations.home_hello_prefix}$firstName' - : prefix; return SizedBox( width: maxWidth, - child: _buildLine( - context: context, - text: greeting, - style: greetingStyle, - fontSize: greetingFontSize, + height: toolbarHeight, + child: Align( + alignment: alignToBottom ? Alignment.bottomLeft : Alignment.centerLeft, + child: Padding( + padding: EdgeInsets.only( + top: + context.isTibetanLocale + ? HomeTabAppBar.tibetanGreetingTopPadding + : 0, + ), + child: greetingContent, + ), ), ); } diff --git a/lib/shared/utils/helper_functions.dart b/lib/shared/utils/helper_functions.dart index 093e58b9..f33eada1 100644 --- a/lib/shared/utils/helper_functions.dart +++ b/lib/shared/utils/helper_functions.dart @@ -80,6 +80,25 @@ String withTibetanLineBreakOpportunities(String text) { ); } +final _whitespacePattern = RegExp(r'\s'); + +/// Inserts zero-width break opportunities so Flutter can wrap [text] that has +/// no whitespace (e.g. a long username). +String withWordBreakOpportunities(String text, {int minLength = 12}) { + if (text.isEmpty || + text.length < minLength || + _whitespacePattern.hasMatch(text)) { + return text; + } + + return text.split('').join('\u200B'); +} + +/// Applies Tibetan syllable and word-level break opportunities for constrained UI. +String withDisplayLineBreakOpportunities(String text) { + return withWordBreakOpportunities(withTibetanLineBreakOpportunities(text)); +} + /// Calculates the share position origin for share_plus ShareParams. /// /// Tries to get the position from the provided [context] or [globalKey]. diff --git a/lib/shared/widgets/main_tab_app_bar.dart b/lib/shared/widgets/main_tab_app_bar.dart index 2eccd4bd..06f13157 100644 --- a/lib/shared/widgets/main_tab_app_bar.dart +++ b/lib/shared/widgets/main_tab_app_bar.dart @@ -54,6 +54,7 @@ class MainTabAppBar extends StatelessWidget implements PreferredSizeWidget { @override Widget build(BuildContext context) { return AppBar( + clipBehavior: Clip.none, elevation: 0, scrolledUnderElevation: 0, automaticallyImplyLeading: false, From 56aebce17e57c0dbff1a922bccf12541f54c8dd6 Mon Sep 17 00:00:00 2001 From: tentamdin Date: Wed, 1 Jul 2026 17:25:46 +0530 Subject: [PATCH 19/27] feat: add accumulator group model and UI components for group accumulations --- lib/features/mala/README.md | 59 ++++++++++++++--- .../data/models/accumulator_group_model.dart | 9 +-- .../domain/entities/accumulator_group.dart | 7 ++- .../widgets/group_accumulations_bar.dart | 63 ++++++++----------- 4 files changed, 87 insertions(+), 51 deletions(-) diff --git a/lib/features/mala/README.md b/lib/features/mala/README.md index 37414de4..b46214f1 100644 --- a/lib/features/mala/README.md +++ b/lib/features/mala/README.md @@ -15,12 +15,14 @@ data/ mala_local_datasource.dart Hive store (LocalMalaState), namespaced by user models/ accumulator_model.dart JSON DTOs + toEntity()/toMalaCount() + accumulator_group_model.dart Group accumulator DTOs (ImageUrlModel → ResponsiveImage) repositories/ mala_repository_impl.dart maps exceptions → Failure (fpdart Either) domain/ entities/ mantra.dart Mantra, MantraText, AccumulatorMetadata; kBeadsPerRound = 108 mala_count.dart MalaCount (total, accumulatorId, beadImageUrl) + accumulator_group.dart Joined group accumulator summary for a preset repositories/mala_repository.dart usecases/mala_usecases.dart GetCatalogue / GetAccumulatorDetail / Create / Update presentation/ @@ -28,12 +30,17 @@ presentation/ mala_providers.dart all DI providers + user-id resolution 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) + accumulation_search_provider.dart preset search state (settings / add flows) services/ mala_sound_player.dart bead-tap click (just_audio) - screens/mala_screen.dart screen layout (40% switcher / 60% counter+beads) + screens/mala_screen.dart screen layout (mantra card → counter → beads → group bar) widgets/ mala_beads.dart the tappable bead-arc CustomPainter mantra_switcher.dart infinite looping carousel (swipe + ‹ › chevrons) + group_accumulations_bar.dart joined-group pill with overlapping avatars + mala_settings_sheet.dart sound/vibration, reset, bookmark, add accumulation mala_skeleton.dart loading placeholder ``` @@ -49,6 +56,7 @@ 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`). | | `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). | @@ -198,13 +206,48 @@ A `CustomPaint` strand that advances **forward only** (counting is monotonic). ## Screen layout (`MalaScreen`) -Below the app bar: **40%** `MantraSwitcher` — an **infinite looping carousel** -(an unbounded `PageView` seeded at `_loopBase * length + index`, mapped back -with `page % length`); swipe or tap the chevrons and it wraps endlessly -(last → first, first → last). The screen owns `_index`; the carousel reports -settles via `onIndexChanged`. With one mantra it locks (no swipe, chevrons -disabled). **60%** `_CounterBlock` (`n/108`, rounds) above the `MalaBeads` arc. States: skeleton (loading), error+retry (catalogue or seed -failure), data. +Below the app bar, the body is a vertical column (24px horizontal padding, 16px +bottom): + +1. **`MantraSwitcher`** — `Expanded(flex: 36)`, inside a white card + (`AppColors.surfaceWhite`, 16px corner radius). Infinite looping carousel + (unbounded `PageView` seeded at `_loopBase * length + index`, mapped back + with `page % length`); swipe or tap chevrons to wrap. The screen owns + `_index`; the carousel reports settles via `onIndexChanged`. With one mantra + it locks (no swipe, chevrons disabled). +2. **`_CounterBlock`** — `n/108` bead-in-round and rounds count, left-aligned. +3. **`MalaBeads`** — `Expanded(flex: 42)`, top-aligned in a canvas sized to + ~85% of the available height so the arc sits higher on screen. +4. **`GroupAccumulationsBar`** — fixed 40px slot at the bottom (see below). + +States: skeleton (loading), error+retry (catalogue or seed failure), data. + +## Group accumulations bar (`GroupAccumulationsBar`) + +Entry point for group counting tied to the current preset. Fetched per mantra via +`joinedAccumulatorGroupsProvider(presetId)` → +`MalaRepository.getJoinedAccumulatorGroups()` → +`GET /accumulators/{presetId}/groups?joined_only=true`. + +- **Visibility:** the grey pill appears only when the response contains at + least one group. Loading, error, and empty responses show nothing inside the + slot. +- **Layout stability:** the widget always reserves **40px height** + (`GroupAccumulationsBar.barHeight`) so the bead arc does not jump when the + request resolves. +- **Preview:** up to two overlapping circular avatars (28px, 10px overlap) plus + a chevron. Tap handler is stubbed for future detail navigation. +- **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. + +On API failure the provider returns an empty list (bar stays hidden, slot +reserved). ## Analytics diff --git a/lib/features/mala/data/models/accumulator_group_model.dart b/lib/features/mala/data/models/accumulator_group_model.dart index 4d2637b5..d4276f27 100644 --- a/lib/features/mala/data/models/accumulator_group_model.dart +++ b/lib/features/mala/data/models/accumulator_group_model.dart @@ -1,4 +1,5 @@ import 'package:flutter_pecha/features/mala/domain/entities/accumulator_group.dart'; +import 'package:flutter_pecha/features/plans/data/models/plans_model.dart'; class AccumulatorGroupModel { const AccumulatorGroupModel({ @@ -7,13 +8,13 @@ class AccumulatorGroupModel { required this.userTotalCount, required this.isJoined, this.title, - this.imageKey, + this.image, }); final String groupAccumulatorId; final String groupId; final String? title; - final String? imageKey; + final ImageModel? image; final int userTotalCount; final bool isJoined; @@ -22,7 +23,7 @@ class AccumulatorGroupModel { groupAccumulatorId: json['group_accumulator_id'] as String, groupId: json['group_id'] as String, title: json['title'] as String?, - imageKey: json['image_key'] as String?, + image: ImageModel.fromJsonMap(json), userTotalCount: (json['user_total_count'] as num?)?.toInt() ?? 0, isJoined: json['is_joined'] as bool? ?? false, ); @@ -33,7 +34,7 @@ class AccumulatorGroupModel { groupAccumulatorId: groupAccumulatorId, groupId: groupId, title: title, - imageKey: imageKey, + image: image?.toResponsiveImage(), userTotalCount: userTotalCount, isJoined: isJoined, ); diff --git a/lib/features/mala/domain/entities/accumulator_group.dart b/lib/features/mala/domain/entities/accumulator_group.dart index 0758a99e..b2220239 100644 --- a/lib/features/mala/domain/entities/accumulator_group.dart +++ b/lib/features/mala/domain/entities/accumulator_group.dart @@ -1,4 +1,5 @@ import 'package:equatable/equatable.dart'; +import 'package:flutter_pecha/shared/domain/value_objects/responsive_image.dart'; /// A group accumulator linked to a preset, from /// `GET /accumulators/{accumulator_id}/groups`. @@ -9,13 +10,13 @@ class AccumulatorGroup extends Equatable { required this.userTotalCount, required this.isJoined, this.title, - this.imageKey, + this.image, }); final String groupAccumulatorId; final String groupId; final String? title; - final String? imageKey; + final ResponsiveImage? image; final int userTotalCount; final bool isJoined; @@ -24,7 +25,7 @@ class AccumulatorGroup extends Equatable { groupAccumulatorId, groupId, title, - imageKey, + image, userTotalCount, isJoined, ]; diff --git a/lib/features/mala/presentation/widgets/group_accumulations_bar.dart b/lib/features/mala/presentation/widgets/group_accumulations_bar.dart index 967b4d75..02bd6f53 100644 --- a/lib/features/mala/presentation/widgets/group_accumulations_bar.dart +++ b/lib/features/mala/presentation/widgets/group_accumulations_bar.dart @@ -1,19 +1,21 @@ import 'package:flutter/material.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/mala/domain/entities/accumulator_group.dart'; import 'package:flutter_pecha/features/mala/presentation/providers/accumulator_groups_provider.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; /// Pill entry point for group accumulations on the mala screen. /// -/// Visible only when `GET /accumulators/{presetId}/groups?joined_only=true` -/// returns at least one group; otherwise collapses to zero height. +/// 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. class GroupAccumulationsBar extends ConsumerWidget { const GroupAccumulationsBar({super.key, required this.presetId}); final String presetId; + static const barHeight = 40.0; static const _avatarSize = 28.0; static const _avatarOverlap = 10.0; @@ -21,17 +23,20 @@ class GroupAccumulationsBar extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final groupsAsync = ref.watch(joinedAccumulatorGroupsProvider(presetId)); - return groupsAsync.when( - data: (groups) { - if (groups.isEmpty) return const SizedBox.shrink(); - return _GroupAccumulationsBarContent( - groups: groups, - avatarSize: _avatarSize, - avatarOverlap: _avatarOverlap, - ); - }, - loading: () => const SizedBox.shrink(), - error: (_, __) => const SizedBox.shrink(), + return SizedBox( + height: barHeight, + child: groupsAsync.when( + data: (groups) { + if (groups.isEmpty) return const SizedBox.shrink(); + return _GroupAccumulationsBarContent( + groups: groups, + avatarSize: _avatarSize, + avatarOverlap: _avatarOverlap, + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, __) => const SizedBox.shrink(), + ), ); } } @@ -52,9 +57,7 @@ class _GroupAccumulationsBarContent extends StatelessWidget { final iconColor = Theme.of(context).colorScheme.onSurfaceVariant; final preview = groups.take(2).toList(); final stackWidth = - preview.length == 1 - ? avatarSize - : avatarSize + avatarOverlap; + preview.length == 1 ? avatarSize : avatarSize + avatarOverlap; return Align( alignment: Alignment.centerLeft, @@ -67,7 +70,7 @@ class _GroupAccumulationsBarContent extends StatelessWidget { // Group accumulations detail navigation will be wired separately. }, child: Container( - height: 40, + height: GroupAccumulationsBar.barHeight, padding: const EdgeInsets.fromLTRB(6, 6, 8, 6), child: Row( mainAxisSize: MainAxisSize.min, @@ -107,7 +110,6 @@ class _GroupAvatar extends StatelessWidget { @override Widget build(BuildContext context) { - final imageUrl = _resolveImageUrl(group.imageKey); final fallbackColor = Theme.of(context).colorScheme.surfaceContainerHighest; @@ -120,23 +122,12 @@ class _GroupAvatar extends StatelessWidget { border: Border.all(color: AppColors.surfaceWhite, width: 1.5), ), clipBehavior: Clip.antiAlias, - child: - imageUrl != null - ? CachedNetworkImageWidget( - imageUrl: imageUrl, - width: size, - height: size, - fit: BoxFit.cover, - ) - : null, + child: ResponsiveCoverImage( + image: group.image, + width: size, + height: size, + fit: BoxFit.cover, + ), ); } - - String? _resolveImageUrl(String? imageKey) { - if (imageKey == null || imageKey.isEmpty) return null; - if (imageKey.startsWith('http://') || imageKey.startsWith('https://')) { - return imageKey; - } - return null; - } } From 9b9a3908f13183dcca38a2f15144c47a51ac2987 Mon Sep 17 00:00:00 2001 From: tentamdin Date: Wed, 1 Jul 2026 17:48:25 +0530 Subject: [PATCH 20/27] feat: add group accumulation sheet and localization for group accumulations --- lib/core/l10n/app_bo.arb | 2 + lib/core/l10n/app_en.arb | 2 + lib/core/l10n/app_hi.arb | 2 + lib/core/l10n/app_mn.arb | 2 + lib/core/l10n/app_ne.arb | 2 + lib/core/l10n/app_zh.arb | 2 + .../l10n/generated/app_localizations.dart | 12 + .../l10n/generated/app_localizations_bo.dart | 6 + .../l10n/generated/app_localizations_en.dart | 6 + .../l10n/generated/app_localizations_hi.dart | 6 + .../l10n/generated/app_localizations_mn.dart | 6 + .../l10n/generated/app_localizations_ne.dart | 6 + .../l10n/generated/app_localizations_zh.dart | 6 + lib/features/mala/README.md | 16 +- .../presentation/screens/mala_screen.dart | 5 +- .../widgets/group_accumulations_bar.dart | 20 +- .../widgets/group_accumulations_sheet.dart | 314 ++++++++++++++++++ 17 files changed, 409 insertions(+), 6 deletions(-) create mode 100644 lib/features/mala/presentation/widgets/group_accumulations_sheet.dart diff --git a/lib/core/l10n/app_bo.arb b/lib/core/l10n/app_bo.arb index 7868b169..e1aa3617 100644 --- a/lib/core/l10n/app_bo.arb +++ b/lib/core/l10n/app_bo.arb @@ -87,6 +87,8 @@ } } }, + "mala_group_accumulations": "Group accumulations", + "mala_groups_section": "Groups", "home_timer": "སྒོམ་ཐུན།", "preset_timers": "སྔོན་སྒྲིག་དུས་ཚོད།", "meditation_timer": "སྒོམ་ཐུན།", diff --git a/lib/core/l10n/app_en.arb b/lib/core/l10n/app_en.arb index 3d241281..84f47ee2 100644 --- a/lib/core/l10n/app_en.arb +++ b/lib/core/l10n/app_en.arb @@ -87,6 +87,8 @@ } } }, + "mala_group_accumulations": "Group accumulations", + "mala_groups_section": "Groups", "home_timer": "Timer", "preset_timers": "Preset timers", "meditation_timer": "Meditation Timer", diff --git a/lib/core/l10n/app_hi.arb b/lib/core/l10n/app_hi.arb index 6559faec..f102bf38 100644 --- a/lib/core/l10n/app_hi.arb +++ b/lib/core/l10n/app_hi.arb @@ -87,6 +87,8 @@ } } }, + "mala_group_accumulations": "Group accumulations", + "mala_groups_section": "Groups", "home_timer": "टाइमर", "preset_timers": "पूर्व-निर्धारित टाइमर", "meditation_timer": "ध्यान टाइमर", diff --git a/lib/core/l10n/app_mn.arb b/lib/core/l10n/app_mn.arb index a73ccf3a..ebdb96bf 100644 --- a/lib/core/l10n/app_mn.arb +++ b/lib/core/l10n/app_mn.arb @@ -87,6 +87,8 @@ } } }, + "mala_group_accumulations": "Group accumulations", + "mala_groups_section": "Groups", "home_timer": "Цаг хэмжигч", "preset_timers": "Бэлэн тохируулсан цаг", "meditation_timer": "Бясалгалын цаг хэмжигч", diff --git a/lib/core/l10n/app_ne.arb b/lib/core/l10n/app_ne.arb index b35a0419..79c90145 100644 --- a/lib/core/l10n/app_ne.arb +++ b/lib/core/l10n/app_ne.arb @@ -87,6 +87,8 @@ } } }, + "mala_group_accumulations": "Group accumulations", + "mala_groups_section": "Groups", "home_timer": "टाइमर", "preset_timers": "पूर्वनिर्धारित टाइमरहरू", "meditation_timer": "ध्यान टाइमर", diff --git a/lib/core/l10n/app_zh.arb b/lib/core/l10n/app_zh.arb index 110dc301..0ef74bd0 100644 --- a/lib/core/l10n/app_zh.arb +++ b/lib/core/l10n/app_zh.arb @@ -87,6 +87,8 @@ } } }, + "mala_group_accumulations": "Group accumulations", + "mala_groups_section": "Groups", "home_timer": "計時", "preset_timers": "預設計時", "meditation_timer": "禪修計時", diff --git a/lib/core/l10n/generated/app_localizations.dart b/lib/core/l10n/generated/app_localizations.dart index f4a10855..efe1da9c 100644 --- a/lib/core/l10n/generated/app_localizations.dart +++ b/lib/core/l10n/generated/app_localizations.dart @@ -424,6 +424,18 @@ abstract class AppLocalizations { /// **'Count {bead} of {total}, {rounds}'** String mala_counter_semantics(int bead, int total, String rounds); + /// No description provided for @mala_group_accumulations. + /// + /// In en, this message translates to: + /// **'Group accumulations'** + String get mala_group_accumulations; + + /// No description provided for @mala_groups_section. + /// + /// In en, this message translates to: + /// **'Groups'** + String get mala_groups_section; + /// No description provided for @home_timer. /// /// 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 71efb61c..31fbade9 100644 --- a/lib/core/l10n/generated/app_localizations_bo.dart +++ b/lib/core/l10n/generated/app_localizations_bo.dart @@ -193,6 +193,12 @@ class AppLocalizationsBo extends AppLocalizations { return 'གྲངས་ཀ $bead/$total, $rounds'; } + @override + String get mala_group_accumulations => 'Group accumulations'; + + @override + String get mala_groups_section => 'Groups'; + @override String get home_timer => 'སྒོམ་ཐུན།'; diff --git a/lib/core/l10n/generated/app_localizations_en.dart b/lib/core/l10n/generated/app_localizations_en.dart index a01ad149..2c8a7fac 100644 --- a/lib/core/l10n/generated/app_localizations_en.dart +++ b/lib/core/l10n/generated/app_localizations_en.dart @@ -191,6 +191,12 @@ class AppLocalizationsEn extends AppLocalizations { return 'Count $bead of $total, $rounds'; } + @override + String get mala_group_accumulations => 'Group accumulations'; + + @override + String get mala_groups_section => 'Groups'; + @override String get home_timer => 'Timer'; diff --git a/lib/core/l10n/generated/app_localizations_hi.dart b/lib/core/l10n/generated/app_localizations_hi.dart index 72448463..922a2727 100644 --- a/lib/core/l10n/generated/app_localizations_hi.dart +++ b/lib/core/l10n/generated/app_localizations_hi.dart @@ -191,6 +191,12 @@ class AppLocalizationsHi extends AppLocalizations { return 'गिनती $bead/$total, $rounds'; } + @override + String get mala_group_accumulations => 'Group accumulations'; + + @override + String get mala_groups_section => 'Groups'; + @override String get home_timer => 'टाइमर'; diff --git a/lib/core/l10n/generated/app_localizations_mn.dart b/lib/core/l10n/generated/app_localizations_mn.dart index 89209900..535cbd96 100644 --- a/lib/core/l10n/generated/app_localizations_mn.dart +++ b/lib/core/l10n/generated/app_localizations_mn.dart @@ -192,6 +192,12 @@ class AppLocalizationsMn extends AppLocalizations { return 'Тоол $bead/$total, $rounds'; } + @override + String get mala_group_accumulations => 'Group accumulations'; + + @override + String get mala_groups_section => 'Groups'; + @override String get home_timer => 'Цаг хэмжигч'; diff --git a/lib/core/l10n/generated/app_localizations_ne.dart b/lib/core/l10n/generated/app_localizations_ne.dart index 81a61d2a..4ac21d92 100644 --- a/lib/core/l10n/generated/app_localizations_ne.dart +++ b/lib/core/l10n/generated/app_localizations_ne.dart @@ -192,6 +192,12 @@ class AppLocalizationsNe extends AppLocalizations { return 'गणना $bead/$total, $rounds'; } + @override + String get mala_group_accumulations => 'Group accumulations'; + + @override + String get mala_groups_section => 'Groups'; + @override String get home_timer => 'टाइमर'; diff --git a/lib/core/l10n/generated/app_localizations_zh.dart b/lib/core/l10n/generated/app_localizations_zh.dart index 3dc3017f..37da9ffe 100644 --- a/lib/core/l10n/generated/app_localizations_zh.dart +++ b/lib/core/l10n/generated/app_localizations_zh.dart @@ -182,6 +182,12 @@ class AppLocalizationsZh extends AppLocalizations { return '計數 $bead/$total,$rounds'; } + @override + String get mala_group_accumulations => 'Group accumulations'; + + @override + String get mala_groups_section => 'Groups'; + @override String get home_timer => '計時'; diff --git a/lib/features/mala/README.md b/lib/features/mala/README.md index b46214f1..e510aad2 100644 --- a/lib/features/mala/README.md +++ b/lib/features/mala/README.md @@ -40,6 +40,7 @@ presentation/ mala_beads.dart the tappable bead-arc CustomPainter mantra_switcher.dart infinite looping carousel (swipe + ‹ › chevrons) group_accumulations_bar.dart joined-group pill with overlapping avatars + group_accumulations_sheet.dart group accumulations bottom sheet mala_settings_sheet.dart sound/vibration, reset, bookmark, add accumulation mala_skeleton.dart loading placeholder ``` @@ -236,7 +237,7 @@ Entry point for group counting tied to the current preset. Fetched per mantra vi (`GroupAccumulationsBar.barHeight`) so the bead arc does not jump when the request resolves. - **Preview:** up to two overlapping circular avatars (28px, 10px overlap) plus - a chevron. Tap handler is stubbed for future detail navigation. + a chevron. Tapping opens [GroupAccumulationsSheet] with the cached groups list. - **Images:** group `image` is parsed as `ImageUrlModel` (`thumbnail` / `medium` / `original`) via `ImageModel.fromJsonMap()` and mapped to `ResponsiveImage` on the entity. Avatars render through `ResponsiveCoverImage` @@ -249,6 +250,19 @@ Entry point for group counting tied to the current preset. Fetched per mantra vi On API failure the provider returns an empty list (bar stays hidden, slot 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`). + +- **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 uses the on-screen mala total. +- **Groups list:** each row shows group image, title, and `userTotalCount` + from the groups API response. +- **Styling:** user name/count in `AppColors.blue` (light) / + `AppColors.blueDark` (dark); group rows use default on-surface text. + ## Analytics `mala_screen_opened`, `mala_mantra_switched`, `mala_round_completed`, diff --git a/lib/features/mala/presentation/screens/mala_screen.dart b/lib/features/mala/presentation/screens/mala_screen.dart index 348c2728..f2ef2132 100644 --- a/lib/features/mala/presentation/screens/mala_screen.dart +++ b/lib/features/mala/presentation/screens/mala_screen.dart @@ -209,7 +209,10 @@ class _MalaScreenState extends ConsumerState { ), ), const SizedBox(height: 12), - GroupAccumulationsBar(presetId: mantra.presetId), + GroupAccumulationsBar( + presetId: mantra.presetId, + userTotalCount: counter.total, + ), ], ), ), diff --git a/lib/features/mala/presentation/widgets/group_accumulations_bar.dart b/lib/features/mala/presentation/widgets/group_accumulations_bar.dart index 02bd6f53..7295a03b 100644 --- a/lib/features/mala/presentation/widgets/group_accumulations_bar.dart +++ b/lib/features/mala/presentation/widgets/group_accumulations_bar.dart @@ -3,6 +3,7 @@ import 'package:flutter_pecha/core/theme/app_colors.dart'; import 'package:flutter_pecha/core/widgets/responsive_cover_image.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'; import 'package:flutter_riverpod/flutter_riverpod.dart'; /// Pill entry point for group accumulations on the mala screen. @@ -11,9 +12,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; /// request resolves. The pill is shown only when /// `GET /accumulators/{presetId}/groups?joined_only=true` returns groups. class GroupAccumulationsBar extends ConsumerWidget { - const GroupAccumulationsBar({super.key, required this.presetId}); + const GroupAccumulationsBar({ + super.key, + required this.presetId, + required this.userTotalCount, + }); final String presetId; + final int userTotalCount; static const barHeight = 40.0; static const _avatarSize = 28.0; @@ -30,6 +36,7 @@ class GroupAccumulationsBar extends ConsumerWidget { if (groups.isEmpty) return const SizedBox.shrink(); return _GroupAccumulationsBarContent( groups: groups, + userTotalCount: userTotalCount, avatarSize: _avatarSize, avatarOverlap: _avatarOverlap, ); @@ -44,11 +51,13 @@ class GroupAccumulationsBar extends ConsumerWidget { class _GroupAccumulationsBarContent extends StatelessWidget { const _GroupAccumulationsBarContent({ required this.groups, + required this.userTotalCount, required this.avatarSize, required this.avatarOverlap, }); final List groups; + final int userTotalCount; final double avatarSize; final double avatarOverlap; @@ -66,9 +75,12 @@ class _GroupAccumulationsBarContent extends StatelessWidget { borderRadius: BorderRadius.circular(20), child: InkWell( borderRadius: BorderRadius.circular(20), - onTap: () { - // Group accumulations detail navigation will be wired separately. - }, + onTap: + () => GroupAccumulationsSheet.show( + context, + groups: groups, + userTotalCount: userTotalCount, + ), child: Container( height: GroupAccumulationsBar.barHeight, padding: const EdgeInsets.fromLTRB(6, 6, 8, 6), diff --git a/lib/features/mala/presentation/widgets/group_accumulations_sheet.dart b/lib/features/mala/presentation/widgets/group_accumulations_sheet.dart new file mode 100644 index 00000000..9aaecb2e --- /dev/null +++ b/lib/features/mala/presentation/widgets/group_accumulations_sheet.dart @@ -0,0 +1,314 @@ +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/l10n/intl_format_locale.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/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_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +class GroupAccumulationsSheet extends ConsumerStatefulWidget { + const GroupAccumulationsSheet({ + super.key, + required this.groups, + required this.userTotalCount, + }); + + final List groups; + final int userTotalCount; + + static Future show( + BuildContext context, { + required List groups, + required int userTotalCount, + }) { + return showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + useRootNavigator: true, + builder: + (_) => GroupAccumulationsSheet( + groups: groups, + userTotalCount: userTotalCount, + ), + ); + } + + @override + ConsumerState createState() => + _GroupAccumulationsSheetState(); +} + +class _GroupAccumulationsSheetState + extends ConsumerState { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _ensureUserLoaded()); + } + + /// Profile is cached locally after login; refresh from GET /users/info when + /// the sheet opens and no cached user is available yet. + void _ensureUserLoaded() { + final userState = ref.read(userProvider); + if (userState.user == null && !userState.isLoading) { + ref.read(userProvider.notifier).refreshUser(); + } + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final user = ref.watch(userProvider).user; + final accentColor = isDark ? AppColors.blueDark : AppColors.blue; + final dividerColor = isDark ? AppColors.cardBorderDark : AppColors.grey300; + final locale = intlFormatLocaleOf(context); + final formattedUserCount = NumberFormat.decimalPattern( + locale, + ).format(widget.userTotalCount); + + return Container( + decoration: BoxDecoration( + color: isDark ? AppColors.cardBackgroundDark : AppColors.goldLight, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + child: SafeArea( + top: false, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.75, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 12), + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.25), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + 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), + _UserAccumulationRow( + user: user, + formattedCount: formattedUserCount, + accentColor: accentColor, + ), + const SizedBox(height: 12), + Divider(height: 1, thickness: 1, color: dividerColor), + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Text( + context.l10n.mala_groups_section, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + Flexible( + child: ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.only(bottom: 8), + itemCount: widget.groups.length, + separatorBuilder: + (_, __) => Divider( + height: 1, + thickness: 1, + indent: 20, + endIndent: 20, + color: dividerColor, + ), + itemBuilder: (context, index) { + return _GroupAccumulationRow( + group: widget.groups[index], + locale: locale, + ); + }, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _UserAccumulationRow extends StatelessWidget { + const _UserAccumulationRow({ + required this.user, + required this.formattedCount, + required this.accentColor, + }); + + final User? user; + final String formattedCount; + final Color accentColor; + + @override + Widget build(BuildContext context) { + final nameStyle = Theme.of(context).textTheme.bodyLarge?.copyWith( + color: accentColor, + fontWeight: FontWeight.w500, + ); + final countStyle = Theme.of(context).textTheme.titleMedium?.copyWith( + color: accentColor, + fontWeight: FontWeight.w700, + ); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + _UserAvatar(avatarUrl: user?.avatarUrl), + const SizedBox(width: 12), + Expanded( + child: Text( + user != null ? _userDisplayName(user!) : '—', + style: nameStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Text(formattedCount, style: countStyle), + ], + ), + ); + } + + String _userDisplayName(User user) { + final parts = + [ + user.firstName, + user.lastName, + ].whereType().where((name) => name.isNotEmpty).toList(); + if (parts.isNotEmpty) return parts.join(' '); + return user.displayName; + } +} + +class _UserAvatar extends StatelessWidget { + const _UserAvatar({this.avatarUrl}); + + final String? avatarUrl; + + static const _size = 40.0; + + @override + Widget build(BuildContext context) { + final fallbackColor = Theme.of(context).colorScheme.surfaceContainerHighest; + + return ClipOval( + child: SizedBox( + width: _size, + height: _size, + child: + avatarUrl != null && avatarUrl!.isNotEmpty + ? CachedNetworkImageWidget( + imageUrl: avatarUrl, + width: _size, + height: _size, + fit: BoxFit.cover, + ) + : ColoredBox( + color: fallbackColor, + child: Icon( + AppAssets.profile, + size: 22, + color: AppColors.grey600, + ), + ), + ), + ); + } +} + +class _GroupAccumulationRow extends StatelessWidget { + const _GroupAccumulationRow({required this.group, required this.locale}); + + final AccumulatorGroup group; + final String locale; + + static const _avatarSize = 40.0; + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final placeholderColor = + isDark ? AppColors.surfaceVariantDark : AppColors.grey100; + final formattedCount = NumberFormat.decimalPattern( + locale, + ).format(group.userTotalCount); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipOval( + child: SizedBox( + width: _avatarSize, + height: _avatarSize, + child: + group.image != null && !group.image!.isEmpty + ? ResponsiveCoverImage( + image: group.image, + width: _avatarSize, + height: _avatarSize, + fit: BoxFit.cover, + ) + : ColoredBox( + color: placeholderColor, + child: Icon( + AppAssets.usersThree, + size: 22, + color: isDark ? AppColors.grey500 : AppColors.grey600, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + group.title!.trim(), + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 12), + Text( + formattedCount, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ), + ], + ), + ); + } +} From 2d1acf87423db1c07044b87e604998c07441036a Mon Sep 17 00:00:00 2001 From: TenzDelek Date: Wed, 1 Jul 2026 17:50:31 +0530 Subject: [PATCH 21/27] fix --- .../group_accumulator_providers.dart | 7 +- .../widgets/group_accumulator_card.dart | 64 ++++++++++--------- .../widgets/group_profile_body.dart | 10 +-- 3 files changed, 42 insertions(+), 39 deletions(-) diff --git a/lib/features/group_profile/presentation/providers/group_accumulator_providers.dart b/lib/features/group_profile/presentation/providers/group_accumulator_providers.dart index 5081f854..29ec047a 100644 --- a/lib/features/group_profile/presentation/providers/group_accumulator_providers.dart +++ b/lib/features/group_profile/presentation/providers/group_accumulator_providers.dart @@ -237,6 +237,7 @@ Future joinGroupAccumulator({ required String accumulatorId, required String groupId, GroupProfile? group, + bool awaitRefresh = true, }) async { final repository = ref.read(groupAccumulatorRepositoryProvider); final result = await repository.joinGroupAccumulator(accumulatorId); @@ -266,7 +267,7 @@ Future joinGroupAccumulator({ ref.invalidate(groupAccumulatorsProvider(groupId)); ref.invalidate(groupAccumulatorDetailProvider(accumulatorId)); - await Future.wait([ + final refreshFuture = Future.wait([ ref.read(groupAccumulatorDetailProvider(accumulatorId).future), ref.read(groupAccumulatorsProvider(groupId).future), ref @@ -278,5 +279,9 @@ Future joinGroupAccumulator({ .loadInitial(force: true), ]); + if (awaitRefresh) { + await refreshFuture; + } + return true; } 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 fc49d170..20165d53 100644 --- a/lib/features/group_profile/presentation/widgets/group_accumulator_card.dart +++ b/lib/features/group_profile/presentation/widgets/group_accumulator_card.dart @@ -69,41 +69,45 @@ class GroupAccumulatorCard extends StatelessWidget { ), ), if (showJoinOverlay) - Container( - color: Colors.black.withValues(alpha: 0.55), - alignment: Alignment.center, - child: - isJoining - ? const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: onJoinTap, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 24, - vertical: 12, - ), - decoration: BoxDecoration( + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: isJoining ? null : onTap, + child: Container( + color: Colors.black.withValues(alpha: 0.55), + alignment: Alignment.center, + child: + isJoining + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white, - borderRadius: BorderRadius.circular(999), ), - child: Text( - context.l10n.group_join_to_contribute, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w700, - color: AppColors.textPrimary, + ) + : GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onJoinTap, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + context.l10n.group_join_to_contribute, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), ), ), ), - ), + ), ), ], ), 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 fb485eca..808c9a01 100644 --- a/lib/features/group_profile/presentation/widgets/group_profile_body.dart +++ b/lib/features/group_profile/presentation/widgets/group_profile_body.dart @@ -508,17 +508,11 @@ class _GroupProfileBodyState extends ConsumerState accumulatorId: accumulator.id, groupId: profile.id, group: profile, + awaitRefresh: false, ); if (!mounted) return; - setState(() { - _joiningAccumulatorId = null; - if (ok) { - ref - .read(groupAccumulatorJoinCacheProvider(profile.id).notifier) - .markJoined(accumulator.id); - } - }); + setState(() => _joiningAccumulatorId = null); if (ok) { _navigateToAccumulatorDetail(accumulator.id); From 4314c4b7805b312742cf10fb38599b29d67511a9 Mon Sep 17 00:00:00 2001 From: tentamdin Date: Wed, 1 Jul 2026 21:42:59 +0530 Subject: [PATCH 22/27] feat: implement group accumulation functionality with selection and submission logic --- lib/core/config/protected_routes.dart | 3 + lib/core/l10n/app_bo.arb | 1 + lib/core/l10n/app_en.arb | 1 + lib/core/l10n/app_hi.arb | 1 + lib/core/l10n/app_mn.arb | 1 + lib/core/l10n/app_ne.arb | 1 + lib/core/l10n/app_zh.arb | 1 + .../l10n/generated/app_localizations.dart | 6 + .../l10n/generated/app_localizations_bo.dart | 3 + .../l10n/generated/app_localizations_en.dart | 3 + .../l10n/generated/app_localizations_hi.dart | 3 + .../l10n/generated/app_localizations_mn.dart | 3 + .../l10n/generated/app_localizations_ne.dart | 3 + .../l10n/generated/app_localizations_zh.dart | 3 + lib/core/storage/storage_keys.dart | 3 + lib/features/mala/README.md | 33 ++- .../datasources/mala_remote_datasource.dart | 23 ++ .../repositories/mala_repository_impl.dart | 15 ++ .../domain/entities/accumulator_group.dart | 1 + .../entities/mala_accumulation_selection.dart | 53 ++++ .../domain/repositories/mala_repository.dart | 9 + .../group_accumulation_counts_provider.dart | 91 +++++++ .../mala_accumulation_selection_provider.dart | 54 ++++ .../presentation/screens/mala_screen.dart | 94 ++++++- .../widgets/group_accumulations_bar.dart | 6 +- .../widgets/group_accumulations_sheet.dart | 252 ++++++++++-------- 26 files changed, 537 insertions(+), 130 deletions(-) create mode 100644 lib/features/mala/domain/entities/mala_accumulation_selection.dart create mode 100644 lib/features/mala/presentation/providers/group_accumulation_counts_provider.dart create mode 100644 lib/features/mala/presentation/providers/mala_accumulation_selection_provider.dart diff --git a/lib/core/config/protected_routes.dart b/lib/core/config/protected_routes.dart index 62f70eb3..f7d1e75a 100644 --- a/lib/core/config/protected_routes.dart +++ b/lib/core/config/protected_routes.dart @@ -62,6 +62,9 @@ class ProtectedRoutes { // which are user-scoped and 403 without a token. '/accumulators/', // Catch-all: detail, create (/user), update (/user/{id}) + // Group accumulator counts (submit requires auth). + '/group-accumulators/', // Catch-all: POST count, GET detail, etc. + // Group join / follow '/author/groups/{groupId}/join', '/author/groups/{groupId}/follow', diff --git a/lib/core/l10n/app_bo.arb b/lib/core/l10n/app_bo.arb index e1aa3617..9053f990 100644 --- a/lib/core/l10n/app_bo.arb +++ b/lib/core/l10n/app_bo.arb @@ -89,6 +89,7 @@ }, "mala_group_accumulations": "Group accumulations", "mala_groups_section": "Groups", + "mala_group_untitled": "Untitled group", "home_timer": "སྒོམ་ཐུན།", "preset_timers": "སྔོན་སྒྲིག་དུས་ཚོད།", "meditation_timer": "སྒོམ་ཐུན།", diff --git a/lib/core/l10n/app_en.arb b/lib/core/l10n/app_en.arb index 84f47ee2..f0ff10e3 100644 --- a/lib/core/l10n/app_en.arb +++ b/lib/core/l10n/app_en.arb @@ -89,6 +89,7 @@ }, "mala_group_accumulations": "Group accumulations", "mala_groups_section": "Groups", + "mala_group_untitled": "Untitled group", "home_timer": "Timer", "preset_timers": "Preset timers", "meditation_timer": "Meditation Timer", diff --git a/lib/core/l10n/app_hi.arb b/lib/core/l10n/app_hi.arb index f102bf38..f4391f92 100644 --- a/lib/core/l10n/app_hi.arb +++ b/lib/core/l10n/app_hi.arb @@ -89,6 +89,7 @@ }, "mala_group_accumulations": "Group accumulations", "mala_groups_section": "Groups", + "mala_group_untitled": "Untitled group", "home_timer": "टाइमर", "preset_timers": "पूर्व-निर्धारित टाइमर", "meditation_timer": "ध्यान टाइमर", diff --git a/lib/core/l10n/app_mn.arb b/lib/core/l10n/app_mn.arb index ebdb96bf..32eb884d 100644 --- a/lib/core/l10n/app_mn.arb +++ b/lib/core/l10n/app_mn.arb @@ -89,6 +89,7 @@ }, "mala_group_accumulations": "Group accumulations", "mala_groups_section": "Groups", + "mala_group_untitled": "Untitled group", "home_timer": "Цаг хэмжигч", "preset_timers": "Бэлэн тохируулсан цаг", "meditation_timer": "Бясалгалын цаг хэмжигч", diff --git a/lib/core/l10n/app_ne.arb b/lib/core/l10n/app_ne.arb index 79c90145..ab7940bd 100644 --- a/lib/core/l10n/app_ne.arb +++ b/lib/core/l10n/app_ne.arb @@ -89,6 +89,7 @@ }, "mala_group_accumulations": "Group accumulations", "mala_groups_section": "Groups", + "mala_group_untitled": "Untitled group", "home_timer": "टाइमर", "preset_timers": "पूर्वनिर्धारित टाइमरहरू", "meditation_timer": "ध्यान टाइमर", diff --git a/lib/core/l10n/app_zh.arb b/lib/core/l10n/app_zh.arb index 0ef74bd0..bd34ecc6 100644 --- a/lib/core/l10n/app_zh.arb +++ b/lib/core/l10n/app_zh.arb @@ -89,6 +89,7 @@ }, "mala_group_accumulations": "Group accumulations", "mala_groups_section": "Groups", + "mala_group_untitled": "Untitled group", "home_timer": "計時", "preset_timers": "預設計時", "meditation_timer": "禪修計時", diff --git a/lib/core/l10n/generated/app_localizations.dart b/lib/core/l10n/generated/app_localizations.dart index efe1da9c..aa43ed5a 100644 --- a/lib/core/l10n/generated/app_localizations.dart +++ b/lib/core/l10n/generated/app_localizations.dart @@ -436,6 +436,12 @@ abstract class AppLocalizations { /// **'Groups'** String get mala_groups_section; + /// No description provided for @mala_group_untitled. + /// + /// In en, this message translates to: + /// **'Untitled group'** + String get mala_group_untitled; + /// No description provided for @home_timer. /// /// 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 31fbade9..ebcf7dd7 100644 --- a/lib/core/l10n/generated/app_localizations_bo.dart +++ b/lib/core/l10n/generated/app_localizations_bo.dart @@ -199,6 +199,9 @@ class AppLocalizationsBo extends AppLocalizations { @override String get mala_groups_section => 'Groups'; + @override + String get mala_group_untitled => 'Untitled group'; + @override String get home_timer => 'སྒོམ་ཐུན།'; diff --git a/lib/core/l10n/generated/app_localizations_en.dart b/lib/core/l10n/generated/app_localizations_en.dart index 2c8a7fac..38007580 100644 --- a/lib/core/l10n/generated/app_localizations_en.dart +++ b/lib/core/l10n/generated/app_localizations_en.dart @@ -197,6 +197,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get mala_groups_section => 'Groups'; + @override + String get mala_group_untitled => 'Untitled group'; + @override String get home_timer => 'Timer'; diff --git a/lib/core/l10n/generated/app_localizations_hi.dart b/lib/core/l10n/generated/app_localizations_hi.dart index 922a2727..b0f6f7fd 100644 --- a/lib/core/l10n/generated/app_localizations_hi.dart +++ b/lib/core/l10n/generated/app_localizations_hi.dart @@ -197,6 +197,9 @@ class AppLocalizationsHi extends AppLocalizations { @override String get mala_groups_section => 'Groups'; + @override + String get mala_group_untitled => 'Untitled group'; + @override String get home_timer => 'टाइमर'; diff --git a/lib/core/l10n/generated/app_localizations_mn.dart b/lib/core/l10n/generated/app_localizations_mn.dart index 535cbd96..2e880106 100644 --- a/lib/core/l10n/generated/app_localizations_mn.dart +++ b/lib/core/l10n/generated/app_localizations_mn.dart @@ -198,6 +198,9 @@ class AppLocalizationsMn extends AppLocalizations { @override String get mala_groups_section => 'Groups'; + @override + String get mala_group_untitled => 'Untitled group'; + @override String get home_timer => 'Цаг хэмжигч'; diff --git a/lib/core/l10n/generated/app_localizations_ne.dart b/lib/core/l10n/generated/app_localizations_ne.dart index 4ac21d92..574e7a91 100644 --- a/lib/core/l10n/generated/app_localizations_ne.dart +++ b/lib/core/l10n/generated/app_localizations_ne.dart @@ -198,6 +198,9 @@ class AppLocalizationsNe extends AppLocalizations { @override String get mala_groups_section => 'Groups'; + @override + String get mala_group_untitled => 'Untitled group'; + @override String get home_timer => 'टाइमर'; diff --git a/lib/core/l10n/generated/app_localizations_zh.dart b/lib/core/l10n/generated/app_localizations_zh.dart index 37da9ffe..1aaca8d9 100644 --- a/lib/core/l10n/generated/app_localizations_zh.dart +++ b/lib/core/l10n/generated/app_localizations_zh.dart @@ -188,6 +188,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get mala_groups_section => 'Groups'; + @override + String get mala_group_untitled => 'Untitled group'; + @override String get home_timer => '計時'; diff --git a/lib/core/storage/storage_keys.dart b/lib/core/storage/storage_keys.dart index fed910d5..ab28142d 100644 --- a/lib/core/storage/storage_keys.dart +++ b/lib/core/storage/storage_keys.dart @@ -99,6 +99,9 @@ class StorageKeys { static const String malaSoundEnabled = 'mala_sound_enabled'; /// Haptic feedback on the mala counter. Default: true. static const String malaVibrationEnabled = 'mala_vibration_enabled'; + /// Per-preset accumulation source: `personal` or `group:{uuid}`. + static const String malaAccumulationSelectionPrefix = + 'mala_accumulation_selection_'; // ========== BUSINESS LOGIC ========== /// Last profile update timestamp diff --git a/lib/features/mala/README.md b/lib/features/mala/README.md index e510aad2..e344a811 100644 --- a/lib/features/mala/README.md +++ b/lib/features/mala/README.md @@ -61,6 +61,7 @@ fetched by authenticated users, so the catch-all is safe. | `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`. | `mala_image_url` appears at both the accumulator and mantra level; it drives the bead artwork (see below). @@ -125,7 +126,14 @@ count stays at 0 even if the parent detail echoes a non-zero `current_count`. Hive box `mala_counts`, keys `userId:presetId`, value = JSON `LocalMalaState` (`total`, `syncedTotal`, `accumulatorId`, `beadImageUrl`). `isDirty = total > syncedTotal`. `pruneSynced` removes fully-synced entries (keeps dirty -tails). Opened once in app bootstrap via `MalaLocalDataSource.init()`. +tails). + +Group counts use a separate Hive box `mala_group_counts`, keys +`userId:groupAccumulatorId`, value = JSON `LocalGroupMalaState` (`total`, +`syncedTotal`). Same dirty model; flushed by [MalaSyncManager] via +`POST /group-accumulators/{id}`. + +Opened once in app bootstrap via `MalaLocalDataSource.init()`. ## Bead artwork & caching @@ -238,6 +246,13 @@ Entry point for group counting tied to the current preset. Fetched per mantra vi request resolves. - **Preview:** up to two overlapping circular avatars (28px, 10px overlap) plus 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. - **Images:** group `image` is parsed as `ImageUrlModel` (`thumbnail` / `medium` / `original`) via `ImageModel.fromJsonMap()` and mapped to `ResponsiveImage` on the entity. Avatars render through `ResponsiveCoverImage` @@ -257,11 +272,14 @@ and the user's personal 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 uses the on-screen mala total. -- **Groups list:** each row shows group image, title, and `userTotalCount` - from the groups API response. -- **Styling:** user name/count in `AppColors.blue` (light) / - `AppColors.blueDark` (dark); group rows use default on-surface text. + 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. +- **Persistence:** selection survives app restarts via + `StorageKeys.malaAccumulationSelectionPrefix` + preset id. Invalid group ids + fall back to personal when the groups list reloads. ## Analytics @@ -274,7 +292,8 @@ and the user's personal count for the current preset (`MalaCounterState.total`). offline-tail preservation, no-op while seeding, monotonic increment, round 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. + `max()` adoption, dirty-on-failure, logged-out no-op, per-user namespacing, + group count POST flush + dirty-on-failure. - `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_remote_datasource.dart b/lib/features/mala/data/datasources/mala_remote_datasource.dart index 370cf4d8..cbc5bde0 100644 --- a/lib/features/mala/data/datasources/mala_remote_datasource.dart +++ b/lib/features/mala/data/datasources/mala_remote_datasource.dart @@ -171,6 +171,29 @@ class MalaRemoteDataSource { } } + /// `POST /group-accumulators/{group_accumulator_id}` — submit absolute count. + /// + /// Body: `{ "current_count": }` ([SubmitGroupCountRequest]). + /// Path param is [groupAccumulatorId] (`group_accumulator_id` from the groups + /// list API), not the parent `group_id`. + Future submitGroupCount( + String groupAccumulatorId, + int currentCount, + ) async { + try { + final response = await dio.post( + '/group-accumulators/$groupAccumulatorId', + data: {'current_count': currentCount}, + ); + final status = response.statusCode; + if (status != null && status >= 200 && status < 300) return; + throw _statusToException(status, 'Failed to submit group count'); + } on DioException catch (e) { + _logger.error('Dio error in submitGroupCount', e); + throw _dioToException(e, 'Failed to submit group count'); + } + } + Future> fetchImageBytes(String url) async { try { final response = await dio.get>( diff --git a/lib/features/mala/data/repositories/mala_repository_impl.dart b/lib/features/mala/data/repositories/mala_repository_impl.dart index 4822a8f9..81e24ed0 100644 --- a/lib/features/mala/data/repositories/mala_repository_impl.dart +++ b/lib/features/mala/data/repositories/mala_repository_impl.dart @@ -128,6 +128,21 @@ class MalaRepositoryImpl implements MalaRepository { } } + @override + Future> submitGroupAccumulatorCount({ + required String groupAccumulatorId, + required int currentCount, + }) async { + try { + await remote.submitGroupCount(groupAccumulatorId, currentCount); + return const Right(unit); + } on AppException catch (e) { + return Left(_toFailure(e)); + } catch (e) { + return Left(UnknownFailure('Failed to submit group count: $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 b2220239..fef70d46 100644 --- a/lib/features/mala/domain/entities/accumulator_group.dart +++ b/lib/features/mala/domain/entities/accumulator_group.dart @@ -17,6 +17,7 @@ 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). final int userTotalCount; final bool isJoined; diff --git a/lib/features/mala/domain/entities/mala_accumulation_selection.dart b/lib/features/mala/domain/entities/mala_accumulation_selection.dart new file mode 100644 index 00000000..436846b5 --- /dev/null +++ b/lib/features/mala/domain/entities/mala_accumulation_selection.dart @@ -0,0 +1,53 @@ +import 'package:equatable/equatable.dart'; + +/// Which accumulation total the mala counter displays and increments. +sealed class MalaAccumulationSelection extends Equatable { + const MalaAccumulationSelection(); + + const factory MalaAccumulationSelection.personal() = + PersonalAccumulationSelection; + + factory MalaAccumulationSelection.group(String groupAccumulatorId) { + return GroupAccumulationSelection(groupAccumulatorId); + } + + bool get isPersonal => this is PersonalAccumulationSelection; + + String? get groupAccumulatorId => + switch (this) { + PersonalAccumulationSelection() => null, + GroupAccumulationSelection(id: final id) => id, + }; + + static MalaAccumulationSelection fromStorage(String? raw) { + if (raw == null || raw.isEmpty || raw == 'personal') { + return const MalaAccumulationSelection.personal(); + } + if (raw.startsWith('group:')) { + final id = raw.substring('group:'.length); + if (id.isNotEmpty) return MalaAccumulationSelection.group(id); + } + return const MalaAccumulationSelection.personal(); + } + + String toStorage() => switch (this) { + PersonalAccumulationSelection() => 'personal', + GroupAccumulationSelection(id: final id) => 'group:$id', + }; +} + +final class PersonalAccumulationSelection extends MalaAccumulationSelection { + const PersonalAccumulationSelection(); + + @override + List get props => const []; +} + +final class GroupAccumulationSelection extends MalaAccumulationSelection { + const GroupAccumulationSelection(this.id); + + final String id; + + @override + List get props => [id]; +} diff --git a/lib/features/mala/domain/repositories/mala_repository.dart b/lib/features/mala/domain/repositories/mala_repository.dart index 60041fb1..c05a8981 100644 --- a/lib/features/mala/domain/repositories/mala_repository.dart +++ b/lib/features/mala/domain/repositories/mala_repository.dart @@ -40,4 +40,13 @@ abstract class MalaRepository { Future>> getJoinedAccumulatorGroups( String accumulatorId, ); + + /// Submit the user's absolute count for a group accumulator + /// (`POST /group-accumulators/{group_accumulator_id}`, body + /// `{current_count}`). [groupAccumulatorId] is the UUID from + /// `AccumulatorGroup.groupAccumulatorId`, not `groupId`. + Future> submitGroupAccumulatorCount({ + required String groupAccumulatorId, + required int currentCount, + }); } diff --git a/lib/features/mala/presentation/providers/group_accumulation_counts_provider.dart b/lib/features/mala/presentation/providers/group_accumulation_counts_provider.dart new file mode 100644 index 00000000..988596e6 --- /dev/null +++ b/lib/features/mala/presentation/providers/group_accumulation_counts_provider.dart @@ -0,0 +1,91 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_pecha/core/utils/app_logger.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'; + +final _logger = AppLogger('GroupAccumulationCountsNotifier'); + +/// Local group [userTotalCount] values keyed by [AccumulatorGroup.groupAccumulatorId]. +/// +/// Seeded from the groups API and incremented when the user counts while a group +/// is selected. Syncs absolute totals via `POST /group-accumulators/{id}`. +class GroupAccumulationCountsNotifier extends StateNotifier> { + GroupAccumulationCountsNotifier(this._ref) : super(const {}); + + final Ref _ref; + + void mergeFromApi(List groups) { + if (groups.isEmpty) return; + final next = Map.from(state); + var changed = false; + for (final group in groups) { + final id = group.groupAccumulatorId; + final merged = _maxCount(next[id], group.userTotalCount); + if (merged != next[id]) { + next[id] = merged; + changed = true; + } + } + if (changed) state = next; + } + + int countFor(String groupAccumulatorId, List groups) { + final local = state[groupAccumulatorId]; + if (local != null) return local; + for (final group in groups) { + if (group.groupAccumulatorId == groupAccumulatorId) { + return group.userTotalCount; + } + } + return 0; + } + + void increment({ + required String groupAccumulatorId, + required List groups, + required bool soundEnabled, + required bool vibrationEnabled, + required int beadsPerRound, + }) { + final current = countFor(groupAccumulatorId, groups); + final newTotal = current + 1; + state = {...state, groupAccumulatorId: newTotal}; + + final roundComplete = newTotal % beadsPerRound == 0; + if (soundEnabled) { + _ref.read(malaSoundPlayerProvider).play(); + } + if (vibrationEnabled) { + HapticFeedback.lightImpact(); + if (roundComplete) HapticFeedback.mediumImpact(); + } + + _syncCount(groupAccumulatorId, newTotal); + } + + Future _syncCount(String groupAccumulatorId, int currentCount) async { + final result = await _ref + .read(malaRepositoryProvider) + .submitGroupAccumulatorCount( + groupAccumulatorId: groupAccumulatorId, + currentCount: currentCount, + ); + result.fold( + (failure) => _logger.warning( + 'Failed to sync group count ($groupAccumulatorId): ${failure.message}', + ), + (_) {}, + ); + } + + int _maxCount(int? a, int b) { + if (a == null) return b; + return a > b ? a : b; + } +} + +final groupAccumulationCountsProvider = StateNotifierProvider.autoDispose + .family, String>( + (ref, presetId) => GroupAccumulationCountsNotifier(ref), + ); diff --git a/lib/features/mala/presentation/providers/mala_accumulation_selection_provider.dart b/lib/features/mala/presentation/providers/mala_accumulation_selection_provider.dart new file mode 100644 index 00000000..0c0d3bfd --- /dev/null +++ b/lib/features/mala/presentation/providers/mala_accumulation_selection_provider.dart @@ -0,0 +1,54 @@ +import 'package:flutter_pecha/core/storage/storage_keys.dart'; +import 'package:flutter_pecha/features/mala/domain/entities/accumulator_group.dart'; +import 'package:flutter_pecha/features/mala/domain/entities/mala_accumulation_selection.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class MalaAccumulationSelectionNotifier + extends StateNotifier { + MalaAccumulationSelectionNotifier(this._presetId) + : super(const MalaAccumulationSelection.personal()) { + _load(); + } + + final String _presetId; + + Future _load() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString( + '${StorageKeys.malaAccumulationSelectionPrefix}$_presetId', + ); + state = MalaAccumulationSelection.fromStorage(raw); + } + + Future selectPersonal() async { + state = const MalaAccumulationSelection.personal(); + await _persist(); + } + + Future selectGroup(String groupAccumulatorId) async { + state = MalaAccumulationSelection.group(groupAccumulatorId); + await _persist(); + } + + /// Falls back to personal when the saved group is no longer joined. + Future validateAgainst(List groups) async { + final id = state.groupAccumulatorId; + if (id == null) return; + final stillJoined = groups.any((g) => g.groupAccumulatorId == id); + if (!stillJoined) await selectPersonal(); + } + + Future _persist() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + '${StorageKeys.malaAccumulationSelectionPrefix}$_presetId', + state.toStorage(), + ); + } +} + +final malaAccumulationSelectionProvider = StateNotifierProvider.autoDispose + .family( + (ref, presetId) => MalaAccumulationSelectionNotifier(presetId), + ); diff --git a/lib/features/mala/presentation/screens/mala_screen.dart b/lib/features/mala/presentation/screens/mala_screen.dart index f2ef2132..3e89e126 100644 --- a/lib/features/mala/presentation/screens/mala_screen.dart +++ b/lib/features/mala/presentation/screens/mala_screen.dart @@ -4,7 +4,12 @@ import 'package:flutter_pecha/core/analytics/analytics_providers.dart'; import 'package:flutter_pecha/core/core.dart'; import 'package:flutter_pecha/core/theme/app_colors.dart'; import 'package:flutter_pecha/core/extensions/context_ext.dart'; +import 'package:flutter_pecha/features/mala/domain/entities/accumulator_group.dart'; import 'package:flutter_pecha/features/mala/domain/entities/mantra.dart'; +import 'package:flutter_pecha/features/mala/domain/entities/mala_accumulation_selection.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/group_accumulations_bar.dart'; @@ -129,6 +134,59 @@ class _MalaScreenState extends ConsumerState { final counter = ref.watch(malaCounterProvider(mantra)); final notifier = ref.read(malaCounterProvider(mantra).notifier); final settings = ref.watch(malaSettingsProvider); + final selection = ref.watch( + malaAccumulationSelectionProvider(mantra.presetId), + ); + final groupsAsync = ref.watch( + joinedAccumulatorGroupsProvider(mantra.presetId), + ); + final groups = groupsAsync.valueOrNull ?? const []; + ref.watch(groupAccumulationCountsProvider(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); + }); + }); + + final displayTotal = _displayTotal( + selection: selection, + personalTotal: counter.total, + groups: groups, + groupCountsNotifier: groupCountsNotifier, + ); + final beadsPerRound = counter.beadsPerRound; + final displayBeadInRound = displayTotal % beadsPerRound; + final displayRounds = displayTotal ~/ beadsPerRound; + final countingEnabled = + !counter.isSeeding && + (selection.isPersonal || selection.groupAccumulatorId != null); + + void onBeadTap() { + if (counter.isSeeding) return; + if (selection.isPersonal) { + notifier.incrementBead( + soundEnabled: settings.soundEnabled, + vibrationEnabled: settings.vibrationEnabled, + ); + return; + } + final groupId = selection.groupAccumulatorId; + if (groupId == null || groups.isEmpty) return; + groupCountsNotifier.increment( + groupAccumulatorId: groupId, + groups: groups, + soundEnabled: settings.soundEnabled, + vibrationEnabled: settings.vibrationEnabled, + beadsPerRound: beadsPerRound, + ); + } return Column( children: [ @@ -163,9 +221,9 @@ class _MalaScreenState extends ConsumerState { ), const SizedBox(height: 16), _CounterBlock( - beadInRound: counter.beadInRound, - beadsPerRound: counter.beadsPerRound, - rounds: counter.rounds, + beadInRound: displayBeadInRound, + beadsPerRound: beadsPerRound, + rounds: displayRounds, dimmed: counter.isSeeding, ), const SizedBox(height: 8), @@ -185,23 +243,20 @@ class _MalaScreenState extends ConsumerState { onRetry: notifier.seed, ) : MalaBeads( - key: ValueKey(mantra.presetId), - total: counter.total, - beadInRound: counter.beadInRound, - beadsPerRound: counter.beadsPerRound, - enabled: !counter.isSeeding, + key: ValueKey( + '${mantra.presetId}_${selection.groupAccumulatorId ?? 'personal'}', + ), + total: displayTotal, + beadInRound: displayBeadInRound, + beadsPerRound: beadsPerRound, + enabled: countingEnabled, beadImageUrl: counter.beadImageUrl ?? mantra.beadImageUrl, beadImageBytes: counter.beadImageBytes, beadColor: const Color(0xFF8D6E63), threadColor: const Color(0xFFC62828), - onTap: - () => notifier.incrementBead( - soundEnabled: settings.soundEnabled, - vibrationEnabled: - settings.vibrationEnabled, - ), + onTap: onBeadTap, ), ), ); @@ -220,6 +275,17 @@ class _MalaScreenState extends ConsumerState { ], ); } + + int _displayTotal({ + required MalaAccumulationSelection selection, + required int personalTotal, + required List groups, + required GroupAccumulationCountsNotifier groupCountsNotifier, + }) { + final groupId = selection.groupAccumulatorId; + if (groupId == null || groups.isEmpty) return personalTotal; + return groupCountsNotifier.countFor(groupId, groups); + } } class _CounterBlock extends StatelessWidget { diff --git a/lib/features/mala/presentation/widgets/group_accumulations_bar.dart b/lib/features/mala/presentation/widgets/group_accumulations_bar.dart index 7295a03b..549f3e6a 100644 --- a/lib/features/mala/presentation/widgets/group_accumulations_bar.dart +++ b/lib/features/mala/presentation/widgets/group_accumulations_bar.dart @@ -35,6 +35,7 @@ class GroupAccumulationsBar extends ConsumerWidget { data: (groups) { if (groups.isEmpty) return const SizedBox.shrink(); return _GroupAccumulationsBarContent( + presetId: presetId, groups: groups, userTotalCount: userTotalCount, avatarSize: _avatarSize, @@ -50,12 +51,14 @@ class GroupAccumulationsBar extends ConsumerWidget { class _GroupAccumulationsBarContent extends StatelessWidget { const _GroupAccumulationsBarContent({ + required this.presetId, required this.groups, required this.userTotalCount, required this.avatarSize, required this.avatarOverlap, }); + final String presetId; final List groups; final int userTotalCount; final double avatarSize; @@ -78,8 +81,9 @@ class _GroupAccumulationsBarContent extends StatelessWidget { onTap: () => GroupAccumulationsSheet.show( context, + presetId: presetId, groups: groups, - userTotalCount: userTotalCount, + personalTotalCount: userTotalCount, ), child: Container( height: GroupAccumulationsBar.barHeight, diff --git a/lib/features/mala/presentation/widgets/group_accumulations_sheet.dart b/lib/features/mala/presentation/widgets/group_accumulations_sheet.dart index 9aaecb2e..acd0f88f 100644 --- a/lib/features/mala/presentation/widgets/group_accumulations_sheet.dart +++ b/lib/features/mala/presentation/widgets/group_accumulations_sheet.dart @@ -8,23 +8,28 @@ 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/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'; class GroupAccumulationsSheet extends ConsumerStatefulWidget { const GroupAccumulationsSheet({ super.key, + required this.presetId, required this.groups, - required this.userTotalCount, + required this.personalTotalCount, }); + final String presetId; final List groups; - final int userTotalCount; + final int personalTotalCount; static Future show( BuildContext context, { + required String presetId, required List groups, - required int userTotalCount, + required int personalTotalCount, }) { return showModalBottomSheet( context: context, @@ -33,8 +38,9 @@ class GroupAccumulationsSheet extends ConsumerStatefulWidget { useRootNavigator: true, builder: (_) => GroupAccumulationsSheet( + presetId: presetId, groups: groups, - userTotalCount: userTotalCount, + personalTotalCount: personalTotalCount, ), ); } @@ -52,8 +58,6 @@ class _GroupAccumulationsSheetState WidgetsBinding.instance.addPostFrameCallback((_) => _ensureUserLoaded()); } - /// Profile is cached locally after login; refresh from GET /users/info when - /// the sheet opens and no cached user is available yet. void _ensureUserLoaded() { final userState = ref.read(userProvider); if (userState.user == null && !userState.isLoading) { @@ -65,12 +69,15 @@ class _GroupAccumulationsSheetState Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; final user = ref.watch(userProvider).user; + final selection = ref.watch( + malaAccumulationSelectionProvider(widget.presetId), + ); final accentColor = isDark ? AppColors.blueDark : AppColors.blue; final dividerColor = isDark ? AppColors.cardBorderDark : AppColors.grey300; final locale = intlFormatLocaleOf(context); - final formattedUserCount = NumberFormat.decimalPattern( - locale, - ).format(widget.userTotalCount); + final countsNotifier = ref.read( + groupAccumulationCountsProvider(widget.presetId).notifier, + ); return Container( decoration: BoxDecoration( @@ -111,10 +118,23 @@ class _GroupAccumulationsSheetState ), ), const SizedBox(height: 16), - _UserAccumulationRow( - user: user, - formattedCount: formattedUserCount, + _SelectableAccumulationRow( + isSelected: selection.isPersonal, accentColor: accentColor, + onTap: + () => ref + .read( + malaAccumulationSelectionProvider( + widget.presetId, + ).notifier, + ) + .selectPersonal(), + leading: _UserAvatar(avatarUrl: user?.avatarUrl), + title: + user != null ? _userDisplayName(user) : '—', + formattedCount: NumberFormat.decimalPattern( + locale, + ).format(widget.personalTotalCount), ), const SizedBox(height: 12), Divider(height: 1, thickness: 1, color: dividerColor), @@ -141,9 +161,34 @@ class _GroupAccumulationsSheetState color: dividerColor, ), itemBuilder: (context, index) { - return _GroupAccumulationRow( - group: widget.groups[index], - locale: locale, + final group = widget.groups[index]; + final isSelected = + selection.groupAccumulatorId == + group.groupAccumulatorId; + final count = countsNotifier.countFor( + group.groupAccumulatorId, + widget.groups, + ); + + return _SelectableAccumulationRow( + isSelected: isSelected, + accentColor: accentColor, + onTap: + () => ref + .read( + malaAccumulationSelectionProvider( + widget.presetId, + ).notifier, + ) + .selectGroup(group.groupAccumulatorId), + leading: _GroupAvatar(group: group), + title: + group.title?.trim().isNotEmpty == true + ? group.title!.trim() + : context.l10n.mala_group_untitled, + formattedCount: NumberFormat.decimalPattern( + locale, + ).format(count), ); }, ), @@ -154,59 +199,79 @@ class _GroupAccumulationsSheetState ), ); } + + String _userDisplayName(User user) { + final parts = + [ + user.firstName, + user.lastName, + ].whereType().where((name) => name.isNotEmpty).toList(); + if (parts.isNotEmpty) return parts.join(' '); + return user.displayName; + } } -class _UserAccumulationRow extends StatelessWidget { - const _UserAccumulationRow({ - required this.user, - required this.formattedCount, +class _SelectableAccumulationRow extends StatelessWidget { + const _SelectableAccumulationRow({ + required this.isSelected, required this.accentColor, + required this.onTap, + required this.leading, + required this.title, + required this.formattedCount, }); - final User? user; - final String formattedCount; + final bool isSelected; final Color accentColor; + final VoidCallback onTap; + final Widget leading; + final String title; + final String formattedCount; @override Widget build(BuildContext context) { - final nameStyle = Theme.of(context).textTheme.bodyLarge?.copyWith( - color: accentColor, - fontWeight: FontWeight.w500, - ); - final countStyle = Theme.of(context).textTheme.titleMedium?.copyWith( - color: accentColor, - fontWeight: FontWeight.w700, - ); + final theme = Theme.of(context); + final nameColor = + isSelected ? accentColor : theme.colorScheme.onSurface; + final countColor = + isSelected ? accentColor : theme.colorScheme.onSurface; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Row( - children: [ - _UserAvatar(avatarUrl: user?.avatarUrl), - const SizedBox(width: 12), - Expanded( - child: Text( - user != null ? _userDisplayName(user!) : '—', - style: nameStyle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + leading, + const SizedBox(width: 12), + Expanded( + child: Text( + title, + style: theme.textTheme.bodyLarge?.copyWith( + color: nameColor, + fontWeight: FontWeight.w500, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 12), + Text( + formattedCount, + style: theme.textTheme.titleMedium?.copyWith( + color: countColor, + fontWeight: FontWeight.w700, + ), + ), + ], ), - Text(formattedCount, style: countStyle), - ], + ), ), ); } - - String _userDisplayName(User user) { - final parts = - [ - user.firstName, - user.lastName, - ].whereType().where((name) => name.isNotEmpty).toList(); - if (parts.isNotEmpty) return parts.join(' '); - return user.displayName; - } } class _UserAvatar extends StatelessWidget { @@ -218,7 +283,8 @@ 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( @@ -245,69 +311,39 @@ class _UserAvatar extends StatelessWidget { } } -class _GroupAccumulationRow extends StatelessWidget { - const _GroupAccumulationRow({required this.group, required this.locale}); +class _GroupAvatar extends StatelessWidget { + const _GroupAvatar({required this.group}); final AccumulatorGroup group; - final String locale; - static const _avatarSize = 40.0; + static const _size = 40.0; @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; final placeholderColor = isDark ? AppColors.surfaceVariantDark : AppColors.grey100; - final formattedCount = NumberFormat.decimalPattern( - locale, - ).format(group.userTotalCount); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipOval( - child: SizedBox( - width: _avatarSize, - height: _avatarSize, - child: - group.image != null && !group.image!.isEmpty - ? ResponsiveCoverImage( - image: group.image, - width: _avatarSize, - height: _avatarSize, - fit: BoxFit.cover, - ) - : ColoredBox( - color: placeholderColor, - child: Icon( - AppAssets.usersThree, - size: 22, - color: isDark ? AppColors.grey500 : AppColors.grey600, - ), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - group.title!.trim(), - style: Theme.of( - context, - ).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 12), - Text( - formattedCount, - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), - ), - ], + return ClipOval( + child: SizedBox( + width: _size, + height: _size, + child: + group.image != null && !group.image!.isEmpty + ? ResponsiveCoverImage( + image: group.image, + width: _size, + height: _size, + fit: BoxFit.cover, + ) + : ColoredBox( + color: placeholderColor, + child: Icon( + AppAssets.usersThree, + size: 22, + color: isDark ? AppColors.grey500 : AppColors.grey600, + ), + ), ), ); } From 1006bf845b5f317eb07b9a7073ad03e0ec2e97af Mon Sep 17 00:00:00 2001 From: tentamdin Date: Wed, 1 Jul 2026 21:46:29 +0530 Subject: [PATCH 23/27] feat: enhance group accumulation management with local state handling and synchronization --- .../datasources/mala_local_datasource.dart | 104 ++++++++++++++- .../mala/domain/usecases/mala_usecases.dart | 26 ++++ .../group_accumulation_counts_provider.dart | 122 +++++++++++++----- .../providers/mala_providers.dart | 14 +- .../providers/mala_sync_manager.dart | 44 +++++++ .../mala/mala_counter_notifier_test.dart | 1 + .../features/mala/mala_sync_manager_test.dart | 63 +++++++++ .../mala/mala_sync_manager_test.mocks.dart | 25 ++++ 8 files changed, 360 insertions(+), 39 deletions(-) diff --git a/lib/features/mala/data/datasources/mala_local_datasource.dart b/lib/features/mala/data/datasources/mala_local_datasource.dart index 92c9aa17..8920ce8d 100644 --- a/lib/features/mala/data/datasources/mala_local_datasource.dart +++ b/lib/features/mala/data/datasources/mala_local_datasource.dart @@ -77,19 +77,54 @@ class LocalMalaState { ); } +/// Per-`(userId, groupAccumulatorId)` local group counting state. +class LocalGroupMalaState { + const LocalGroupMalaState({ + this.total = 0, + this.syncedTotal = 0, + }); + + final int total; + final int syncedTotal; + + bool get isDirty => total > syncedTotal; + + LocalGroupMalaState copyWith({int? total, int? syncedTotal}) => + LocalGroupMalaState( + total: total ?? this.total, + syncedTotal: syncedTotal ?? this.syncedTotal, + ); + + Map toJson() => { + 'total': total, + 'syncedTotal': syncedTotal, + }; + + factory LocalGroupMalaState.fromJson(Map j) => + LocalGroupMalaState( + total: (j['total'] as num?)?.toInt() ?? 0, + syncedTotal: (j['syncedTotal'] as num?)?.toInt() ?? 0, + ); +} + /// Hive-backed local store for mala counts, namespaced by user id so one /// account never reads or sends another's counts. Keys are `userId:presetId`. class MalaLocalDataSource { static const String boxName = 'mala_counts'; + static const String groupBoxName = 'mala_group_counts'; Box get _box => Hive.box(boxName); + Box get _groupBox => Hive.box(groupBoxName); /// Open the box. Call once during app bootstrap (after `Hive.initFlutter()`). static Future init() async { if (!Hive.isBoxOpen(boxName)) { await Hive.openBox(boxName); - _logger.info('MalaLocalDataSource initialized'); } + if (!Hive.isBoxOpen(groupBoxName)) { + await Hive.openBox(groupBoxName); + } + _logger.info('MalaLocalDataSource initialized'); } String _key(String userId, String presetId) => '$userId:$presetId'; @@ -197,4 +232,71 @@ class MalaLocalDataSource { }).toList(); await _box.deleteAll(toDelete); } + + // ========== Group accumulations (separate box) ========== + + String _groupKey(String userId, String groupAccumulatorId) => + '$userId:$groupAccumulatorId'; + + LocalGroupMalaState readGroup(String userId, String groupAccumulatorId) { + final raw = _groupBox.get(_groupKey(userId, groupAccumulatorId)); + if (raw == null) return const LocalGroupMalaState(); + try { + return LocalGroupMalaState.fromJson( + jsonDecode(raw) as Map, + ); + } catch (e) { + _logger.error('Failed to parse local group mala state', e); + return const LocalGroupMalaState(); + } + } + + Future writeGroup( + String userId, + String groupAccumulatorId, + LocalGroupMalaState s, + ) => + _groupBox.put( + _groupKey(userId, groupAccumulatorId), + jsonEncode(s.toJson()), + ); + + Future recordGroupTap( + String userId, + String groupAccumulatorId, + ) async { + final s = readGroup(userId, groupAccumulatorId); + final next = s.copyWith(total: s.total + 1); + await writeGroup(userId, groupAccumulatorId, next); + return next; + } + + List groupAccumulatorIdsForUser(String userId) { + final prefix = '$userId:'; + return _groupBox.keys + .cast() + .where((k) => k.startsWith(prefix)) + .map((k) => k.substring(prefix.length)) + .toList(); + } + + List dirtyGroupAccumulatorIds(String userId) { + final prefix = '$userId:'; + return _groupBox.keys + .cast() + .where((k) => k.startsWith(prefix)) + .where((k) { + final raw = _groupBox.get(k); + if (raw == null) return false; + try { + return LocalGroupMalaState.fromJson( + jsonDecode(raw) as Map, + ).isDirty; + } catch (_) { + return false; + } + }) + .map((k) => k.substring(prefix.length)) + .toList(); + } } diff --git a/lib/features/mala/domain/usecases/mala_usecases.dart b/lib/features/mala/domain/usecases/mala_usecases.dart index 0bdf1953..5e106d88 100644 --- a/lib/features/mala/domain/usecases/mala_usecases.dart +++ b/lib/features/mala/domain/usecases/mala_usecases.dart @@ -71,3 +71,29 @@ class DeleteUserAccumulatorUseCase extends UseCase { Future> call(String accumulatorId) => _repository.deleteUserAccumulator(accumulatorId); } + +class SubmitGroupAccumulatorCountParams { + const SubmitGroupAccumulatorCountParams({ + required this.groupAccumulatorId, + required this.currentCount, + }); + + final String groupAccumulatorId; + final int currentCount; +} + +/// Pushes the absolute group total (`POST /group-accumulators/{id}`). +class SubmitGroupAccumulatorCountUseCase + extends UseCase { + SubmitGroupAccumulatorCountUseCase(this._repository); + final MalaRepository _repository; + + @override + Future> call( + SubmitGroupAccumulatorCountParams params, + ) => + _repository.submitGroupAccumulatorCount( + groupAccumulatorId: params.groupAccumulatorId, + currentCount: params.currentCount, + ); +} 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 988596e6..518143d3 100644 --- a/lib/features/mala/presentation/providers/group_accumulation_counts_provider.dart +++ b/lib/features/mala/presentation/providers/group_accumulation_counts_provider.dart @@ -1,38 +1,101 @@ +import 'dart:async'; +import 'dart:math'; + import 'package:flutter/services.dart'; -import 'package:flutter_pecha/core/utils/app_logger.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/presentation/providers/mala_providers.dart'; +import 'package:flutter_pecha/features/mala/presentation/providers/mala_sync_manager.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -final _logger = AppLogger('GroupAccumulationCountsNotifier'); - /// Local group [userTotalCount] values keyed by [AccumulatorGroup.groupAccumulatorId]. /// -/// Seeded from the groups API and incremented when the user counts while a group -/// is selected. Syncs absolute totals via `POST /group-accumulators/{id}`. +/// Persisted in Hive per `(userId, groupAccumulatorId)` with `total` / +/// `syncedTotal` dirty tracking. Background sync via [MalaSyncManager]. class GroupAccumulationCountsNotifier extends StateNotifier> { - GroupAccumulationCountsNotifier(this._ref) : super(const {}); + GroupAccumulationCountsNotifier({ + required Ref ref, + required MalaLocalDataSource local, + required MalaSyncManager sync, + required Future Function() currentUserId, + }) : _ref = ref, + _local = local, + _sync = sync, + _currentUserId = currentUserId, + super(const {}) { + unawaited(_init()); + } final Ref _ref; + final MalaLocalDataSource _local; + final MalaSyncManager _sync; + final Future Function() _currentUserId; + + String? _userId; + + Future _init() async { + _userId = await _currentUserId(); + final userId = _userId; + if (userId == null || userId.isEmpty) return; + + final ids = _local.groupAccumulatorIdsForUser(userId); + if (ids.isEmpty) return; - void mergeFromApi(List groups) { + final next = Map.from(state); + for (final id in ids) { + next[id] = _local.readGroup(userId, id).total; + } + state = next; + } + + /// Reconcile API totals with local Hive state (`max()` on both sides). + Future mergeFromApi(List groups) async { if (groups.isEmpty) return; + + var userId = _userId; + userId ??= await _currentUserId(); + if (userId == null || userId.isEmpty) return; + _userId = userId; + final next = Map.from(state); var changed = false; + var hasDirtyTail = false; + for (final group in groups) { final id = group.groupAccumulatorId; - final merged = _maxCount(next[id], group.userTotalCount); - if (merged != next[id]) { - next[id] = merged; + 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), + ); + + if (reconciled.total != localState.total || + reconciled.syncedTotal != localState.syncedTotal) { + await _local.writeGroup(userId, id, reconciled); + } + if (next[id] != total) { + next[id] = total; changed = true; } + if (reconciled.isDirty) hasDirtyTail = true; } + if (changed) state = next; + if (hasDirtyTail) unawaited(_sync.flush(SyncReason.launch)); } int countFor(String groupAccumulatorId, List groups) { - final local = state[groupAccumulatorId]; - if (local != null) return local; + 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; @@ -48,9 +111,13 @@ class GroupAccumulationCountsNotifier extends StateNotifier> { required bool vibrationEnabled, required int beadsPerRound, }) { + final userId = _userId; + if (userId == null || userId.isEmpty) return; + final current = countFor(groupAccumulatorId, groups); final newTotal = current + 1; state = {...state, groupAccumulatorId: newTotal}; + unawaited(_local.recordGroupTap(userId, groupAccumulatorId)); final roundComplete = newTotal % beadsPerRound == 0; if (soundEnabled) { @@ -61,31 +128,16 @@ class GroupAccumulationCountsNotifier extends StateNotifier> { if (roundComplete) HapticFeedback.mediumImpact(); } - _syncCount(groupAccumulatorId, newTotal); - } - - Future _syncCount(String groupAccumulatorId, int currentCount) async { - final result = await _ref - .read(malaRepositoryProvider) - .submitGroupAccumulatorCount( - groupAccumulatorId: groupAccumulatorId, - currentCount: currentCount, - ); - result.fold( - (failure) => _logger.warning( - 'Failed to sync group count ($groupAccumulatorId): ${failure.message}', - ), - (_) {}, - ); - } - - int _maxCount(int? a, int b) { - if (a == null) return b; - return a > b ? a : b; + _sync.onTap(roundComplete: roundComplete); } } final groupAccumulationCountsProvider = StateNotifierProvider.autoDispose .family, String>( - (ref, presetId) => GroupAccumulationCountsNotifier(ref), - ); + (ref, presetId) => GroupAccumulationCountsNotifier( + ref: ref, + local: ref.watch(malaLocalDataSourceProvider), + sync: ref.watch(malaSyncManagerProvider), + currentUserId: () => resolveMalaUserId(ref), + ), +); diff --git a/lib/features/mala/presentation/providers/mala_providers.dart b/lib/features/mala/presentation/providers/mala_providers.dart index 90bbf388..02d0b8ef 100644 --- a/lib/features/mala/presentation/providers/mala_providers.dart +++ b/lib/features/mala/presentation/providers/mala_providers.dart @@ -66,6 +66,13 @@ final deleteUserAccumulatorUseCaseProvider = return DeleteUserAccumulatorUseCase(ref.watch(malaRepositoryProvider)); }); +final submitGroupAccumulatorCountUseCaseProvider = + Provider((ref) { + return SubmitGroupAccumulatorCountUseCase( + ref.watch(malaRepositoryProvider), + ); + }); + // ============ Auth helpers ============ bool _isAuthenticated(Ref ref) { @@ -79,7 +86,7 @@ bool _isAuthenticated(Ref ref) { /// written before auth state flips to logged-in and does not depend on the /// async (and possibly failing) user-profile fetch, so it's available the /// moment the login-gated mala route is reachable. Falls back to the profile. -Future _resolveUserId(Ref ref) async { +Future resolveMalaUserId(Ref ref) async { final stored = await ref .read(localStorageServiceProvider) .get(StorageKeys.currentUserId); @@ -96,8 +103,9 @@ final malaSyncManagerProvider = Provider((ref) { local: ref.watch(malaLocalDataSourceProvider), createAccumulator: ref.watch(createUserAccumulatorUseCaseProvider), updateAccumulator: ref.watch(updateUserAccumulatorUseCaseProvider), + submitGroupCount: ref.watch(submitGroupAccumulatorCountUseCaseProvider), isLoggedIn: () => _isAuthenticated(ref), - currentUserId: () => _resolveUserId(ref), + currentUserId: () => resolveMalaUserId(ref), connectivityStream: ref.watch(connectivityServiceProvider).onConnectivityChanged, analytics: ref.watch(analyticsServiceProvider), @@ -154,7 +162,7 @@ final malaCounterProvider = StateNotifierProvider.autoDispose downloadImageBytes: ref.watch(malaRemoteDataSourceProvider).fetchImageBytes, sync: ref.watch(malaSyncManagerProvider), - currentUserId: () => _resolveUserId(ref), + currentUserId: () => resolveMalaUserId(ref), analytics: ref.watch(analyticsServiceProvider), sound: ref.watch(malaSoundPlayerProvider), ); diff --git a/lib/features/mala/presentation/providers/mala_sync_manager.dart b/lib/features/mala/presentation/providers/mala_sync_manager.dart index 30073597..0b15c366 100644 --- a/lib/features/mala/presentation/providers/mala_sync_manager.dart +++ b/lib/features/mala/presentation/providers/mala_sync_manager.dart @@ -31,6 +31,7 @@ class MalaSyncManager with WidgetsBindingObserver { required MalaLocalDataSource local, required CreateUserAccumulatorUseCase createAccumulator, required UpdateUserAccumulatorUseCase updateAccumulator, + required SubmitGroupAccumulatorCountUseCase submitGroupCount, required bool Function() isLoggedIn, required Future Function() currentUserId, Stream? connectivityStream, @@ -38,6 +39,7 @@ class MalaSyncManager with WidgetsBindingObserver { }) : _local = local, _createAccumulator = createAccumulator, _updateAccumulator = updateAccumulator, + _submitGroupCount = submitGroupCount, _isLoggedIn = isLoggedIn, _currentUserId = currentUserId, _connectivityStream = connectivityStream, @@ -46,6 +48,7 @@ class MalaSyncManager with WidgetsBindingObserver { final MalaLocalDataSource _local; final CreateUserAccumulatorUseCase _createAccumulator; final UpdateUserAccumulatorUseCase _updateAccumulator; + final SubmitGroupAccumulatorCountUseCase _submitGroupCount; final bool Function() _isLoggedIn; final Future Function() _currentUserId; final Stream? _connectivityStream; @@ -124,6 +127,11 @@ class MalaSyncManager with WidgetsBindingObserver { if (!s.isDirty) continue; await _pushTotal(userId, presetId, s.total); } + for (final groupAccumulatorId in _local.dirtyGroupAccumulatorIds(userId)) { + final s = _local.readGroup(userId, groupAccumulatorId); + if (!s.isDirty) continue; + await _pushGroupTotal(userId, groupAccumulatorId, s.total); + } _retryAttempt = 0; _retry?.cancel(); } catch (e) { @@ -280,6 +288,42 @@ class MalaSyncManager with WidgetsBindingObserver { ); } + Future _pushGroupTotal( + String userId, + String groupAccumulatorId, + int sending, + ) async { + final result = await _submitGroupCount( + SubmitGroupAccumulatorCountParams( + groupAccumulatorId: groupAccumulatorId, + currentCount: sending, + ), + ); + + result.fold( + (failure) => throw Exception(failure.message), + (_) { + final after = _local.readGroup(userId, groupAccumulatorId); + _local.writeGroup( + userId, + groupAccumulatorId, + after.copyWith( + total: max(after.total, sending), + syncedTotal: max(after.syncedTotal, sending), + ), + ); + _analytics?.track( + AnalyticsEvents.malaSynced, + properties: { + 'groupAccumulatorId': groupAccumulatorId, + 'total': max(after.total, sending), + 'group': true, + }, + ); + }, + ); + } + void _scheduleRetry() { _retry?.cancel(); final seconds = min(_maxBackoff.inSeconds, pow(2, _retryAttempt + 1).toInt()); diff --git a/test/features/mala/mala_counter_notifier_test.dart b/test/features/mala/mala_counter_notifier_test.dart index f7877daa..81fbd3a7 100644 --- a/test/features/mala/mala_counter_notifier_test.dart +++ b/test/features/mala/mala_counter_notifier_test.dart @@ -58,6 +58,7 @@ void main() { tearDown(() async { await Hive.deleteBoxFromDisk(MalaLocalDataSource.boxName); + await Hive.deleteBoxFromDisk(MalaLocalDataSource.groupBoxName); await Hive.close(); tempDir.deleteSync(recursive: true); }); diff --git a/test/features/mala/mala_sync_manager_test.dart b/test/features/mala/mala_sync_manager_test.dart index 73e9129b..85e6aa7b 100644 --- a/test/features/mala/mala_sync_manager_test.dart +++ b/test/features/mala/mala_sync_manager_test.dart @@ -17,6 +17,7 @@ import 'mala_sync_manager_test.mocks.dart'; CreateUserAccumulatorUseCase, UpdateUserAccumulatorUseCase, DeleteUserAccumulatorUseCase, + SubmitGroupAccumulatorCountUseCase, ]) void main() { provideDummy>( @@ -29,10 +30,12 @@ void main() { late MockCreateUserAccumulatorUseCase create; late MockUpdateUserAccumulatorUseCase update; late MockDeleteUserAccumulatorUseCase delete; + late MockSubmitGroupAccumulatorCountUseCase submitGroup; const userA = 'user-a'; const userB = 'user-b'; const presetId = 'chenrezig'; + const groupAccId = 'group-acc-1'; MalaSyncManager buildManager({ bool loggedIn = true, @@ -42,6 +45,7 @@ void main() { local: local, createAccumulator: create, updateAccumulator: update, + submitGroupCount: submitGroup, isLoggedIn: () => loggedIn, currentUserId: () async => userId, ); @@ -55,10 +59,13 @@ void main() { create = MockCreateUserAccumulatorUseCase(); update = MockUpdateUserAccumulatorUseCase(); delete = MockDeleteUserAccumulatorUseCase(); + submitGroup = MockSubmitGroupAccumulatorCountUseCase(); + when(submitGroup(any)).thenAnswer((_) async => const Right(unit)); }); tearDown(() async { await Hive.deleteBoxFromDisk(MalaLocalDataSource.boxName); + await Hive.deleteBoxFromDisk(MalaLocalDataSource.groupBoxName); await Hive.close(); tempDir.deleteSync(recursive: true); }); @@ -278,4 +285,60 @@ void main() { expect(after.accumulatorId, isNull); expect(after.total, 0); }); + + test('flushes dirty group counts via POST absolute total', () async { + await local.writeGroup( + userA, + groupAccId, + const LocalGroupMalaState(total: 42, syncedTotal: 30), + ); + + await buildManager().flush(SyncReason.debounce); + + final captured = verify(submitGroup(captureAny)).captured.single + as SubmitGroupAccumulatorCountParams; + expect(captured.groupAccumulatorId, groupAccId); + expect(captured.currentCount, 42); + + final after = local.readGroup(userA, groupAccId); + expect(after.syncedTotal, 42); + expect(after.isDirty, isFalse); + }); + + test('failed group flush keeps entry dirty for retry', () async { + await local.writeGroup( + userA, + groupAccId, + const LocalGroupMalaState(total: 10, syncedTotal: 0), + ); + when(submitGroup(any)).thenAnswer( + (_) async => const Left(NetworkFailure('offline')), + ); + + await buildManager().flush(SyncReason.launch); + + final after = local.readGroup(userA, groupAccId); + expect(after.syncedTotal, 0); + expect(after.isDirty, isTrue); + }); + + test('group flush only touches current user entries', () async { + await local.writeGroup( + userA, + groupAccId, + const LocalGroupMalaState(total: 5, syncedTotal: 0), + ); + await local.writeGroup( + userB, + 'group-acc-b', + const LocalGroupMalaState(total: 9, syncedTotal: 0), + ); + + await buildManager(userId: userA).flush(SyncReason.launch); + + final captured = verify(submitGroup(captureAny)).captured.single + as SubmitGroupAccumulatorCountParams; + expect(captured.groupAccumulatorId, groupAccId); + expect(local.readGroup(userB, 'group-acc-b').isDirty, isTrue); + }); } diff --git a/test/features/mala/mala_sync_manager_test.mocks.dart b/test/features/mala/mala_sync_manager_test.mocks.dart index 4c3e82ac..fda59bb4 100644 --- a/test/features/mala/mala_sync_manager_test.mocks.dart +++ b/test/features/mala/mala_sync_manager_test.mocks.dart @@ -101,3 +101,28 @@ class MockDeleteUserAccumulatorUseCase extends _i1.Mock ) as _i3.Future<_i4.Either<_i5.Failure, _i4.Unit>>); } + +/// A class which mocks [SubmitGroupAccumulatorCountUseCase]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockSubmitGroupAccumulatorCountUseCase extends _i1.Mock + implements _i2.SubmitGroupAccumulatorCountUseCase { + MockSubmitGroupAccumulatorCountUseCase() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Future<_i4.Either<_i5.Failure, _i4.Unit>> call( + _i2.SubmitGroupAccumulatorCountParams? params, + ) => + (super.noSuchMethod( + Invocation.method(#call, [params]), + returnValue: _i3.Future<_i4.Either<_i5.Failure, _i4.Unit>>.value( + _i7.dummyValue<_i4.Either<_i5.Failure, _i4.Unit>>( + this, + Invocation.method(#call, [params]), + ), + ), + ) + as _i3.Future<_i4.Either<_i5.Failure, _i4.Unit>>); +} From 02400322d11f4c8c0eccc4de6efd2c5b37fce3fb Mon Sep 17 00:00:00 2001 From: tentamdin Date: Wed, 1 Jul 2026 22:20:56 +0530 Subject: [PATCH 24/27] fix: update localisation --- lib/core/l10n/app_bo.arb | 1 + lib/core/l10n/app_en.arb | 3 ++- lib/core/l10n/app_hi.arb | 1 + lib/core/l10n/app_mn.arb | 1 + lib/core/l10n/app_ne.arb | 1 + lib/core/l10n/app_zh.arb | 1 + lib/core/l10n/generated/app_localizations.dart | 8 +++++++- lib/core/l10n/generated/app_localizations_bo.dart | 3 +++ lib/core/l10n/generated/app_localizations_en.dart | 5 ++++- lib/core/l10n/generated/app_localizations_hi.dart | 3 +++ lib/core/l10n/generated/app_localizations_mn.dart | 3 +++ lib/core/l10n/generated/app_localizations_ne.dart | 3 +++ lib/core/l10n/generated/app_localizations_zh.dart | 3 +++ .../presentation/widgets/group_profile_body.dart | 2 +- 14 files changed, 34 insertions(+), 4 deletions(-) diff --git a/lib/core/l10n/app_bo.arb b/lib/core/l10n/app_bo.arb index 7868b169..60e4dab7 100644 --- a/lib/core/l10n/app_bo.arb +++ b/lib/core/l10n/app_bo.arb @@ -112,6 +112,7 @@ "nav_settings": "སྒྲིག་འགོད།", "nav_connect": "མཐུད།", "nav_me": "ང་།", + "tab_practices": "ཉམས་ལེན།", "text_search": "འཚོལ།", "text_toc_versions": "པར་གཞི།", "text_commentary": "འགྲེལ་བ་ཁག", diff --git a/lib/core/l10n/app_en.arb b/lib/core/l10n/app_en.arb index 6b9efad2..e37bf69d 100644 --- a/lib/core/l10n/app_en.arb +++ b/lib/core/l10n/app_en.arb @@ -108,10 +108,11 @@ "nav_home": "Home", "nav_explore": "Explore", "nav_learn": "Learn", - "nav_practice": "Practices", + "nav_practice": "Practice", "nav_settings": "Settings", "nav_connect": "Connect", "nav_me": "Me", + "tab_practices": "Practices", "text_search": "Search", "text_toc_versions": "Versions", "text_commentary": "Commentaries", diff --git a/lib/core/l10n/app_hi.arb b/lib/core/l10n/app_hi.arb index 6559faec..d28701bf 100644 --- a/lib/core/l10n/app_hi.arb +++ b/lib/core/l10n/app_hi.arb @@ -112,6 +112,7 @@ "nav_settings": "सेटिंग्स", "nav_connect": "कनेक्ट", "nav_me": "मैं", + "tab_practices": "अभ्यास", "text_search": "खोजें", "text_toc_versions": "संस्करण", "text_commentary": "टीकाएँ", diff --git a/lib/core/l10n/app_mn.arb b/lib/core/l10n/app_mn.arb index a73ccf3a..41a2a228 100644 --- a/lib/core/l10n/app_mn.arb +++ b/lib/core/l10n/app_mn.arb @@ -112,6 +112,7 @@ "nav_settings": "Тохиргоо", "nav_connect": "Холбогдох", "nav_me": "Би", + "tab_practices": "Дадлага", "text_search": "Хайх", "text_toc_versions": "Хувилбарууд", "text_commentary": "Тайлбар", diff --git a/lib/core/l10n/app_ne.arb b/lib/core/l10n/app_ne.arb index b35a0419..2e62c358 100644 --- a/lib/core/l10n/app_ne.arb +++ b/lib/core/l10n/app_ne.arb @@ -112,6 +112,7 @@ "nav_settings": "सेटिङ", "nav_connect": "जोडिनुहोस्", "nav_me": "म", + "tab_practices": "अभ्यास", "text_search": "खोज्नुहोस्", "text_toc_versions": "संस्करणहरू", "text_commentary": "टीकाहरू", diff --git a/lib/core/l10n/app_zh.arb b/lib/core/l10n/app_zh.arb index 110dc301..d03abca7 100644 --- a/lib/core/l10n/app_zh.arb +++ b/lib/core/l10n/app_zh.arb @@ -112,6 +112,7 @@ "nav_settings": "設定", "nav_connect": "社群", "nav_me": "個人", + "tab_practices": "修持計畫", "text_search": "搜尋", "text_toc_versions": "版本", "text_commentary": "注釋", diff --git a/lib/core/l10n/generated/app_localizations.dart b/lib/core/l10n/generated/app_localizations.dart index 268b3288..b86ee898 100644 --- a/lib/core/l10n/generated/app_localizations.dart +++ b/lib/core/l10n/generated/app_localizations.dart @@ -511,7 +511,7 @@ abstract class AppLocalizations { /// No description provided for @nav_practice. /// /// In en, this message translates to: - /// **'Practices'** + /// **'Practice'** String get nav_practice; /// No description provided for @nav_settings. @@ -532,6 +532,12 @@ abstract class AppLocalizations { /// **'Me'** String get nav_me; + /// No description provided for @tab_practices. + /// + /// In en, this message translates to: + /// **'Practices'** + String get tab_practices; + /// No description provided for @text_search. /// /// 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 71efb61c..94127d57 100644 --- a/lib/core/l10n/generated/app_localizations_bo.dart +++ b/lib/core/l10n/generated/app_localizations_bo.dart @@ -249,6 +249,9 @@ class AppLocalizationsBo extends AppLocalizations { @override String get nav_me => 'ང་།'; + @override + String get tab_practices => 'ཉམས་ལེན།'; + @override String get text_search => 'འཚོལ།'; diff --git a/lib/core/l10n/generated/app_localizations_en.dart b/lib/core/l10n/generated/app_localizations_en.dart index 3bb7fda5..8d5cee69 100644 --- a/lib/core/l10n/generated/app_localizations_en.dart +++ b/lib/core/l10n/generated/app_localizations_en.dart @@ -236,7 +236,7 @@ class AppLocalizationsEn extends AppLocalizations { String get nav_learn => 'Learn'; @override - String get nav_practice => 'Practices'; + String get nav_practice => 'Practice'; @override String get nav_settings => 'Settings'; @@ -247,6 +247,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get nav_me => 'Me'; + @override + String get tab_practices => 'Practices'; + @override String get text_search => 'Search'; diff --git a/lib/core/l10n/generated/app_localizations_hi.dart b/lib/core/l10n/generated/app_localizations_hi.dart index 72448463..395a6f3e 100644 --- a/lib/core/l10n/generated/app_localizations_hi.dart +++ b/lib/core/l10n/generated/app_localizations_hi.dart @@ -247,6 +247,9 @@ class AppLocalizationsHi extends AppLocalizations { @override String get nav_me => 'मैं'; + @override + String get tab_practices => 'अभ्यास'; + @override String get text_search => 'खोजें'; diff --git a/lib/core/l10n/generated/app_localizations_mn.dart b/lib/core/l10n/generated/app_localizations_mn.dart index 89209900..6fbc0a5e 100644 --- a/lib/core/l10n/generated/app_localizations_mn.dart +++ b/lib/core/l10n/generated/app_localizations_mn.dart @@ -248,6 +248,9 @@ class AppLocalizationsMn extends AppLocalizations { @override String get nav_me => 'Би'; + @override + String get tab_practices => 'Дадлага'; + @override String get text_search => 'Хайх'; diff --git a/lib/core/l10n/generated/app_localizations_ne.dart b/lib/core/l10n/generated/app_localizations_ne.dart index 81a61d2a..eda5d2f3 100644 --- a/lib/core/l10n/generated/app_localizations_ne.dart +++ b/lib/core/l10n/generated/app_localizations_ne.dart @@ -248,6 +248,9 @@ class AppLocalizationsNe extends AppLocalizations { @override String get nav_me => 'म'; + @override + String get tab_practices => 'अभ्यास'; + @override String get text_search => 'खोज्नुहोस्'; diff --git a/lib/core/l10n/generated/app_localizations_zh.dart b/lib/core/l10n/generated/app_localizations_zh.dart index 3dc3017f..c4e4dc07 100644 --- a/lib/core/l10n/generated/app_localizations_zh.dart +++ b/lib/core/l10n/generated/app_localizations_zh.dart @@ -238,6 +238,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get nav_me => '個人'; + @override + String get tab_practices => '修持計畫'; + @override String get text_search => '搜尋'; 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 018f4a71..d8efa3f3 100644 --- a/lib/features/group_profile/presentation/widgets/group_profile_body.dart +++ b/lib/features/group_profile/presentation/widgets/group_profile_body.dart @@ -350,7 +350,7 @@ class _GroupProfileBodyState extends ConsumerState fontWeight: FontWeight.w500, ), tabs: [ - Tab(text: context.l10n.nav_practice), + Tab(text: context.l10n.tab_practices), Tab(text: membersTabLabel), if (hasAbout) Tab(text: context.l10n.about_title), ], From f8f2a0d672388ebff6b2eedb8406942716aa7dcb Mon Sep 17 00:00:00 2001 From: Tenzin Tamdin <38410384+tentamdin@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:22:38 +0530 Subject: [PATCH 25/27] Update lib/features/reader/presentation/widgets/reader_app_bar/reader_more_bottom_sheet.dart Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../widgets/reader_app_bar/reader_more_bottom_sheet.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 772e9ef1..561a631b 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 @@ -231,7 +231,7 @@ class _ReaderMoreBottomSheetState extends ConsumerState { ), ) : Icon(Icons.share, color: theme.colorScheme.onSurface), - title: Text('Share', style: theme.textTheme.bodyLarge), + title: Text(l10n.share, style: theme.textTheme.bodyLarge), onTap: () { HapticFeedback.lightImpact(); _share(); From dcfdca5955393aaa400cf637169969c04590f377 Mon Sep 17 00:00:00 2001 From: tentamdin Date: Wed, 1 Jul 2026 23:09:27 +0530 Subject: [PATCH 26/27] fix: disable some action on group accumulation --- .../mala/presentation/widgets/mala_settings_sheet.dart | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/features/mala/presentation/widgets/mala_settings_sheet.dart b/lib/features/mala/presentation/widgets/mala_settings_sheet.dart index c9d1e0f9..edee7c43 100644 --- a/lib/features/mala/presentation/widgets/mala_settings_sheet.dart +++ b/lib/features/mala/presentation/widgets/mala_settings_sheet.dart @@ -7,6 +7,7 @@ import 'package:flutter_pecha/core/network/connectivity_service.dart' show conne 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/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/practice/data/datasource/bookmark_remote_datasource.dart'; @@ -39,6 +40,9 @@ 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 isBookmarked = ref.watch( isBookmarkedProvider( BookmarkTarget(type: BookmarkType.accumulator, sourceId: mantra.presetId), @@ -70,6 +74,7 @@ class MalaSettingsSheet extends ConsumerWidget { _MalaSettingsTile( icon: AppAssets.plus, label: l10n.mala_add_to_practice, + enabled: isPersonal, onTap: () => _onAddToPractice(context), ), Divider(height: 1, color: dividerColor), @@ -79,6 +84,7 @@ class MalaSettingsSheet extends ConsumerWidget { ? AppAssets.bookmarkSimpleFill : AppAssets.bookmarkSimple, label: l10n.mala_add_to_bookmark, + enabled: isPersonal, onTap: () => _onToggleBookmark(context, ref), ), Divider(height: 1, color: dividerColor), @@ -101,7 +107,7 @@ class MalaSettingsSheet extends ConsumerWidget { label: l10n.mala_reset_count, labelColor: destructiveColor, iconColor: destructiveColor, - enabled: isOnline, + enabled: isOnline && isPersonal, onTap: () => _onResetCount(context, ref), ), const SizedBox(height: 8), From 487571a82496ff5e2131320c01e2b62849d1cfdb Mon Sep 17 00:00:00 2001 From: tentamdin Date: Wed, 1 Jul 2026 23:18:33 +0530 Subject: [PATCH 27/27] refactor: streamline accumulator ID handling and improve local state updates in MalaSyncManager --- .../providers/mala_sync_manager.dart | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/lib/features/mala/presentation/providers/mala_sync_manager.dart b/lib/features/mala/presentation/providers/mala_sync_manager.dart index 0b15c366..7fac0b51 100644 --- a/lib/features/mala/presentation/providers/mala_sync_manager.dart +++ b/lib/features/mala/presentation/providers/mala_sync_manager.dart @@ -238,22 +238,19 @@ class MalaSyncManager with WidgetsBindingObserver { var accumulatorId = s.accumulatorId; if (accumulatorId == null) { final created = await _createAccumulator(presetId); - accumulatorId = created.fold( - (failure) => throw Exception(failure.message), // keep dirty; retry - (count) { - _local.write( - userId, - presetId, - _local.read(userId, presetId).copyWith( - accumulatorId: count.accumulatorId, - ), - ); - return count.accumulatorId; - }, + final newId = created.fold( + (failure) => throw Exception(failure.message), + (count) => count.accumulatorId, ); - if (accumulatorId == null) { + if (newId == null || newId.isEmpty) { throw Exception('Create returned no accumulator id'); } + await _local.write( + userId, + presetId, + _local.read(userId, presetId).copyWith(accumulatorId: newId), + ); + accumulatorId = newId; } final result = await _updateAccumulator( @@ -263,17 +260,18 @@ class MalaSyncManager with WidgetsBindingObserver { ), ); - result.fold( - (failure) => throw Exception(failure.message), // keep dirty; retry - (count) { + await result.fold( + (failure) async => throw Exception(failure.message), + (count) async { // Re-read: taps may have landed during the round-trip. final after = _local.read(userId, presetId); - _local.write( + final confirmedTotal = max(count.total, sending); + await _local.write( userId, presetId, after.copyWith( total: max(after.total, count.total), - syncedTotal: max(count.total, sending), + syncedTotal: confirmedTotal, accumulatorId: count.accumulatorId ?? accumulatorId, ), ); @@ -281,7 +279,7 @@ class MalaSyncManager with WidgetsBindingObserver { AnalyticsEvents.malaSynced, properties: { 'accumulatorId': count.accumulatorId ?? accumulatorId, - 'total': max(count.total, sending), + 'total': max(after.total, count.total), }, ); }, @@ -300,16 +298,18 @@ class MalaSyncManager with WidgetsBindingObserver { ), ); - result.fold( - (failure) => throw Exception(failure.message), - (_) { + await result.fold( + (failure) async => throw Exception(failure.message), + (_) async { + // Re-read: taps may have landed during the round-trip. final after = _local.readGroup(userId, groupAccumulatorId); - _local.writeGroup( + final confirmedTotal = max(after.syncedTotal, sending); + await _local.writeGroup( userId, groupAccumulatorId, after.copyWith( total: max(after.total, sending), - syncedTotal: max(after.syncedTotal, sending), + syncedTotal: confirmedTotal, ), ); _analytics?.track(