Skip to content

Feature share headlines and enhance headline details page data fetching #25

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 17 additions & 11 deletions lib/headline-details/bloc/headline_details_bloc.dart
Original file line number Diff line number Diff line change
@@ -1,28 +1,27 @@
import 'dart:async'; // Ensure async is imported
import 'dart:async';

import 'package:bloc/bloc.dart';
import 'package:ht_data_repository/ht_data_repository.dart'; // Generic Data Repository
import 'package:equatable/equatable.dart';
import 'package:ht_data_repository/ht_data_repository.dart';
import 'package:ht_shared/ht_shared.dart'
show
Headline,
HtHttpException,
NotFoundException; // Shared models and standardized exceptions
show Headline, HtHttpException, NotFoundException;

part 'headline_details_event.dart';
part 'headline_details_state.dart';

class HeadlineDetailsBloc
extends Bloc<HeadlineDetailsEvent, HeadlineDetailsState> {
HeadlineDetailsBloc({required HtDataRepository<Headline> headlinesRepository})
: _headlinesRepository = headlinesRepository,
super(HeadlineDetailsInitial()) {
on<HeadlineDetailsRequested>(_onHeadlineDetailsRequested);
: _headlinesRepository = headlinesRepository,
super(HeadlineDetailsInitial()) {
on<FetchHeadlineById>(_onFetchHeadlineById);
on<HeadlineProvided>(_onHeadlineProvided);
}

final HtDataRepository<Headline> _headlinesRepository;

Future<void> _onHeadlineDetailsRequested(
HeadlineDetailsRequested event,
Future<void> _onFetchHeadlineById(
FetchHeadlineById event,
Emitter<HeadlineDetailsState> emit,
) async {
emit(HeadlineDetailsLoading());
Expand All @@ -37,4 +36,11 @@ class HeadlineDetailsBloc
emit(HeadlineDetailsFailure(message: 'An unexpected error occurred: $e'));
}
}

void _onHeadlineProvided(
HeadlineProvided event,
Emitter<HeadlineDetailsState> emit,
) {
emit(HeadlineDetailsLoaded(headline: event.headline));
}
}
21 changes: 18 additions & 3 deletions lib/headline-details/bloc/headline_details_event.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
part of 'headline_details_bloc.dart';

abstract class HeadlineDetailsEvent {}
abstract class HeadlineDetailsEvent extends Equatable {
const HeadlineDetailsEvent();

class HeadlineDetailsRequested extends HeadlineDetailsEvent {
HeadlineDetailsRequested({required this.headlineId});
@override
List<Object> get props => [];
}

class FetchHeadlineById extends HeadlineDetailsEvent {
const FetchHeadlineById(this.headlineId);
final String headlineId;

@override
List<Object> get props => [headlineId];
}

class HeadlineProvided extends HeadlineDetailsEvent {
const HeadlineProvided(this.headline);
final Headline headline;

@override
List<Object> get props => [headline];
}
17 changes: 14 additions & 3 deletions lib/headline-details/bloc/headline_details_state.dart
Original file line number Diff line number Diff line change
@@ -1,19 +1,30 @@
part of 'headline_details_bloc.dart';

abstract class HeadlineDetailsState {}
abstract class HeadlineDetailsState extends Equatable {
const HeadlineDetailsState();

@override
List<Object> get props => [];
}

class HeadlineDetailsInitial extends HeadlineDetailsState {}

class HeadlineDetailsLoading extends HeadlineDetailsState {}

class HeadlineDetailsLoaded extends HeadlineDetailsState {
HeadlineDetailsLoaded({required this.headline});
const HeadlineDetailsLoaded({required this.headline});

final Headline headline;

@override
List<Object> get props => [headline];
}

class HeadlineDetailsFailure extends HeadlineDetailsState {
HeadlineDetailsFailure({required this.message});
const HeadlineDetailsFailure({required this.message});

final String message;

@override
List<Object> get props => [message];
}
188 changes: 131 additions & 57 deletions lib/headline-details/view/headline_details_page.dart
Original file line number Diff line number Diff line change
@@ -1,41 +1,63 @@
//
// ignore_for_file: avoid_redundant_argument_values

import 'package:flutter/foundation.dart' show kIsWeb; // Import kIsWeb
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:ht_main/account/bloc/account_bloc.dart'; // Import AccountBloc
import 'package:ht_main/headline-details/bloc/headline_details_bloc.dart';
import 'package:ht_main/headline-details/bloc/headline_details_bloc.dart'; // Import BLoC
import 'package:ht_main/l10n/l10n.dart';
import 'package:ht_main/shared/shared.dart';
import 'package:ht_shared/ht_shared.dart'
show Headline; // Import Headline model
import 'package:intl/intl.dart';
import 'package:share_plus/share_plus.dart'; // Import share_plus
import 'package:url_launcher/url_launcher_string.dart';

class HeadlineDetailsPage extends StatelessWidget {
const HeadlineDetailsPage({required this.headlineId, super.key});
class HeadlineDetailsPage extends StatefulWidget {
const HeadlineDetailsPage({
super.key,
this.headlineId,
this.initialHeadline,
}) : assert(headlineId != null || initialHeadline != null);

final String headlineId;
final String? headlineId;
final Headline? initialHeadline;

@override
State<HeadlineDetailsPage> createState() => _HeadlineDetailsPageState();
}

class _HeadlineDetailsPageState extends State<HeadlineDetailsPage> {
@override
void initState() {
super.initState();
if (widget.initialHeadline != null) {
context
.read<HeadlineDetailsBloc>()
.add(HeadlineProvided(widget.initialHeadline!));
} else if (widget.headlineId != null) {
context
.read<HeadlineDetailsBloc>()
.add(FetchHeadlineById(widget.headlineId!));
}
}

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

return SafeArea(
child: Scaffold(
// Body contains the BlocBuilder which returns either state widgets
// or the scroll view
body: BlocListener<AccountBloc, AccountState>(
listenWhen: (previous, current) {
// Listen if status is failure or if the saved status of *this* headline changed
if (current.status == AccountStatus.failure &&
previous.status != AccountStatus.failure) {
return true;
}
if (headlineDetailsState is HeadlineDetailsLoaded) {
final currentHeadlineId = headlineDetailsState.headline.id;
final detailsState = context.read<HeadlineDetailsBloc>().state;
if (detailsState is HeadlineDetailsLoaded) {
if (current.status == AccountStatus.failure &&
previous.status != AccountStatus.failure) {
return true;
}
final currentHeadlineId = detailsState.headline.id;
final wasPreviouslySaved =
previous.preferences?.savedHeadlines.any(
(h) => h.id == currentHeadlineId,
Expand All @@ -46,20 +68,18 @@ class HeadlineDetailsPage extends StatelessWidget {
(h) => h.id == currentHeadlineId,
) ??
false;
// Listen if the specific headline's saved status changed OR
// if a general success occurred (e.g. after an optimistic update that might not change the list length but confirms persistence)
return (wasPreviouslySaved != isCurrentlySaved) ||
(current.status == AccountStatus.success &&
previous.status != AccountStatus.success);
}
return false;
},
listener: (context, accountState) {
if (headlineDetailsState is HeadlineDetailsLoaded) {
final currentHeadline = headlineDetailsState.headline;
final detailsState = context.read<HeadlineDetailsBloc>().state;
if (detailsState is HeadlineDetailsLoaded) {
final nowIsSaved =
accountState.preferences?.savedHeadlines.any(
(h) => h.id == currentHeadline.id,
(h) => h.id == detailsState.headline.id,
) ??
false;

Expand All @@ -72,12 +92,11 @@ class HeadlineDetailsPage extends StatelessWidget {
content: Text(
accountState.errorMessage ??
l10n.headlineSaveErrorSnackbar,
), // Use specific or generic error
),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
} else {
// Only show success snackbar if the state isn't failure
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
Expand All @@ -94,36 +113,32 @@ class HeadlineDetailsPage extends StatelessWidget {
}
},
child: BlocBuilder<HeadlineDetailsBloc, HeadlineDetailsState>(
// No need to re-watch headlineDetailsState here, already have it.
// builder: (context, state) // state here is headlineDetailsState
builder: (context, headlineDetailsBuilderState) {
// Handle Loading/Initial/Failure states outside the scroll view
// for better user experience.
// Use headlineDetailsBuilderState for the switch
return switch (headlineDetailsBuilderState) {
HeadlineDetailsInitial _ => InitialStateWidget(
icon: Icons.article,
headline: l10n.headlineDetailsInitialHeadline,
subheadline: l10n.headlineDetailsInitialSubheadline,
),
HeadlineDetailsLoading _ => LoadingStateWidget(
icon: Icons.downloading,
headline: l10n.headlineDetailsLoadingHeadline,
subheadline: l10n.headlineDetailsLoadingSubheadline,
),
final HeadlineDetailsFailure state => FailureStateWidget(
message: state.message,
onRetry: () {
context.read<HeadlineDetailsBloc>().add(
HeadlineDetailsRequested(headlineId: headlineId),
);
},
),
final HeadlineDetailsLoaded state => _buildLoadedContent(
context,
state.headline,
),
_ => const SizedBox.shrink(), // Should not happen in practice
builder: (context, state) {
return switch (state) {
HeadlineDetailsInitial() ||
HeadlineDetailsLoading() =>
LoadingStateWidget(
icon: Icons.downloading,
headline: l10n.headlineDetailsLoadingHeadline,
subheadline: l10n.headlineDetailsLoadingSubheadline,
),
final HeadlineDetailsFailure failureState => FailureStateWidget(
message: failureState.message,
onRetry: () {
if (widget.headlineId != null) {
context
.read<HeadlineDetailsBloc>()
.add(FetchHeadlineById(widget.headlineId!));
}
// If only initialHeadline was provided and it failed to load
// (which shouldn't happen with HeadlineProvided),
// there's no ID to refetch. Consider a different UI.
},
),
final HeadlineDetailsLoaded loadedState =>
_buildLoadedContent(context, loadedState.headline),
// Add a default case to satisfy exhaustiveness
_ => const Center(child: Text('Unknown state')),
};
},
),
Expand Down Expand Up @@ -166,10 +181,69 @@ class HeadlineDetailsPage extends StatelessWidget {
},
);

final shareButton = IconButton(
icon: const Icon(Icons.share),
onPressed: () {
// TODO(fulleni): Implement share functionality
// Use a Builder to get the correct context for sharePositionOrigin
final Widget shareButtonWidget = Builder(
builder: (BuildContext buttonContext) {
return IconButton(
icon: const Icon(Icons.share),
tooltip: l10n.shareActionTooltip,
onPressed: () async {
final box = buttonContext.findRenderObject() as RenderBox?;
Rect? sharePositionOrigin;
if (box != null) {
sharePositionOrigin = box.localToGlobal(Offset.zero) & box.size;
}

String shareText = headline.title;
if (headline.url != null && headline.url!.isNotEmpty) {
shareText += '\n\n${headline.url}';
}

ShareParams params;
if (kIsWeb && headline.url != null && headline.url!.isNotEmpty) {
// For web, prioritize sharing the URL directly as a URI.
// The 'title' in ShareParams might be used by some platforms or if
// the plugin's web handling evolves to use it with navigator.share's title field.
params = ShareParams(
uri: Uri.parse(headline.url!),
title: headline.title, // Title hint for the shared content
sharePositionOrigin: sharePositionOrigin,
);
} else if (headline.url != null && headline.url!.isNotEmpty) {
// For native platforms with a URL, combine title and URL in text.
// Subject can be used by email clients.
params = ShareParams(
text: '${headline.title}\n\n${headline.url!}',
subject: headline.title,
sharePositionOrigin: sharePositionOrigin,
);
} else {
// No URL, share only the title as text (works for all platforms).
params = ShareParams(
text: headline.title,
subject: headline.title, // Subject for email clients
sharePositionOrigin: sharePositionOrigin,
);
}

final shareResult = await SharePlus.instance.share(params);

// Optional: Handle ShareResult for user feedback
if (buttonContext.mounted) { // Check if context is still valid
if (shareResult.status == ShareResultStatus.unavailable) {
ScaffoldMessenger.of(buttonContext).showSnackBar(
SnackBar(
content: Text(
l10n.sharingUnavailableSnackbar, // Add this l10n key
),
),
);
}
// You can add more feedback for success/dismissed if desired
// e.g., print('Share result: ${shareResult.status}, raw: ${shareResult.raw}');
}
},
);
},
);

Expand All @@ -181,7 +255,7 @@ class HeadlineDetailsPage extends StatelessWidget {
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).pop(),
),
actions: [bookmarkButton, shareButton],
actions: [bookmarkButton, shareButtonWidget], // Use the new widget
// Pinned=false, floating=true, snap=true is common for news apps
pinned: false,
floating: true, // Trailing comma
Expand Down
1 change: 1 addition & 0 deletions lib/headlines-feed/widgets/headline_item_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ class HeadlineItemWidget extends StatelessWidget {
context.goNamed(
targetRouteName, // Use the new parameter here
pathParameters: {'id': headline.id},
extra: headline, // Pass the full headline object
);
},
child: Padding(
Expand Down
8 changes: 8 additions & 0 deletions lib/l10n/arb/app_ar.arb
Original file line number Diff line number Diff line change
Expand Up @@ -779,5 +779,13 @@
"headlineSaveErrorSnackbar": "تعذر تحديث حالة الحفظ. يرجى المحاولة مرة أخرى.",
"@headlineSaveErrorSnackbar": {
"description": "Snackbar message shown when saving/unsaving a headline fails"
},
"shareActionTooltip": "مشاركة العنوان",
"@shareActionTooltip": {
"description": "Tooltip for the share button on the headline details page"
},
"sharingUnavailableSnackbar": "المشاركة غير متاحة على هذا الجهاز أو المنصة.",
"@sharingUnavailableSnackbar": {
"description": "Snackbar message shown when sharing is unavailable"
}
}
Loading
Loading