Skip to content

Commit 839299d

Browse files
authored
Merge pull request #486 from OpenPecha/feature/mala-screen-layout
Feature/mala screen layout
2 parents 6e833a1 + 0e0bacb commit 839299d

8 files changed

Lines changed: 254 additions & 95 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/// Strips the query string and fragment so presigned URLs (e.g. S3 with
2+
/// rotating signatures) resolve to the same logical asset.
3+
String stableNetworkImageCacheKey(String url) {
4+
final uri = Uri.tryParse(url);
5+
if (uri == null) return url;
6+
return uri.replace(query: '', fragment: '').toString();
7+
}
8+
9+
bool isSameNetworkImage(String? a, String? b) {
10+
if (a == null || b == null) return a == b;
11+
return stableNetworkImageCacheKey(a) == stableNetworkImageCacheKey(b);
12+
}

lib/core/widgets/cached_network_image_widget.dart

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'package:cached_network_image/cached_network_image.dart';
22
import 'package:flutter/material.dart';
3+
import 'package:flutter_pecha/core/utils/network_image_utils.dart';
34

45
bool _isAssetPath(String url) => url.trim().startsWith('assets/');
56

@@ -20,11 +21,7 @@ int? _toCachePx(double? logical, double dpr) {
2021

2122
/// Strips the query string and fragment so presigned URLs (e.g. S3 with
2223
/// rotating signatures) resolve to the same cache entry across sessions.
23-
String _stableCacheKey(String url) {
24-
final uri = Uri.tryParse(url);
25-
if (uri == null) return url;
26-
return uri.replace(query: '', fragment: '').toString();
27-
}
24+
String _stableCacheKey(String url) => stableNetworkImageCacheKey(url);
2825

2926
class CachedNetworkImageWidget extends StatefulWidget {
3027
/// Remote http(s) URL, or a bundled asset path starting with `assets/`.

lib/features/mala/README.md

Lines changed: 14 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -196,26 +196,20 @@ Opened once in app bootstrap via `MalaLocalDataSource.init()`.
196196

197197
## Bead artwork & caching
198198

199-
Resolution order in `MalaScreen`:
200-
**accumulator detail `beadImageUrl` → preset/mantra `beadImageUrl` → drawn
201-
gradient bead**. There is no bundled asset fallback: while the network image
202-
loads, or whenever there's no URL or it fails to load, the painter draws a
203-
gradient bead (`_drawDrawnBead`).
204-
205-
- **Preset preview source.** `Mantra.beadImageUrl` resolves to the
206-
**mantra-level** `mala_image_url` (`PresetMantraModel`) first, falling back to
207-
the accumulator-level one (`PresetAccumulatorModel`). The mantra-level image
208-
mirrors what the detail endpoint (`AccumulatorDetailModel.mala_image_url`)
209-
returns, so the pre-seed preview and the post-seed image share one URL — the
210-
URL doesn't change after seeding, so `MalaBeads` skips a second fetch and the
211-
bead doesn't flicker. (See `PresetAccumulatorModel.toEntity()`.)
212-
- The detail image is threaded through `MalaCount → MalaCounterState →
213-
MalaBeads` so per-user bead customization works.
214-
- It is **persisted** in `LocalMalaState.beadImageUrl` and surfaced into state at
215-
seed start (before/without network) so the correct bead shows offline on a
216-
cold start.
217-
- `MalaBeads` loads via **`CachedNetworkImageProvider`** (on-disk cache), so the
218-
image bytes survive across launches and load offline.
199+
`MalaCounterNotifier` downloads bead artwork via dio during seed (not in the
200+
widget). URLs from the API are **presigned S3 links** that expire (~1 hour), so
201+
stale Hive URLs are retried against fresh catalogue / detail URLs before giving
202+
up. Successful downloads are stored as **`beadImageBase64`** in Hive.
203+
204+
`MalaBeads` renders **`beadImageBytes` only** (MemoryImage). While bytes are
205+
still downloading — or when download fails — the painter draws a gradient bead
206+
(`_drawDrawnBead`). This avoids loading expired presigned URLs directly in the
207+
widget layer (which would log ImageResourceService 403 errors).
208+
209+
- **Preset source.** `Mantra.beadImageUrl` resolves to the mantra-level
210+
`mala_image_url` first, falling back to the accumulator-level one.
211+
- **Offline.** Persisted bytes in `LocalMalaState` let the strand render on a
212+
cold start without network.
219213

220214
## Bead input & feedback
221215

lib/features/mala/presentation/providers/mala_counter_notifier.dart

Lines changed: 98 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
66
import 'package:flutter_pecha/core/analytics/analytics_events.dart';
77
import 'package:flutter_pecha/core/analytics/analytics_service.dart';
88
import 'package:flutter_pecha/core/utils/app_logger.dart';
9+
import 'package:flutter_pecha/core/utils/network_image_utils.dart';
910
import 'package:flutter_pecha/features/mala/data/datasources/mala_local_datasource.dart';
1011
import 'package:flutter_pecha/features/mala/domain/entities/mantra.dart';
1112
import 'package:flutter_pecha/features/mala/domain/usecases/mala_usecases.dart';
@@ -135,19 +136,19 @@ class MalaCounterNotifier extends StateNotifier<MalaCounterState> {
135136
beadImageUrl: fallbackImageUrl,
136137
beadImageBytes: localState.beadImageBytes,
137138
);
138-
if (localState.beadImageBytes == null && fallbackImageUrl != null) {
139-
unawaited(_storeBeadImage(userId, fallbackImageUrl));
140-
}
141139

142140
final result = await _getAccumulatorDetail(_presetId);
143141
if (!mounted) return; // screen left mid-seed
142+
143+
String? detailBeadImageUrl;
144144
result.fold(
145145
(failure) {
146146
_logger.warning('Seed failed: ${failure.message}');
147147
// Local counting stays enabled; dirty counts retry via sync manager.
148148
unawaited(_sync.flush(SyncReason.launch));
149149
},
150150
(detail) {
151+
detailBeadImageUrl = detail.beadImageUrl;
151152
// detail.accumulatorId is null when the user has no active accumulator
152153
// (fresh install, or after reset soft-deletes the session record).
153154
// detail.total maps to API `current_count` (active session), not
@@ -184,39 +185,114 @@ class MalaCounterNotifier extends StateNotifier<MalaCounterState> {
184185
beadImageUrl: beadImageUrl,
185186
beadImageBytes: localState.beadImageBytes,
186187
);
187-
if (beadImageUrl != null) {
188-
unawaited(_storeBeadImage(userId, beadImageUrl));
189-
}
190188

191189
// Push any offline tail captured before this seed.
192190
if (total > syncedTotal) unawaited(_sync.flush(SyncReason.launch));
193191
},
194192
);
195-
}
196193

197-
Future<void> _storeBeadImage(String userId, String imageUrl) async {
198-
try {
199-
final bytes = await _downloadImageBytes(imageUrl);
200-
if (bytes.isEmpty) return;
201-
final current = _local.read(userId, _presetId);
202-
await _local.write(
194+
unawaited(
195+
_refreshBeadImage(
203196
userId,
204-
_presetId,
205-
current.copyWith(
206-
beadImageUrl: imageUrl,
207-
beadImageBase64: base64Encode(bytes),
208-
),
209-
);
197+
urlCandidates: [
198+
if (detailBeadImageUrl != null) detailBeadImageUrl!,
199+
if (fallbackImageUrl != null) fallbackImageUrl,
200+
],
201+
refetchDetailOnFailure: detailBeadImageUrl == null,
202+
),
203+
);
204+
}
205+
206+
/// Downloads bead artwork via [MalaRemoteDataSource.fetchImageBytes]
207+
/// (injected as [_downloadImageBytes]) and persists bytes in Hive. Presigned
208+
/// S3 URLs expire, so stale cached URLs are retried against fresh catalogue /
209+
/// detail URLs when needed.
210+
Future<void> _refreshBeadImage(
211+
String userId, {
212+
List<String> urlCandidates = const [],
213+
bool refetchDetailOnFailure = true,
214+
}) async {
215+
final localState = _local.read(userId, _presetId);
216+
final cachedBytes = localState.beadImageBytes;
217+
if (cachedBytes != null) {
210218
if (!mounted) return;
211219
state = state.copyWith(
212-
beadImageUrl: imageUrl,
213-
beadImageBytes: Uint8List.fromList(bytes),
220+
beadImageUrl: localState.beadImageUrl ?? state.beadImageUrl,
221+
beadImageBytes: cachedBytes,
214222
);
223+
return;
224+
}
225+
226+
final candidates = <String>[];
227+
void addCandidate(String? url) {
228+
if (url == null || url.isEmpty) return;
229+
final key = stableNetworkImageCacheKey(url);
230+
if (candidates.any((c) => stableNetworkImageCacheKey(c) == key)) return;
231+
candidates.add(url);
232+
}
233+
234+
for (final url in urlCandidates) {
235+
addCandidate(url);
236+
}
237+
addCandidate(_mantra.beadImageUrl);
238+
addCandidate(localState.beadImageUrl);
239+
240+
for (final url in candidates) {
241+
final bytes = await _tryDownloadBeadImage(url);
242+
if (bytes == null) continue;
243+
await _persistBeadImage(userId, url, bytes);
244+
return;
245+
}
246+
247+
if (!refetchDetailOnFailure) return;
248+
249+
final detailResult = await _getAccumulatorDetail(_presetId);
250+
await detailResult.fold(
251+
(_) async {},
252+
(detail) async {
253+
final freshUrl = detail.beadImageUrl;
254+
if (freshUrl == null || freshUrl.isEmpty) return;
255+
256+
final bytes = await _tryDownloadBeadImage(freshUrl);
257+
if (bytes == null) return;
258+
await _persistBeadImage(userId, freshUrl, bytes);
259+
},
260+
);
261+
}
262+
263+
/// Network fetch delegated to [MalaRemoteDataSource.fetchImageBytes].
264+
Future<List<int>?> _tryDownloadBeadImage(String url) async {
265+
try {
266+
final bytes = await _downloadImageBytes(url);
267+
if (bytes.isEmpty) return null;
268+
return bytes;
215269
} catch (e) {
216-
_logger.warning('Failed to store mala bead image locally: $e');
270+
_logger.warning('Bead image download failed for $url: $e');
271+
return null;
217272
}
218273
}
219274

275+
Future<void> _persistBeadImage(
276+
String userId,
277+
String imageUrl,
278+
List<int> bytes,
279+
) async {
280+
final current = _local.read(userId, _presetId);
281+
await _local.write(
282+
userId,
283+
_presetId,
284+
current.copyWith(
285+
beadImageUrl: imageUrl,
286+
beadImageBase64: base64Encode(bytes),
287+
),
288+
);
289+
if (!mounted) return;
290+
state = state.copyWith(
291+
beadImageUrl: imageUrl,
292+
beadImageBytes: Uint8List.fromList(bytes),
293+
);
294+
}
295+
220296
/// +1 recitation. No-op while seeding or resetting. Monotonic — never decrements.
221297
void incrementBead({
222298
required bool soundEnabled,

lib/features/mala/presentation/screens/mala_screen.dart

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -259,9 +259,6 @@ class _MalaScreenState extends ConsumerState<MalaScreen> {
259259
beadInRound: displayBeadInRound,
260260
beadsPerRound: beadsPerRound,
261261
enabled: countingEnabled,
262-
beadImageUrl:
263-
counter.beadImageUrl ??
264-
mantra.beadImageUrl,
265262
beadImageBytes: counter.beadImageBytes,
266263
beadColor: const Color(0xFF8D6E63),
267264
threadColor: const Color(0xFFC62828),

lib/features/mala/presentation/widgets/group_accumulations_sheet.dart

Lines changed: 16 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,9 @@ class _GroupAccumulationsSheetState
8585
final locale = intlFormatLocaleOf(context);
8686
ref.watch(groupAccumulationCountsProvider(widget.presetId));
8787
final groups =
88-
ref.watch(joinedAccumulatorGroupsProvider(widget.presetId)).valueOrNull ??
88+
ref
89+
.watch(joinedAccumulatorGroupsProvider(widget.presetId))
90+
.valueOrNull ??
8991
widget.groups;
9092
final countsNotifier = ref.read(
9193
groupAccumulationCountsProvider(widget.presetId).notifier,
@@ -120,30 +122,20 @@ class _GroupAccumulationsSheetState
120122
),
121123
),
122124
const SizedBox(height: 12),
123-
Padding(
124-
padding: const EdgeInsets.symmetric(horizontal: 20),
125-
child: Text(
126-
context.l10n.mala_group_accumulations,
127-
style: Theme.of(context).textTheme.titleMedium?.copyWith(
128-
fontWeight: FontWeight.w700,
129-
),
130-
),
131-
),
132-
const SizedBox(height: 16),
133125
_SelectableAccumulationRow(
134126
isSelected: selection.isPersonal,
135127
accentColor: accentColor,
136128
onTap:
137-
() => ref
138-
.read(
139-
malaAccumulationSelectionProvider(
140-
widget.presetId,
141-
).notifier,
142-
)
143-
.selectPersonal(),
129+
() =>
130+
ref
131+
.read(
132+
malaAccumulationSelectionProvider(
133+
widget.presetId,
134+
).notifier,
135+
)
136+
.selectPersonal(),
144137
leading: _UserAvatar(avatarUrl: user?.avatarUrl),
145-
title:
146-
user != null ? _userDisplayName(user) : '—',
138+
title: user != null ? _userDisplayName(user) : '—',
147139
formattedCount: NumberFormat.decimalPattern(
148140
locale,
149141
).format(widget.personalTotalCount),
@@ -153,7 +145,7 @@ class _GroupAccumulationsSheetState
153145
Padding(
154146
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
155147
child: Text(
156-
context.l10n.mala_groups_section,
148+
context.l10n.mala_group_accumulations,
157149
style: Theme.of(context).textTheme.titleMedium?.copyWith(
158150
fontWeight: FontWeight.w700,
159151
),
@@ -244,10 +236,8 @@ class _SelectableAccumulationRow extends StatelessWidget {
244236
@override
245237
Widget build(BuildContext context) {
246238
final theme = Theme.of(context);
247-
final nameColor =
248-
isSelected ? accentColor : theme.colorScheme.onSurface;
249-
final countColor =
250-
isSelected ? accentColor : theme.colorScheme.onSurface;
239+
final nameColor = isSelected ? accentColor : theme.colorScheme.onSurface;
240+
final countColor = isSelected ? accentColor : theme.colorScheme.onSurface;
251241

252242
return Material(
253243
color: Colors.transparent,
@@ -296,8 +286,7 @@ class _UserAvatar extends StatelessWidget {
296286

297287
@override
298288
Widget build(BuildContext context) {
299-
final fallbackColor =
300-
Theme.of(context).colorScheme.surfaceContainerHighest;
289+
final fallbackColor = Theme.of(context).colorScheme.surfaceContainerHighest;
301290

302291
return ClipOval(
303292
child: SizedBox(

0 commit comments

Comments
 (0)