Skip to content

Commit 0fd355a

Browse files
committed
feat(details): Add save/unsave headline feature
- Implemented bookmark button - Added snackbar messages - Handled save errors
1 parent 92ab08b commit 0fd355a

File tree

1 file changed

+137
-49
lines changed

1 file changed

+137
-49
lines changed

lib/headline-details/view/headline_details_page.dart

Lines changed: 137 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import 'package:flutter/material.dart';
55
import 'package:flutter_bloc/flutter_bloc.dart';
6+
import 'package:ht_main/account/bloc/account_bloc.dart'; // Import AccountBloc
67
import 'package:ht_main/headline-details/bloc/headline_details_bloc.dart';
78
import 'package:ht_main/l10n/l10n.dart';
89
import 'package:ht_main/shared/shared.dart';
@@ -19,40 +20,113 @@ class HeadlineDetailsPage extends StatelessWidget {
1920
@override
2021
Widget build(BuildContext context) {
2122
final l10n = context.l10n;
23+
// Keep a reference to headlineDetailsState to use in BlocListener
24+
final headlineDetailsState = context.watch<HeadlineDetailsBloc>().state;
25+
2226
return SafeArea(
2327
child: Scaffold(
2428
// Body contains the BlocBuilder which returns either state widgets
2529
// or the scroll view
26-
body: BlocBuilder<HeadlineDetailsBloc, HeadlineDetailsState>(
27-
builder: (context, state) {
28-
// Handle Loading/Initial/Failure states outside the scroll view
29-
// for better user experience.
30-
return switch (state) {
31-
HeadlineDetailsInitial _ => InitialStateWidget(
32-
icon: Icons.article,
33-
headline: l10n.headlineDetailsInitialHeadline,
34-
subheadline: l10n.headlineDetailsInitialSubheadline,
35-
),
36-
HeadlineDetailsLoading _ => LoadingStateWidget(
37-
icon: Icons.downloading,
38-
headline: l10n.headlineDetailsLoadingHeadline,
39-
subheadline: l10n.headlineDetailsLoadingSubheadline,
40-
),
41-
final HeadlineDetailsFailure state => FailureStateWidget(
42-
message: state.message,
43-
onRetry: () {
44-
context.read<HeadlineDetailsBloc>().add(
45-
HeadlineDetailsRequested(headlineId: headlineId),
30+
body: BlocListener<AccountBloc, AccountState>(
31+
listenWhen: (previous, current) {
32+
// Listen if status is failure or if the saved status of *this* headline changed
33+
if (current.status == AccountStatus.failure &&
34+
previous.status != AccountStatus.failure) {
35+
return true;
36+
}
37+
if (headlineDetailsState is HeadlineDetailsLoaded) {
38+
final currentHeadlineId = headlineDetailsState.headline.id;
39+
final wasPreviouslySaved =
40+
previous.preferences?.savedHeadlines.any(
41+
(h) => h.id == currentHeadlineId,
42+
) ??
43+
false;
44+
final isCurrentlySaved =
45+
current.preferences?.savedHeadlines.any(
46+
(h) => h.id == currentHeadlineId,
47+
) ??
48+
false;
49+
// Listen if the specific headline's saved status changed OR
50+
// if a general success occurred (e.g. after an optimistic update that might not change the list length but confirms persistence)
51+
return (wasPreviouslySaved != isCurrentlySaved) ||
52+
(current.status == AccountStatus.success &&
53+
previous.status != AccountStatus.success);
54+
}
55+
return false;
56+
},
57+
listener: (context, accountState) {
58+
if (headlineDetailsState is HeadlineDetailsLoaded) {
59+
final currentHeadline = headlineDetailsState.headline;
60+
final nowIsSaved =
61+
accountState.preferences?.savedHeadlines.any(
62+
(h) => h.id == currentHeadline.id,
63+
) ??
64+
false;
65+
66+
if (accountState.status == AccountStatus.failure &&
67+
accountState.errorMessage != null) {
68+
ScaffoldMessenger.of(context)
69+
..hideCurrentSnackBar()
70+
..showSnackBar(
71+
SnackBar(
72+
content: Text(
73+
accountState.errorMessage ??
74+
l10n.headlineSaveErrorSnackbar,
75+
), // Use specific or generic error
76+
backgroundColor: Theme.of(context).colorScheme.error,
77+
),
4678
);
47-
},
48-
),
49-
final HeadlineDetailsLoaded state => _buildLoadedContent(
50-
context,
51-
state.headline,
52-
),
53-
_ => const SizedBox.shrink(), // Should not happen in practice
54-
};
79+
} else {
80+
// Only show success snackbar if the state isn't failure
81+
ScaffoldMessenger.of(context)
82+
..hideCurrentSnackBar()
83+
..showSnackBar(
84+
SnackBar(
85+
content: Text(
86+
nowIsSaved
87+
? l10n.headlineSavedSuccessSnackbar
88+
: l10n.headlineUnsavedSuccessSnackbar,
89+
),
90+
duration: const Duration(seconds: 2),
91+
),
92+
);
93+
}
94+
}
5595
},
96+
child: BlocBuilder<HeadlineDetailsBloc, HeadlineDetailsState>(
97+
// No need to re-watch headlineDetailsState here, already have it.
98+
// builder: (context, state) // state here is headlineDetailsState
99+
builder: (context, headlineDetailsBuilderState) {
100+
// Handle Loading/Initial/Failure states outside the scroll view
101+
// for better user experience.
102+
// Use headlineDetailsBuilderState for the switch
103+
return switch (headlineDetailsBuilderState) {
104+
HeadlineDetailsInitial _ => InitialStateWidget(
105+
icon: Icons.article,
106+
headline: l10n.headlineDetailsInitialHeadline,
107+
subheadline: l10n.headlineDetailsInitialSubheadline,
108+
),
109+
HeadlineDetailsLoading _ => LoadingStateWidget(
110+
icon: Icons.downloading,
111+
headline: l10n.headlineDetailsLoadingHeadline,
112+
subheadline: l10n.headlineDetailsLoadingSubheadline,
113+
),
114+
final HeadlineDetailsFailure state => FailureStateWidget(
115+
message: state.message,
116+
onRetry: () {
117+
context.read<HeadlineDetailsBloc>().add(
118+
HeadlineDetailsRequested(headlineId: headlineId),
119+
);
120+
},
121+
),
122+
final HeadlineDetailsLoaded state => _buildLoadedContent(
123+
context,
124+
state.headline,
125+
),
126+
_ => const SizedBox.shrink(), // Should not happen in practice
127+
};
128+
},
129+
),
56130
),
57131
),
58132
);
@@ -71,6 +145,33 @@ class HeadlineDetailsPage extends StatelessWidget {
71145
);
72146

73147
// Return CustomScrollView instead of SingleChildScrollView
148+
// Watch AccountBloc state for saved status
149+
final accountState = context.watch<AccountBloc>().state;
150+
final isSaved =
151+
accountState.preferences?.savedHeadlines.any(
152+
(h) => h.id == headline.id,
153+
) ??
154+
false;
155+
156+
final bookmarkButton = IconButton(
157+
icon: Icon(isSaved ? Icons.bookmark : Icons.bookmark_border),
158+
tooltip: isSaved
159+
? l10n.headlineDetailsRemoveFromSavedTooltip
160+
: l10n.headlineDetailsSaveTooltip,
161+
onPressed: () {
162+
context.read<AccountBloc>().add(
163+
AccountSaveHeadlineToggled(headline: headline),
164+
);
165+
},
166+
);
167+
168+
final shareButton = IconButton(
169+
icon: const Icon(Icons.share),
170+
onPressed: () {
171+
// TODO(fulleni): Implement share functionality
172+
},
173+
);
174+
74175
return CustomScrollView(
75176
slivers: [
76177
// --- App Bar ---
@@ -79,31 +180,18 @@ class HeadlineDetailsPage extends StatelessWidget {
79180
icon: const Icon(Icons.arrow_back),
80181
onPressed: () => Navigator.of(context).pop(),
81182
),
82-
actions: [
83-
IconButton(
84-
icon: const Icon(Icons.bookmark_border),
85-
onPressed: () {
86-
// TODO(fulleni): Implement bookmark functionality
87-
},
88-
),
89-
IconButton(
90-
icon: const Icon(Icons.share),
91-
onPressed: () {
92-
// TODO(fulleni): Implement share functionality
93-
},
94-
),
95-
],
183+
actions: [bookmarkButton, shareButton],
96184
// Pinned=false, floating=true, snap=true is common for news apps
97185
pinned: false,
98-
floating: true,
99-
snap: true,
186+
floating: true, // Trailing comma
187+
snap: true, // Trailing comma
100188
// Transparent background to let content scroll behind if needed
101-
backgroundColor: Colors.transparent,
102-
elevation: 0,
189+
backgroundColor: Colors.transparent, // Trailing comma
190+
elevation: 0, // Trailing comma
103191
// Ensure icons use appropriate theme color
104-
foregroundColor: theme.colorScheme.onSurface,
105-
),
106-
192+
foregroundColor:
193+
theme.colorScheme.onSurface, // Trailing comma (optional if last)
194+
), // SliverAppBar
107195
// --- Title ---
108196
SliverPadding(
109197
padding: horizontalPadding.copyWith(top: AppSpacing.lg),

0 commit comments

Comments
 (0)