Skip to content

Commit 2133f3c

Browse files
authored
Merge pull request #25 from headlines-toolkit/feature_share_headlines_and_enhance_headline_details_page_data_fetching
Feature share headlines and enhance headline details page data fetching
2 parents c13a5a5 + c19da51 commit 2133f3c

File tree

10 files changed

+263
-99
lines changed

10 files changed

+263
-99
lines changed
Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,27 @@
1-
import 'dart:async'; // Ensure async is imported
1+
import 'dart:async';
22

33
import 'package:bloc/bloc.dart';
4-
import 'package:ht_data_repository/ht_data_repository.dart'; // Generic Data Repository
4+
import 'package:equatable/equatable.dart';
5+
import 'package:ht_data_repository/ht_data_repository.dart';
56
import 'package:ht_shared/ht_shared.dart'
6-
show
7-
Headline,
8-
HtHttpException,
9-
NotFoundException; // Shared models and standardized exceptions
7+
show Headline, HtHttpException, NotFoundException;
108

119
part 'headline_details_event.dart';
1210
part 'headline_details_state.dart';
1311

1412
class HeadlineDetailsBloc
1513
extends Bloc<HeadlineDetailsEvent, HeadlineDetailsState> {
1614
HeadlineDetailsBloc({required HtDataRepository<Headline> headlinesRepository})
17-
: _headlinesRepository = headlinesRepository,
18-
super(HeadlineDetailsInitial()) {
19-
on<HeadlineDetailsRequested>(_onHeadlineDetailsRequested);
15+
: _headlinesRepository = headlinesRepository,
16+
super(HeadlineDetailsInitial()) {
17+
on<FetchHeadlineById>(_onFetchHeadlineById);
18+
on<HeadlineProvided>(_onHeadlineProvided);
2019
}
2120

2221
final HtDataRepository<Headline> _headlinesRepository;
2322

24-
Future<void> _onHeadlineDetailsRequested(
25-
HeadlineDetailsRequested event,
23+
Future<void> _onFetchHeadlineById(
24+
FetchHeadlineById event,
2625
Emitter<HeadlineDetailsState> emit,
2726
) async {
2827
emit(HeadlineDetailsLoading());
@@ -37,4 +36,11 @@ class HeadlineDetailsBloc
3736
emit(HeadlineDetailsFailure(message: 'An unexpected error occurred: $e'));
3837
}
3938
}
39+
40+
void _onHeadlineProvided(
41+
HeadlineProvided event,
42+
Emitter<HeadlineDetailsState> emit,
43+
) {
44+
emit(HeadlineDetailsLoaded(headline: event.headline));
45+
}
4046
}
Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,24 @@
11
part of 'headline_details_bloc.dart';
22

3-
abstract class HeadlineDetailsEvent {}
3+
abstract class HeadlineDetailsEvent extends Equatable {
4+
const HeadlineDetailsEvent();
45

5-
class HeadlineDetailsRequested extends HeadlineDetailsEvent {
6-
HeadlineDetailsRequested({required this.headlineId});
6+
@override
7+
List<Object> get props => [];
8+
}
79

10+
class FetchHeadlineById extends HeadlineDetailsEvent {
11+
const FetchHeadlineById(this.headlineId);
812
final String headlineId;
13+
14+
@override
15+
List<Object> get props => [headlineId];
16+
}
17+
18+
class HeadlineProvided extends HeadlineDetailsEvent {
19+
const HeadlineProvided(this.headline);
20+
final Headline headline;
21+
22+
@override
23+
List<Object> get props => [headline];
924
}
Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,30 @@
11
part of 'headline_details_bloc.dart';
22

3-
abstract class HeadlineDetailsState {}
3+
abstract class HeadlineDetailsState extends Equatable {
4+
const HeadlineDetailsState();
5+
6+
@override
7+
List<Object> get props => [];
8+
}
49

510
class HeadlineDetailsInitial extends HeadlineDetailsState {}
611

712
class HeadlineDetailsLoading extends HeadlineDetailsState {}
813

914
class HeadlineDetailsLoaded extends HeadlineDetailsState {
10-
HeadlineDetailsLoaded({required this.headline});
15+
const HeadlineDetailsLoaded({required this.headline});
1116

1217
final Headline headline;
18+
19+
@override
20+
List<Object> get props => [headline];
1321
}
1422

1523
class HeadlineDetailsFailure extends HeadlineDetailsState {
16-
HeadlineDetailsFailure({required this.message});
24+
const HeadlineDetailsFailure({required this.message});
1725

1826
final String message;
27+
28+
@override
29+
List<Object> get props => [message];
1930
}

lib/headline-details/view/headline_details_page.dart

Lines changed: 131 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,63 @@
11
//
22
// ignore_for_file: avoid_redundant_argument_values
33

4+
import 'package:flutter/foundation.dart' show kIsWeb; // Import kIsWeb
45
import 'package:flutter/material.dart';
56
import 'package:flutter_bloc/flutter_bloc.dart';
67
import 'package:ht_main/account/bloc/account_bloc.dart'; // Import AccountBloc
7-
import 'package:ht_main/headline-details/bloc/headline_details_bloc.dart';
8+
import 'package:ht_main/headline-details/bloc/headline_details_bloc.dart'; // Import BLoC
89
import 'package:ht_main/l10n/l10n.dart';
910
import 'package:ht_main/shared/shared.dart';
1011
import 'package:ht_shared/ht_shared.dart'
1112
show Headline; // Import Headline model
1213
import 'package:intl/intl.dart';
14+
import 'package:share_plus/share_plus.dart'; // Import share_plus
1315
import 'package:url_launcher/url_launcher_string.dart';
1416

15-
class HeadlineDetailsPage extends StatelessWidget {
16-
const HeadlineDetailsPage({required this.headlineId, super.key});
17+
class HeadlineDetailsPage extends StatefulWidget {
18+
const HeadlineDetailsPage({
19+
super.key,
20+
this.headlineId,
21+
this.initialHeadline,
22+
}) : assert(headlineId != null || initialHeadline != null);
1723

18-
final String headlineId;
24+
final String? headlineId;
25+
final Headline? initialHeadline;
26+
27+
@override
28+
State<HeadlineDetailsPage> createState() => _HeadlineDetailsPageState();
29+
}
30+
31+
class _HeadlineDetailsPageState extends State<HeadlineDetailsPage> {
32+
@override
33+
void initState() {
34+
super.initState();
35+
if (widget.initialHeadline != null) {
36+
context
37+
.read<HeadlineDetailsBloc>()
38+
.add(HeadlineProvided(widget.initialHeadline!));
39+
} else if (widget.headlineId != null) {
40+
context
41+
.read<HeadlineDetailsBloc>()
42+
.add(FetchHeadlineById(widget.headlineId!));
43+
}
44+
}
1945

2046
@override
2147
Widget build(BuildContext context) {
2248
final l10n = context.l10n;
23-
// Keep a reference to headlineDetailsState to use in BlocListener
24-
final headlineDetailsState = context.watch<HeadlineDetailsBloc>().state;
2549

2650
return SafeArea(
2751
child: Scaffold(
28-
// Body contains the BlocBuilder which returns either state widgets
29-
// or the scroll view
3052
body: BlocListener<AccountBloc, AccountState>(
3153
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;
54+
final detailsState = context.read<HeadlineDetailsBloc>().state;
55+
if (detailsState is HeadlineDetailsLoaded) {
56+
if (current.status == AccountStatus.failure &&
57+
previous.status != AccountStatus.failure) {
58+
return true;
59+
}
60+
final currentHeadlineId = detailsState.headline.id;
3961
final wasPreviouslySaved =
4062
previous.preferences?.savedHeadlines.any(
4163
(h) => h.id == currentHeadlineId,
@@ -46,20 +68,18 @@ class HeadlineDetailsPage extends StatelessWidget {
4668
(h) => h.id == currentHeadlineId,
4769
) ??
4870
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)
5171
return (wasPreviouslySaved != isCurrentlySaved) ||
5272
(current.status == AccountStatus.success &&
5373
previous.status != AccountStatus.success);
5474
}
5575
return false;
5676
},
5777
listener: (context, accountState) {
58-
if (headlineDetailsState is HeadlineDetailsLoaded) {
59-
final currentHeadline = headlineDetailsState.headline;
78+
final detailsState = context.read<HeadlineDetailsBloc>().state;
79+
if (detailsState is HeadlineDetailsLoaded) {
6080
final nowIsSaved =
6181
accountState.preferences?.savedHeadlines.any(
62-
(h) => h.id == currentHeadline.id,
82+
(h) => h.id == detailsState.headline.id,
6383
) ??
6484
false;
6585

@@ -72,12 +92,11 @@ class HeadlineDetailsPage extends StatelessWidget {
7292
content: Text(
7393
accountState.errorMessage ??
7494
l10n.headlineSaveErrorSnackbar,
75-
), // Use specific or generic error
95+
),
7696
backgroundColor: Theme.of(context).colorScheme.error,
7797
),
7898
);
7999
} else {
80-
// Only show success snackbar if the state isn't failure
81100
ScaffoldMessenger.of(context)
82101
..hideCurrentSnackBar()
83102
..showSnackBar(
@@ -94,36 +113,32 @@ class HeadlineDetailsPage extends StatelessWidget {
94113
}
95114
},
96115
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
116+
builder: (context, state) {
117+
return switch (state) {
118+
HeadlineDetailsInitial() ||
119+
HeadlineDetailsLoading() =>
120+
LoadingStateWidget(
121+
icon: Icons.downloading,
122+
headline: l10n.headlineDetailsLoadingHeadline,
123+
subheadline: l10n.headlineDetailsLoadingSubheadline,
124+
),
125+
final HeadlineDetailsFailure failureState => FailureStateWidget(
126+
message: failureState.message,
127+
onRetry: () {
128+
if (widget.headlineId != null) {
129+
context
130+
.read<HeadlineDetailsBloc>()
131+
.add(FetchHeadlineById(widget.headlineId!));
132+
}
133+
// If only initialHeadline was provided and it failed to load
134+
// (which shouldn't happen with HeadlineProvided),
135+
// there's no ID to refetch. Consider a different UI.
136+
},
137+
),
138+
final HeadlineDetailsLoaded loadedState =>
139+
_buildLoadedContent(context, loadedState.headline),
140+
// Add a default case to satisfy exhaustiveness
141+
_ => const Center(child: Text('Unknown state')),
127142
};
128143
},
129144
),
@@ -166,10 +181,69 @@ class HeadlineDetailsPage extends StatelessWidget {
166181
},
167182
);
168183

169-
final shareButton = IconButton(
170-
icon: const Icon(Icons.share),
171-
onPressed: () {
172-
// TODO(fulleni): Implement share functionality
184+
// Use a Builder to get the correct context for sharePositionOrigin
185+
final Widget shareButtonWidget = Builder(
186+
builder: (BuildContext buttonContext) {
187+
return IconButton(
188+
icon: const Icon(Icons.share),
189+
tooltip: l10n.shareActionTooltip,
190+
onPressed: () async {
191+
final box = buttonContext.findRenderObject() as RenderBox?;
192+
Rect? sharePositionOrigin;
193+
if (box != null) {
194+
sharePositionOrigin = box.localToGlobal(Offset.zero) & box.size;
195+
}
196+
197+
String shareText = headline.title;
198+
if (headline.url != null && headline.url!.isNotEmpty) {
199+
shareText += '\n\n${headline.url}';
200+
}
201+
202+
ShareParams params;
203+
if (kIsWeb && headline.url != null && headline.url!.isNotEmpty) {
204+
// For web, prioritize sharing the URL directly as a URI.
205+
// The 'title' in ShareParams might be used by some platforms or if
206+
// the plugin's web handling evolves to use it with navigator.share's title field.
207+
params = ShareParams(
208+
uri: Uri.parse(headline.url!),
209+
title: headline.title, // Title hint for the shared content
210+
sharePositionOrigin: sharePositionOrigin,
211+
);
212+
} else if (headline.url != null && headline.url!.isNotEmpty) {
213+
// For native platforms with a URL, combine title and URL in text.
214+
// Subject can be used by email clients.
215+
params = ShareParams(
216+
text: '${headline.title}\n\n${headline.url!}',
217+
subject: headline.title,
218+
sharePositionOrigin: sharePositionOrigin,
219+
);
220+
} else {
221+
// No URL, share only the title as text (works for all platforms).
222+
params = ShareParams(
223+
text: headline.title,
224+
subject: headline.title, // Subject for email clients
225+
sharePositionOrigin: sharePositionOrigin,
226+
);
227+
}
228+
229+
final shareResult = await SharePlus.instance.share(params);
230+
231+
// Optional: Handle ShareResult for user feedback
232+
if (buttonContext.mounted) { // Check if context is still valid
233+
if (shareResult.status == ShareResultStatus.unavailable) {
234+
ScaffoldMessenger.of(buttonContext).showSnackBar(
235+
SnackBar(
236+
content: Text(
237+
l10n.sharingUnavailableSnackbar, // Add this l10n key
238+
),
239+
),
240+
);
241+
}
242+
// You can add more feedback for success/dismissed if desired
243+
// e.g., print('Share result: ${shareResult.status}, raw: ${shareResult.raw}');
244+
}
245+
},
246+
);
173247
},
174248
);
175249

@@ -181,7 +255,7 @@ class HeadlineDetailsPage extends StatelessWidget {
181255
icon: const Icon(Icons.arrow_back),
182256
onPressed: () => Navigator.of(context).pop(),
183257
),
184-
actions: [bookmarkButton, shareButton],
258+
actions: [bookmarkButton, shareButtonWidget], // Use the new widget
185259
// Pinned=false, floating=true, snap=true is common for news apps
186260
pinned: false,
187261
floating: true, // Trailing comma

lib/headlines-feed/widgets/headline_item_widget.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ class HeadlineItemWidget extends StatelessWidget {
5050
context.goNamed(
5151
targetRouteName, // Use the new parameter here
5252
pathParameters: {'id': headline.id},
53+
extra: headline, // Pass the full headline object
5354
);
5455
},
5556
child: Padding(

lib/l10n/arb/app_ar.arb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,5 +779,13 @@
779779
"headlineSaveErrorSnackbar": "تعذر تحديث حالة الحفظ. يرجى المحاولة مرة أخرى.",
780780
"@headlineSaveErrorSnackbar": {
781781
"description": "Snackbar message shown when saving/unsaving a headline fails"
782+
},
783+
"shareActionTooltip": "مشاركة العنوان",
784+
"@shareActionTooltip": {
785+
"description": "Tooltip for the share button on the headline details page"
786+
},
787+
"sharingUnavailableSnackbar": "المشاركة غير متاحة على هذا الجهاز أو المنصة.",
788+
"@sharingUnavailableSnackbar": {
789+
"description": "Snackbar message shown when sharing is unavailable"
782790
}
783791
}

0 commit comments

Comments
 (0)