3
3
4
4
import 'package:flutter/material.dart' ;
5
5
import 'package:flutter_bloc/flutter_bloc.dart' ;
6
+ import 'package:ht_main/account/bloc/account_bloc.dart' ; // Import AccountBloc
6
7
import 'package:ht_main/headline-details/bloc/headline_details_bloc.dart' ;
7
8
import 'package:ht_main/l10n/l10n.dart' ;
8
9
import 'package:ht_main/shared/shared.dart' ;
@@ -19,40 +20,113 @@ class HeadlineDetailsPage extends StatelessWidget {
19
20
@override
20
21
Widget build (BuildContext context) {
21
22
final l10n = context.l10n;
23
+ // Keep a reference to headlineDetailsState to use in BlocListener
24
+ final headlineDetailsState = context.watch <HeadlineDetailsBloc >().state;
25
+
22
26
return SafeArea (
23
27
child: Scaffold (
24
28
// Body contains the BlocBuilder which returns either state widgets
25
29
// 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
+ ),
46
78
);
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
+ }
55
95
},
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
+ ),
56
130
),
57
131
),
58
132
);
@@ -71,6 +145,33 @@ class HeadlineDetailsPage extends StatelessWidget {
71
145
);
72
146
73
147
// 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
+
74
175
return CustomScrollView (
75
176
slivers: [
76
177
// --- App Bar ---
@@ -79,31 +180,18 @@ class HeadlineDetailsPage extends StatelessWidget {
79
180
icon: const Icon (Icons .arrow_back),
80
181
onPressed: () => Navigator .of (context).pop (),
81
182
),
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],
96
184
// Pinned=false, floating=true, snap=true is common for news apps
97
185
pinned: false ,
98
- floating: true ,
99
- snap: true ,
186
+ floating: true , // Trailing comma
187
+ snap: true , // Trailing comma
100
188
// 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
103
191
// 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
107
195
// --- Title ---
108
196
SliverPadding (
109
197
padding: horizontalPadding.copyWith (top: AppSpacing .lg),
0 commit comments