diff --git a/lib/account/view/manage_followed_items/categories/followed_categories_list_page.dart b/lib/account/view/manage_followed_items/categories/followed_categories_list_page.dart index 73cf2321..030a0fcf 100644 --- a/lib/account/view/manage_followed_items/categories/followed_categories_list_page.dart +++ b/lib/account/view/manage_followed_items/categories/followed_categories_list_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:ht_main/account/bloc/account_bloc.dart'; +import 'package:ht_main/entity_details/view/entity_details_page.dart'; // Added import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/router/routes.dart'; import 'package:ht_main/shared/constants/app_spacing.dart'; @@ -111,6 +112,13 @@ class FollowedCategoriesListPage extends StatelessWidget { ) : const Icon(Icons.category_outlined), title: Text(category.name), + onTap: () { + // Added onTap for navigation + context.push( + Routes.categoryDetails, + extra: EntityDetailsPageArguments(entity: category), + ); + }, trailing: IconButton( icon: Icon( Icons.remove_circle_outline, diff --git a/lib/account/view/manage_followed_items/sources/followed_sources_list_page.dart b/lib/account/view/manage_followed_items/sources/followed_sources_list_page.dart index 6f9ff966..5d2f744c 100644 --- a/lib/account/view/manage_followed_items/sources/followed_sources_list_page.dart +++ b/lib/account/view/manage_followed_items/sources/followed_sources_list_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:ht_main/account/bloc/account_bloc.dart'; +import 'package:ht_main/entity_details/view/entity_details_page.dart'; // Added import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/router/routes.dart'; import 'package:ht_main/shared/constants/app_spacing.dart'; @@ -95,6 +96,13 @@ class FollowedSourcesListPage extends StatelessWidget { margin: const EdgeInsets.only(bottom: AppSpacing.sm), child: ListTile( title: Text(source.name), + onTap: () { + // Added onTap for navigation + context.push( + Routes.sourceDetails, + extra: EntityDetailsPageArguments(entity: source), + ); + }, trailing: IconButton( icon: Icon( Icons.remove_circle_outline, diff --git a/lib/account/view/saved_headlines_page.dart b/lib/account/view/saved_headlines_page.dart index cfee5eba..66e2af2a 100644 --- a/lib/account/view/saved_headlines_page.dart +++ b/lib/account/view/saved_headlines_page.dart @@ -103,7 +103,6 @@ class SavedHeadlinesPage extends StatelessWidget { ), trailing: trailingButton, ); - break; case HeadlineImageStyle.smallThumbnail: tile = HeadlineTileImageStart( headline: headline, @@ -115,7 +114,6 @@ class SavedHeadlinesPage extends StatelessWidget { ), trailing: trailingButton, ); - break; case HeadlineImageStyle.largeThumbnail: tile = HeadlineTileImageTop( headline: headline, @@ -127,7 +125,6 @@ class SavedHeadlinesPage extends StatelessWidget { ), trailing: trailingButton, ); - break; } return tile; }, diff --git a/lib/entity_details/bloc/entity_details_bloc.dart b/lib/entity_details/bloc/entity_details_bloc.dart new file mode 100644 index 00000000..4880ab81 --- /dev/null +++ b/lib/entity_details/bloc/entity_details_bloc.dart @@ -0,0 +1,295 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:ht_main/account/bloc/account_bloc.dart'; // Corrected import +import 'package:ht_data_repository/ht_data_repository.dart'; +import 'package:ht_main/entity_details/models/entity_type.dart'; +import 'package:ht_shared/ht_shared.dart'; + +part 'entity_details_event.dart'; +part 'entity_details_state.dart'; + +class EntityDetailsBloc extends Bloc { + EntityDetailsBloc({ + required HtDataRepository headlinesRepository, + required HtDataRepository categoryRepository, + required HtDataRepository sourceRepository, + required AccountBloc accountBloc, // Changed to AccountBloc + }) : _headlinesRepository = headlinesRepository, + _categoryRepository = categoryRepository, + _sourceRepository = sourceRepository, + _accountBloc = accountBloc, + super(const EntityDetailsState()) { + on(_onEntityDetailsLoadRequested); + on( + _onEntityDetailsToggleFollowRequested, + ); + on( + _onEntityDetailsLoadMoreHeadlinesRequested, + ); + on<_EntityDetailsUserPreferencesChanged>( + _onEntityDetailsUserPreferencesChanged, + ); + + // Listen to AccountBloc for changes in user preferences + _accountBlocSubscription = _accountBloc.stream.listen((accountState) { + if (accountState.preferences != null) { + add(_EntityDetailsUserPreferencesChanged(accountState.preferences!)); + } + }); + } + + final HtDataRepository _headlinesRepository; + final HtDataRepository _categoryRepository; + final HtDataRepository _sourceRepository; + final AccountBloc _accountBloc; // Changed to AccountBloc + late final StreamSubscription _accountBlocSubscription; + + static const _headlinesLimit = 10; + + Future _onEntityDetailsLoadRequested( + EntityDetailsLoadRequested event, + Emitter emit, + ) async { + emit( + state.copyWith(status: EntityDetailsStatus.loading, clearEntity: true), + ); + + dynamic entityToLoad = event.entity; + var entityTypeToLoad = event.entityType; + + try { + // 1. Determine/Fetch Entity + if (entityToLoad == null && + event.entityId != null && + event.entityType != null) { + entityTypeToLoad = event.entityType; // Ensure type is set + if (event.entityType == EntityType.category) { + entityToLoad = await _categoryRepository.read(id: event.entityId!); + } else if (event.entityType == EntityType.source) { + entityToLoad = await _sourceRepository.read(id: event.entityId!); + } else { + throw Exception('Unknown entity type for ID fetch'); + } + } else if (entityToLoad != null) { + // If entity is directly provided, determine its type + if (entityToLoad is Category) { + entityTypeToLoad = EntityType.category; + } else if (entityToLoad is Source) { + entityTypeToLoad = EntityType.source; + } else { + throw Exception('Provided entity is of unknown type'); + } + } + + if (entityToLoad == null || entityTypeToLoad == null) { + emit( + state.copyWith( + status: EntityDetailsStatus.failure, + errorMessage: 'Entity could not be determined or loaded.', + ), + ); + return; + } + + // 2. Fetch Initial Headlines + final queryParams = {}; + if (entityTypeToLoad == EntityType.category) { + queryParams['categories'] = (entityToLoad as Category).id; + } else if (entityTypeToLoad == EntityType.source) { + queryParams['sources'] = (entityToLoad as Source).id; + } + + final headlinesResponse = await _headlinesRepository.readAllByQuery( + queryParams, + limit: _headlinesLimit, + ); + + // 3. Determine isFollowing status + var isCurrentlyFollowing = false; + final currentAccountState = _accountBloc.state; + if (currentAccountState.preferences != null) { + if (entityTypeToLoad == EntityType.category && + entityToLoad is Category) { + isCurrentlyFollowing = currentAccountState + .preferences! + .followedCategories + .any((cat) => cat.id == entityToLoad.id); + } else if (entityTypeToLoad == EntityType.source && + entityToLoad is Source) { + isCurrentlyFollowing = currentAccountState + .preferences! + .followedSources + .any((src) => src.id == entityToLoad.id); + } + } + + emit( + state.copyWith( + status: EntityDetailsStatus.success, + entityType: entityTypeToLoad, + entity: entityToLoad, + isFollowing: isCurrentlyFollowing, + headlines: headlinesResponse.items, + headlinesStatus: EntityHeadlinesStatus.success, + hasMoreHeadlines: headlinesResponse.hasMore, + headlinesCursor: headlinesResponse.cursor, + clearErrorMessage: true, + ), + ); + } on HtHttpException catch (e) { + emit( + state.copyWith( + status: EntityDetailsStatus.failure, + errorMessage: e.message, + entityType: entityTypeToLoad, // Keep type if known + ), + ); + } catch (e) { + emit( + state.copyWith( + status: EntityDetailsStatus.failure, + errorMessage: 'An unexpected error occurred: $e', + entityType: entityTypeToLoad, // Keep type if known + ), + ); + } + } + + Future _onEntityDetailsToggleFollowRequested( + EntityDetailsToggleFollowRequested event, + Emitter emit, + ) async { + if (state.entity == null || state.entityType == null) { + // Cannot toggle follow if no entity is loaded + emit( + state.copyWith( + errorMessage: 'No entity loaded to follow/unfollow.', + clearErrorMessage: false, // Keep existing error if any, or set new + ), + ); + return; + } + + // Optimistic update of UI can be handled by listening to AccountBloc state changes + // which will trigger _onEntityDetailsUserPreferencesChanged. + + if (state.entityType == EntityType.category && state.entity is Category) { + _accountBloc.add( + AccountFollowCategoryToggled(category: state.entity as Category), + ); + } else if (state.entityType == EntityType.source && + state.entity is Source) { + _accountBloc.add( + AccountFollowSourceToggled(source: state.entity as Source), + ); + } else { + // Should not happen if entity and entityType are consistent + emit( + state.copyWith( + errorMessage: 'Cannot determine entity type to follow/unfollow.', + clearErrorMessage: false, + ), + ); + } + // Note: We don't emit a new state here for `isFollowing` directly. + // The change will propagate from AccountBloc -> _accountBlocSubscription + // -> _EntityDetailsUserPreferencesChanged -> update state.isFollowing. + // This keeps AccountBloc as the source of truth for preferences. + } + + Future _onEntityDetailsLoadMoreHeadlinesRequested( + EntityDetailsLoadMoreHeadlinesRequested event, + Emitter emit, + ) async { + if (!state.hasMoreHeadlines || + state.headlinesStatus == EntityHeadlinesStatus.loadingMore) { + return; + } + if (state.entity == null || state.entityType == null) return; + + emit(state.copyWith(headlinesStatus: EntityHeadlinesStatus.loadingMore)); + + try { + final queryParams = {}; + if (state.entityType == EntityType.category) { + queryParams['categories'] = (state.entity as Category).id; + } else if (state.entityType == EntityType.source) { + queryParams['sources'] = (state.entity as Source).id; + } else { + // Should not happen + emit( + state.copyWith( + headlinesStatus: EntityHeadlinesStatus.failure, + errorMessage: 'Cannot load more headlines: Unknown entity type.', + ), + ); + return; + } + + final headlinesResponse = await _headlinesRepository.readAllByQuery( + queryParams, + limit: _headlinesLimit, + startAfterId: state.headlinesCursor, + ); + + emit( + state.copyWith( + headlines: List.of(state.headlines)..addAll(headlinesResponse.items), + headlinesStatus: EntityHeadlinesStatus.success, + hasMoreHeadlines: headlinesResponse.hasMore, + headlinesCursor: headlinesResponse.cursor, + clearHeadlinesCursor: !headlinesResponse.hasMore, // Clear if no more + ), + ); + } on HtHttpException catch (e) { + emit( + state.copyWith( + headlinesStatus: EntityHeadlinesStatus.failure, + errorMessage: e.message, + ), + ); + } catch (e) { + emit( + state.copyWith( + headlinesStatus: EntityHeadlinesStatus.failure, + errorMessage: 'An unexpected error occurred: $e', + ), + ); + } + } + + void _onEntityDetailsUserPreferencesChanged( + _EntityDetailsUserPreferencesChanged event, + Emitter emit, + ) { + if (state.entity == null || state.entityType == null) return; + + var isCurrentlyFollowing = false; + final preferences = event.preferences; + + if (state.entityType == EntityType.category && state.entity is Category) { + final currentCategory = state.entity as Category; + isCurrentlyFollowing = preferences.followedCategories.any( + (cat) => cat.id == currentCategory.id, + ); + } else if (state.entityType == EntityType.source && + state.entity is Source) { + final currentSource = state.entity as Source; + isCurrentlyFollowing = preferences.followedSources.any( + (src) => src.id == currentSource.id, + ); + } + + if (state.isFollowing != isCurrentlyFollowing) { + emit(state.copyWith(isFollowing: isCurrentlyFollowing)); + } + } + + @override + Future close() { + _accountBlocSubscription.cancel(); + return super.close(); + } +} diff --git a/lib/entity_details/bloc/entity_details_event.dart b/lib/entity_details/bloc/entity_details_event.dart new file mode 100644 index 00000000..1bdc0953 --- /dev/null +++ b/lib/entity_details/bloc/entity_details_event.dart @@ -0,0 +1,48 @@ +part of 'entity_details_bloc.dart'; + +abstract class EntityDetailsEvent extends Equatable { + const EntityDetailsEvent(); + + @override + List get props => []; +} + +/// Event to load entity details and initial headlines. +/// Can be triggered by passing an ID and type, or the full entity. +class EntityDetailsLoadRequested extends EntityDetailsEvent { + const EntityDetailsLoadRequested({ + this.entityId, + this.entityType, + this.entity, + }) : assert( + (entityId != null && entityType != null) || entity != null, + 'Either entityId/entityType or entity must be provided.', + ); + + final String? entityId; + final EntityType? entityType; + final dynamic entity; // Category or Source + + @override + List get props => [entityId, entityType, entity]; +} + +/// Event to toggle the follow status of the current entity. +class EntityDetailsToggleFollowRequested extends EntityDetailsEvent { + const EntityDetailsToggleFollowRequested(); +} + +/// Event to load more headlines for pagination. +class EntityDetailsLoadMoreHeadlinesRequested extends EntityDetailsEvent { + const EntityDetailsLoadMoreHeadlinesRequested(); +} + +/// Internal event to notify the BLoC that user preferences have changed. +class _EntityDetailsUserPreferencesChanged extends EntityDetailsEvent { + const _EntityDetailsUserPreferencesChanged(this.preferences); + + final UserContentPreferences preferences; + + @override + List get props => [preferences]; +} diff --git a/lib/entity_details/bloc/entity_details_state.dart b/lib/entity_details/bloc/entity_details_state.dart new file mode 100644 index 00000000..437e8750 --- /dev/null +++ b/lib/entity_details/bloc/entity_details_state.dart @@ -0,0 +1,73 @@ +part of 'entity_details_bloc.dart'; + +/// Status for the overall entity details page. +enum EntityDetailsStatus { initial, loading, success, failure } + +/// Status for fetching headlines within the entity details page. +enum EntityHeadlinesStatus { initial, loadingMore, success, failure } + +class EntityDetailsState extends Equatable { + const EntityDetailsState({ + this.status = EntityDetailsStatus.initial, + this.entityType, + this.entity, + this.isFollowing = false, + this.headlines = const [], + this.headlinesStatus = EntityHeadlinesStatus.initial, + this.hasMoreHeadlines = true, + this.headlinesCursor, + this.errorMessage, + }); + + final EntityDetailsStatus status; + final EntityType? entityType; + final dynamic entity; // Will be Category or Source + final bool isFollowing; + final List headlines; + final EntityHeadlinesStatus headlinesStatus; + final bool hasMoreHeadlines; + final String? headlinesCursor; + final String? errorMessage; + + EntityDetailsState copyWith({ + EntityDetailsStatus? status, + EntityType? entityType, + dynamic entity, + bool? isFollowing, + List? headlines, + EntityHeadlinesStatus? headlinesStatus, + bool? hasMoreHeadlines, + String? headlinesCursor, + String? errorMessage, + bool clearErrorMessage = false, + bool clearEntity = false, + bool clearHeadlinesCursor = false, + }) { + return EntityDetailsState( + status: status ?? this.status, + entityType: entityType ?? this.entityType, + entity: clearEntity ? null : entity ?? this.entity, + isFollowing: isFollowing ?? this.isFollowing, + headlines: headlines ?? this.headlines, + headlinesStatus: headlinesStatus ?? this.headlinesStatus, + hasMoreHeadlines: hasMoreHeadlines ?? this.hasMoreHeadlines, + headlinesCursor: + clearHeadlinesCursor ? null : headlinesCursor ?? this.headlinesCursor, + errorMessage: + clearErrorMessage ? null : errorMessage ?? this.errorMessage, + ); + } + + @override + List get props => [ + status, + entityType, + entity, + isFollowing, + headlines, + headlinesStatus, + hasMoreHeadlines, + headlinesCursor, + errorMessage, + ]; +} diff --git a/lib/entity_details/models/entity_type.dart b/lib/entity_details/models/entity_type.dart new file mode 100644 index 00000000..4358954b --- /dev/null +++ b/lib/entity_details/models/entity_type.dart @@ -0,0 +1,8 @@ +/// Defines the type of entity being displayed or interacted with. +enum EntityType { + /// Represents a news category. + category, + + /// Represents a news source. + source, +} diff --git a/lib/entity_details/view/entity_details_page.dart b/lib/entity_details/view/entity_details_page.dart new file mode 100644 index 00000000..5cdb4689 --- /dev/null +++ b/lib/entity_details/view/entity_details_page.dart @@ -0,0 +1,390 @@ +import 'package:flutter/foundation.dart' show kIsWeb; // Added +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; // Added +import 'package:ht_main/account/bloc/account_bloc.dart'; +import 'package:ht_main/app/bloc/app_bloc.dart'; // For accessing settings +import 'package:ht_main/entity_details/bloc/entity_details_bloc.dart'; +import 'package:ht_main/entity_details/models/entity_type.dart'; +import 'package:ht_main/l10n/l10n.dart'; +import 'package:ht_main/router/routes.dart'; // Added +import 'package:ht_main/shared/constants/app_spacing.dart'; +import 'package:ht_main/shared/widgets/widgets.dart'; +import 'package:ht_shared/ht_shared.dart'; +import 'package:ht_data_repository/ht_data_repository.dart'; // For repository provider + +class EntityDetailsPageArguments { + const EntityDetailsPageArguments({ + this.entityId, + this.entityType, + this.entity, + }) : assert( + (entityId != null && entityType != null) || entity != null, + 'Either entityId/entityType or entity must be provided.', + ); + + final String? entityId; + final EntityType? entityType; + final dynamic entity; // Category or Source +} + +class EntityDetailsPage extends StatelessWidget { + const EntityDetailsPage({required this.args, super.key}); + + final EntityDetailsPageArguments args; + + static Route route({required EntityDetailsPageArguments args}) { + return MaterialPageRoute( + builder: (_) => EntityDetailsPage(args: args), + ); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: + (context) => EntityDetailsBloc( + headlinesRepository: context.read>(), + categoryRepository: context.read>(), + sourceRepository: context.read>(), + accountBloc: context.read(), + )..add( + EntityDetailsLoadRequested( + entityId: args.entityId, + entityType: args.entityType, + entity: args.entity, + ), + ), + child: EntityDetailsView(args: args), // Pass args + ); + } +} + +class EntityDetailsView extends StatefulWidget { + const EntityDetailsView({required this.args, super.key}); // Accept args + + final EntityDetailsPageArguments args; // Store args + + @override + State createState() => _EntityDetailsViewState(); +} + +class _EntityDetailsViewState extends State { + final _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + } + + @override + void dispose() { + _scrollController + ..removeListener(_onScroll) + ..dispose(); + super.dispose(); + } + + void _onScroll() { + if (_isBottom) { + context.read().add( + const EntityDetailsLoadMoreHeadlinesRequested(), + ); + } + } + + bool get _isBottom { + if (!_scrollController.hasClients) return false; + final maxScroll = _scrollController.position.maxScrollExtent; + final currentScroll = _scrollController.offset; + // Trigger load a bit before reaching the absolute bottom + return currentScroll >= (maxScroll * 0.9); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final theme = Theme.of(context); + + return Scaffold( + body: BlocBuilder( + builder: (context, state) { + if (state.status == EntityDetailsStatus.initial || + (state.status == EntityDetailsStatus.loading && + state.entity == null)) { + return const LoadingStateWidget( + icon: Icons.info_outline, // Or a more specific icon + headline: 'Loading Details', // Replace with l10n + subheadline: 'Please wait...', // Replace with l10n + ); + } + + if (state.status == EntityDetailsStatus.failure && + state.entity == null) { + return FailureStateWidget( + message: state.errorMessage ?? 'Failed to load details.', // l10n + onRetry: + () => context.read().add( + EntityDetailsLoadRequested( + entityId: widget.args.entityId, + entityType: widget.args.entityType, + entity: widget.args.entity, + ), + ), + ); + } + + // At this point, state.entity should not be null if success or loading more + final appBarTitle = + state.entity is Category + ? (state.entity as Category).name + : state.entity is Source + ? (state.entity as Source).name + : l10n.detailsPageTitle; + + final description = + state.entity is Category + ? (state.entity as Category).description + : state.entity is Source + ? (state.entity as Source).description + : null; + + final entityIconUrl = + (state.entity is Category && + (state.entity as Category).iconUrl != null) + ? (state.entity as Category).iconUrl + : null; + + final followButton = IconButton( + icon: Icon( + state.isFollowing + ? Icons + .check_circle // Filled when following + : Icons.add_circle_outline, + color: + theme + .colorScheme + .primary, // Use primary for both states for accent + ), + tooltip: + state.isFollowing + ? l10n.unfollowButtonLabel + : l10n.followButtonLabel, + onPressed: () { + context.read().add( + const EntityDetailsToggleFollowRequested(), + ); + }, + ); + + final Widget appBarTitleWidget = Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (entityIconUrl != null) + Padding( + padding: const EdgeInsets.only(right: AppSpacing.sm), + child: ClipRRect( + borderRadius: BorderRadius.circular(AppSpacing.xs), + child: Image.network( + entityIconUrl, + width: kToolbarHeight - 16, // AppBar height minus padding + height: kToolbarHeight - 16, + fit: BoxFit.cover, + errorBuilder: + (context, error, stackTrace) => const Icon( + Icons.category_outlined, + size: kToolbarHeight - 20, + ), + ), + ), + ) + else if (state.entityType == EntityType.category) + Padding( + padding: const EdgeInsets.only(right: AppSpacing.sm), + child: Icon( + Icons.category_outlined, + size: kToolbarHeight - 20, + color: theme.colorScheme.onSurface, + ), + ) + else if (state.entityType == EntityType.source) + Padding( + padding: const EdgeInsets.only(right: AppSpacing.sm), + child: Icon( + Icons.source_outlined, + size: kToolbarHeight - 20, + color: theme.colorScheme.onSurface, + ), + ), + Flexible( + child: Text(appBarTitle, overflow: TextOverflow.ellipsis), + ), + // Info icon removed from here + ], + ); + + return CustomScrollView( + controller: _scrollController, + slivers: [ + SliverAppBar( + title: appBarTitleWidget, + pinned: true, + actions: [followButton], + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all( + AppSpacing.paddingMedium, + ), // Consistent padding + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (description != null && description.isNotEmpty) ...[ + Text( + description, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.lg), + ], + if (state.headlines.isNotEmpty || + state.headlinesStatus == + EntityHeadlinesStatus.loadingMore) ...[ + Text( + l10n.headlinesSectionTitle, + style: theme.textTheme.titleLarge, + ), + const Divider(height: AppSpacing.md), + ], + ], + ), + ), + ), + if (state.headlines.isEmpty && + state.headlinesStatus != EntityHeadlinesStatus.initial && + state.headlinesStatus != EntityHeadlinesStatus.loadingMore && + state.status == EntityDetailsStatus.success) + SliverFillRemaining( + // Use SliverFillRemaining for empty state + child: Center( + child: Text( + l10n.noHeadlinesFoundMessage, + style: theme.textTheme.titleMedium, + ), + ), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index >= state.headlines.length) { + return state.hasMoreHeadlines && + state.headlinesStatus == + EntityHeadlinesStatus.loadingMore + ? const Center( + child: Padding( + padding: EdgeInsets.all(AppSpacing.md), + child: CircularProgressIndicator(), + ), + ) + : const SizedBox.shrink(); + } + final headline = state.headlines[index]; + final imageStyle = + context + .watch() + .state + .settings + .feedPreferences + .headlineImageStyle; + + Widget tile; + switch (imageStyle) { + case HeadlineImageStyle.hidden: + tile = HeadlineTileTextOnly( + headline: headline, + onHeadlineTap: + () => context.pushNamed( + Routes + .globalArticleDetailsName, // Use new global route + pathParameters: {'id': headline.id}, + extra: headline, + ), + currentContextEntityType: state.entityType, + currentContextEntityId: + state.entity is Category + ? (state.entity as Category).id + : state.entity is Source + ? (state.entity as Source).id + : null, + ); + case HeadlineImageStyle.smallThumbnail: + tile = HeadlineTileImageStart( + headline: headline, + onHeadlineTap: + () => context.pushNamed( + Routes + .globalArticleDetailsName, // Use new global route + pathParameters: {'id': headline.id}, + extra: headline, + ), + currentContextEntityType: state.entityType, + currentContextEntityId: + state.entity is Category + ? (state.entity as Category).id + : state.entity is Source + ? (state.entity as Source).id + : null, + ); + case HeadlineImageStyle.largeThumbnail: + tile = HeadlineTileImageTop( + headline: headline, + onHeadlineTap: + () => context.pushNamed( + Routes + .globalArticleDetailsName, // Use new global route + pathParameters: {'id': headline.id}, + extra: headline, + ), + currentContextEntityType: state.entityType, + currentContextEntityId: + state.entity is Category + ? (state.entity as Category).id + : state.entity is Source + ? (state.entity as Source).id + : null, + ); + } + return tile; + }, + childCount: + state.headlines.length + + (state.hasMoreHeadlines && + state.headlinesStatus == + EntityHeadlinesStatus.loadingMore + ? 1 + : 0), + ), + ), + // Error display for headline loading specifically + if (state.headlinesStatus == EntityHeadlinesStatus.failure && + state.headlines.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Text( + state.errorMessage ?? l10n.failedToLoadMoreHeadlines, + style: TextStyle(color: theme.colorScheme.error), + textAlign: TextAlign.center, + ), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/headline-details/view/headline_details_page.dart b/lib/headline-details/view/headline_details_page.dart index 61e3793c..ef5b2826 100644 --- a/lib/headline-details/view/headline_details_page.dart +++ b/lib/headline-details/view/headline_details_page.dart @@ -7,6 +7,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; // Import GoRouter import 'package:ht_main/account/bloc/account_bloc.dart'; import 'package:ht_main/app/bloc/app_bloc.dart'; // Added AppBloc +import 'package:ht_main/entity_details/view/entity_details_page.dart'; // Added for Page Arguments import 'package:ht_main/headline-details/bloc/headline_details_bloc.dart'; import 'package:ht_main/headline-details/bloc/similar_headlines_bloc.dart'; // HeadlineItemWidget import removed @@ -14,7 +15,11 @@ import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/router/routes.dart'; import 'package:ht_main/shared/shared.dart'; import 'package:ht_shared/ht_shared.dart' - show Headline, HeadlineImageStyle; // Added HeadlineImageStyle + show + Category, + Headline, + HeadlineImageStyle, + Source; // Added Category, Source import 'package:intl/intl.dart'; import 'package:share_plus/share_plus.dart'; // Import share_plus import 'package:url_launcher/url_launcher_string.dart'; @@ -394,18 +399,27 @@ class _HeadlineDetailsPageState extends State { if (headline.source != null) { chips.add( - Chip( - avatar: Icon( - Icons.source, - size: chipAvatarSize, - color: chipAvatarColor, + GestureDetector( + // Added GestureDetector + onTap: () { + context.push( + Routes.sourceDetails, + extra: EntityDetailsPageArguments(entity: headline.source), + ); + }, + child: Chip( + avatar: Icon( + Icons.source, + size: chipAvatarSize, + color: chipAvatarColor, + ), + label: Text(headline.source!.name), + labelStyle: chipLabelStyle, + backgroundColor: chipBackgroundColor, + padding: chipPadding, + visualDensity: chipVisualDensity, + materialTapTargetSize: chipMaterialTapTargetSize, ), - label: Text(headline.source!.name), - labelStyle: chipLabelStyle, - backgroundColor: chipBackgroundColor, - padding: chipPadding, - visualDensity: chipVisualDensity, - materialTapTargetSize: chipMaterialTapTargetSize, ), ); } @@ -435,6 +449,7 @@ class _HeadlineDetailsPageState extends State { final country = headline.source!.headquarters!; chips.add( Chip( + // Country chip is usually not tappable to a details page in this context avatar: CircleAvatar( radius: chipAvatarSize / 2, backgroundColor: Colors.transparent, @@ -453,13 +468,22 @@ class _HeadlineDetailsPageState extends State { if (headline.category != null) { chips.add( - Chip( - label: Text(headline.category!.name), - labelStyle: chipLabelStyle, - backgroundColor: chipBackgroundColor, - padding: chipPadding, - visualDensity: chipVisualDensity, - materialTapTargetSize: chipMaterialTapTargetSize, + GestureDetector( + // Added GestureDetector + onTap: () { + context.push( + Routes.categoryDetails, + extra: EntityDetailsPageArguments(entity: headline.category), + ); + }, + child: Chip( + label: Text(headline.category!.name), + labelStyle: chipLabelStyle, + backgroundColor: chipBackgroundColor, + padding: chipPadding, + visualDensity: chipVisualDensity, + materialTapTargetSize: chipMaterialTapTargetSize, + ), ), ); } @@ -527,7 +551,6 @@ class _HeadlineDetailsPageState extends State { extra: similarHeadline, ), ); - break; case HeadlineImageStyle.smallThumbnail: tile = HeadlineTileImageStart( headline: similarHeadline, @@ -538,7 +561,6 @@ class _HeadlineDetailsPageState extends State { extra: similarHeadline, ), ); - break; case HeadlineImageStyle.largeThumbnail: tile = HeadlineTileImageTop( headline: similarHeadline, @@ -549,13 +571,12 @@ class _HeadlineDetailsPageState extends State { extra: similarHeadline, ), ); - break; } return tile; }, ), ); - }, childCount: loadedState.similarHeadlines.length), + }, childCount: loadedState.similarHeadlines.length,), ), _ => const SliverToBoxAdapter(child: SizedBox.shrink()), }; diff --git a/lib/headlines-feed/view/headlines_feed_page.dart b/lib/headlines-feed/view/headlines_feed_page.dart index f415cb73..ea74063c 100644 --- a/lib/headlines-feed/view/headlines_feed_page.dart +++ b/lib/headlines-feed/view/headlines_feed_page.dart @@ -240,7 +240,6 @@ class _HeadlinesFeedPageState extends State { extra: headline, ), ); - break; case HeadlineImageStyle.smallThumbnail: tile = HeadlineTileImageStart( headline: headline, @@ -251,7 +250,6 @@ class _HeadlinesFeedPageState extends State { extra: headline, ), ); - break; case HeadlineImageStyle.largeThumbnail: tile = HeadlineTileImageTop( headline: headline, @@ -262,7 +260,6 @@ class _HeadlinesFeedPageState extends State { extra: headline, ), ); - break; } return tile; }, diff --git a/lib/headlines-search/bloc/headlines_search_bloc.dart b/lib/headlines-search/bloc/headlines_search_bloc.dart index 8b74c557..14d6c39f 100644 --- a/lib/headlines-search/bloc/headlines_search_bloc.dart +++ b/lib/headlines-search/bloc/headlines_search_bloc.dart @@ -143,22 +143,22 @@ class HeadlinesSearchBloc response = await _headlinesRepository.readAllByQuery({ 'q': searchTerm, 'model': modelType.toJson(), - }, limit: _limit); + }, limit: _limit,); case SearchModelType.category: response = await _categoryRepository.readAllByQuery({ 'q': searchTerm, 'model': modelType.toJson(), - }, limit: _limit); + }, limit: _limit,); case SearchModelType.source: response = await _sourceRepository.readAllByQuery({ 'q': searchTerm, 'model': modelType.toJson(), - }, limit: _limit); + }, limit: _limit,); case SearchModelType.country: response = await _countryRepository.readAllByQuery({ 'q': searchTerm, 'model': modelType.toJson(), - }, limit: _limit); + }, limit: _limit,); } emit( HeadlinesSearchSuccess( diff --git a/lib/headlines-search/view/headlines_search_page.dart b/lib/headlines-search/view/headlines_search_page.dart index 7699b7e4..93a39d7f 100644 --- a/lib/headlines-search/view/headlines_search_page.dart +++ b/lib/headlines-search/view/headlines_search_page.dart @@ -295,7 +295,6 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { extra: headline, ), ); - break; case HeadlineImageStyle.smallThumbnail: tile = HeadlineTileImageStart( headline: headline, @@ -306,7 +305,6 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { extra: headline, ), ); - break; case HeadlineImageStyle.largeThumbnail: tile = HeadlineTileImageTop( headline: headline, @@ -317,7 +315,6 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { extra: headline, ), ); - break; } return tile; case SearchModelType.category: diff --git a/lib/headlines-search/widgets/category_item_widget.dart b/lib/headlines-search/widgets/category_item_widget.dart index 23b1b083..3d9b46d3 100644 --- a/lib/headlines-search/widgets/category_item_widget.dart +++ b/lib/headlines-search/widgets/category_item_widget.dart @@ -1,4 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; // Added +import 'package:ht_main/entity_details/view/entity_details_page.dart'; // Added +import 'package:ht_main/router/routes.dart'; // Added import 'package:ht_shared/ht_shared.dart'; // Import Category model /// A simple widget to display a Category search result. @@ -19,12 +22,10 @@ class CategoryItemWidget extends StatelessWidget { overflow: TextOverflow.ellipsis, ) : null, - // TODO(you): Implement onTap navigation if needed for categories onTap: () { - // Example: Navigate to a filtered feed for this category - // context.goNamed('someCategoryFeedRoute', params: {'id': category.id}); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Tapped on category: ${category.name}')), + context.push( + Routes.categoryDetails, + extra: EntityDetailsPageArguments(entity: category), ); }, ); diff --git a/lib/headlines-search/widgets/source_item_widget.dart b/lib/headlines-search/widgets/source_item_widget.dart index 35583293..9c67c15a 100644 --- a/lib/headlines-search/widgets/source_item_widget.dart +++ b/lib/headlines-search/widgets/source_item_widget.dart @@ -1,4 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; // Added +import 'package:ht_main/entity_details/view/entity_details_page.dart'; // Added +import 'package:ht_main/router/routes.dart'; // Added import 'package:ht_shared/ht_shared.dart'; // Import Source model /// A simple widget to display a Source search result. @@ -19,12 +22,10 @@ class SourceItemWidget extends StatelessWidget { overflow: TextOverflow.ellipsis, ) : null, - // TODO(you): Implement onTap navigation if needed for sources onTap: () { - // Example: Navigate to a page showing headlines from this source - // context.goNamed('someSourceFeedRoute', params: {'id': source.id}); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Tapped on source: ${source.name}')), + context.push( + Routes.sourceDetails, + extra: EntityDetailsPageArguments(entity: source), ); }, ); diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index bb2ca86a..e661c01a 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -807,5 +807,29 @@ "similarHeadlinesEmpty": "لم يتم العثور على عناوين مشابهة.", "@similarHeadlinesEmpty": { "description": "Message shown when no similar headlines are found" + }, + "detailsPageTitle": "[AR] Details", + "@detailsPageTitle": { + "description": "Title for the category/source details page" + }, + "followButtonLabel": "[AR] Follow", + "@followButtonLabel": { + "description": "Label for the follow button" + }, + "unfollowButtonLabel": "[AR] Unfollow", + "@unfollowButtonLabel": { + "description": "Label for the unfollow button" + }, + "noHeadlinesFoundMessage": "[AR] No headlines found for this item.", + "@noHeadlinesFoundMessage": { + "description": "Message displayed when no headlines are available for a category/source" + }, + "failedToLoadMoreHeadlines": "[AR] Failed to load more headlines.", + "@failedToLoadMoreHeadlines": { + "description": "Error message when loading more headlines fails on details page" + }, + "headlinesSectionTitle": "[AR] Headlines", + "@headlinesSectionTitle": { + "description": "Title for the headlines section on details page" } } diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 5625a1f9..6cc4a6f5 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -807,5 +807,29 @@ "similarHeadlinesEmpty": "No similar headlines found.", "@similarHeadlinesEmpty": { "description": "Message shown when no similar headlines are found" + }, + "detailsPageTitle": "Details", + "@detailsPageTitle": { + "description": "Title for the category/source details page" + }, + "followButtonLabel": "Follow", + "@followButtonLabel": { + "description": "Label for the follow button" + }, + "unfollowButtonLabel": "Unfollow", + "@unfollowButtonLabel": { + "description": "Label for the unfollow button" + }, + "noHeadlinesFoundMessage": "No headlines found for this item.", + "@noHeadlinesFoundMessage": { + "description": "Message displayed when no headlines are available for a category/source" + }, + "failedToLoadMoreHeadlines": "Failed to load more headlines.", + "@failedToLoadMoreHeadlines": { + "description": "Error message when loading more headlines fails on details page" + }, + "headlinesSectionTitle": "Headlines", + "@headlinesSectionTitle": { + "description": "Title for the headlines section on details page" } } diff --git a/lib/router/router.dart b/lib/router/router.dart index 928f6789..324f5eee 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -19,6 +19,7 @@ import 'package:ht_main/authentication/bloc/authentication_bloc.dart'; import 'package:ht_main/authentication/view/authentication_page.dart'; import 'package:ht_main/authentication/view/email_code_verification_page.dart'; import 'package:ht_main/authentication/view/request_code_page.dart'; +import 'package:ht_main/entity_details/view/entity_details_page.dart'; // Added import 'package:ht_main/headline-details/bloc/headline_details_bloc.dart'; // Re-added import 'package:ht_main/headline-details/bloc/similar_headlines_bloc.dart'; // Import SimilarHeadlinesBloc import 'package:ht_main/headline-details/view/headline_details_page.dart'; @@ -61,6 +62,12 @@ GoRouter createRouter({ htUserContentPreferencesRepository, required HtDataRepository htAppConfigRepository, }) { + // Instantiate AccountBloc once to be shared + final accountBloc = AccountBloc( + authenticationRepository: htAuthenticationRepository, + userContentPreferencesRepository: htUserContentPreferencesRepository, + ); + return GoRouter( refreshListenable: authStatusNotifier, initialLocation: Routes.feed, @@ -177,21 +184,36 @@ GoRouter createRouter({ ); return null; // Allow access } - // **Sub-Case 2.3: Navigating Within the Main App Sections (Feed, Search, Account)** - // Allow anonymous users to access the main content sections and their sub-routes. - else if (isGoingToFeed || isGoingToSearch || isGoingToAccount) { - // Added checks for search and account + // **Sub-Case 2.3: Navigating Within the Main App Sections (Feed, Search, Account) or Details Pages** + // Allow anonymous users to access the main content sections, their sub-routes, and details pages. + else if (isGoingToFeed || + isGoingToSearch || + isGoingToAccount || + currentLocation == Routes.categoryDetails || + currentLocation == Routes.sourceDetails || + currentLocation.startsWith( + Routes.globalArticleDetails.split('/:id').first, + ) || // Allow global article details + currentLocation.startsWith( + '${Routes.feed}/${Routes.articleDetailsName.split('/:id').first}', + ) || + currentLocation.startsWith( + '${Routes.search}/${Routes.searchArticleDetailsName.split('/:id').first}', + ) || + currentLocation.startsWith( + '${Routes.account}/${Routes.accountSavedHeadlines}/${Routes.accountArticleDetailsName.split('/:id').first}', + )) { print( - ' Action: Allowing navigation within main app section ($currentLocation).', // Updated log message + ' Action: Allowing navigation to main app section or details page ($currentLocation).', ); return null; // Allow access } - // **Sub-Case 2.4: Fallback for Unexpected Paths** // Now correctly handles only truly unexpected paths + // **Sub-Case 2.4: Fallback for Unexpected Paths** // If an anonymous user tries to navigate anywhere else unexpected, // redirect them to the main content feed as a safe default. else { print( - ' Action: Unexpected path ($currentLocation), redirecting to $feedPath', // Updated path constant + ' Action: Unexpected path ($currentLocation), redirecting to $feedPath', ); return feedPath; // Redirect to feed } @@ -291,12 +313,112 @@ GoRouter createRouter({ ), ], ), + // --- Entity Details Routes (Top Level) --- + GoRoute( + path: Routes.categoryDetails, + name: Routes.categoryDetailsName, + builder: (context, state) { + final args = state.extra as EntityDetailsPageArguments?; + if (args == null) { + return const Scaffold( + body: Center( + child: Text('Error: Missing category details arguments'), + ), + ); + } + return BlocProvider.value( + value: accountBloc, + child: EntityDetailsPage(args: args), + ); + }, + ), + GoRoute( + path: Routes.sourceDetails, + name: Routes.sourceDetailsName, + builder: (context, state) { + final args = state.extra as EntityDetailsPageArguments?; + if (args == null) { + return const Scaffold( + body: Center( + child: Text('Error: Missing source details arguments'), + ), + ); + } + return BlocProvider.value( + value: accountBloc, + child: EntityDetailsPage(args: args), + ); + }, + ), + // --- Global Article Details Route (Top Level) --- + // This GoRoute provides a top-level, globally accessible way to view the + // HeadlineDetailsPage. + // + // Purpose: + // It is specifically designed for navigating to article details from contexts + // that are *outside* the main StatefulShellRoute's branches (e.g., from + // EntityDetailsPage, which is itself a top-level route, or potentially + // from other future top-level pages or deep links). + // + // Why it's necessary: + // Attempting to push a route that is deeply nested within a specific shell + // branch (like '/feed/article/:id') from a BuildContext outside of that + // shell can lead to navigator context issues and assertion failures. + // This global route avoids such problems by providing a clean, direct path + // to the HeadlineDetailsPage. + // + // How it differs: + // This route is distinct from the article detail routes nested within the + // StatefulShellRoute branches (e.g., Routes.articleDetailsName under /feed, + // Routes.searchArticleDetailsName under /search). Those nested routes are + // intended for navigation *within* their respective shell branches, + // preserving the shell's UI (like the bottom navigation bar). + // This global route, being top-level, will typically cover the entire screen. + GoRoute( + path: Routes.globalArticleDetails, // Use new path: '/article/:id' + name: Routes.globalArticleDetailsName, // Use new name + builder: (context, state) { + final headlineFromExtra = state.extra as Headline?; + final headlineIdFromPath = state.pathParameters['id']; + + // Ensure accountBloc is available if needed by HeadlineDetailsPage + // or its descendants for actions like saving. + // If AccountBloc is already provided higher up (e.g., in AppShell or App), + // this specific BlocProvider.value might not be strictly necessary here, + // but it's safer to ensure it's available for this top-level route. + // We are using the `accountBloc` instance created at the top of `createRouter`. + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: accountBloc), + BlocProvider( + create: + (context) => HeadlineDetailsBloc( + headlinesRepository: + context.read>(), + ), + ), + BlocProvider( + create: + (context) => SimilarHeadlinesBloc( + headlinesRepository: + context.read>(), + ), + ), + ], + child: HeadlineDetailsPage( + initialHeadline: headlineFromExtra, + headlineId: headlineFromExtra?.id ?? headlineIdFromPath, + ), + ); + }, + ), // --- Main App Shell --- StatefulShellRoute.indexedStack( builder: (context, state, navigationShell) { // Return the shell widget which contains the AdaptiveScaffold return MultiBlocProvider( providers: [ + BlocProvider.value(value: accountBloc), // Use the shared instance BlocProvider( create: (context) => HeadlinesFeedBloc( @@ -317,16 +439,7 @@ GoRouter createRouter({ context.read>(), ), ), - BlocProvider( - create: - (context) => AccountBloc( - authenticationRepository: - context.read(), - userContentPreferencesRepository: - context - .read>(), - ), - ), + // Removed separate AccountBloc creation here ], child: AppShell(navigationShell: navigationShell), ); @@ -350,6 +463,7 @@ GoRouter createRouter({ return MultiBlocProvider( providers: [ + BlocProvider.value(value: accountBloc), // Added BlocProvider( create: (context) => HeadlineDetailsBloc( @@ -492,6 +606,7 @@ GoRouter createRouter({ final headlineIdFromPath = state.pathParameters['id']; return MultiBlocProvider( providers: [ + BlocProvider.value(value: accountBloc), // Added BlocProvider( create: (context) => HeadlineDetailsBloc( @@ -706,6 +821,7 @@ GoRouter createRouter({ final headlineIdFromPath = state.pathParameters['id']; return MultiBlocProvider( providers: [ + BlocProvider.value(value: accountBloc), // Added BlocProvider( create: (context) => HeadlineDetailsBloc( diff --git a/lib/router/routes.dart b/lib/router/routes.dart index b71b98ac..c667b1d7 100644 --- a/lib/router/routes.dart +++ b/lib/router/routes.dart @@ -34,6 +34,12 @@ abstract final class Routes { static const notifications = 'notifications'; // Relative path static const notificationsName = 'notifications'; + // --- Entity Details Routes (can be accessed from multiple places) --- + static const categoryDetails = '/category-details'; // New + static const categoryDetailsName = 'categoryDetails'; // New + static const sourceDetails = '/source-details'; // New + static const sourceDetailsName = 'sourceDetails'; // New + // --- Authentication Routes --- static const authentication = '/authentication'; static const authenticationName = 'authentication'; @@ -90,6 +96,12 @@ abstract final class Routes { 'article/:id'; // Relative to accountSavedHeadlines static const String accountArticleDetailsName = 'accountArticleDetails'; + // --- Global Article Details --- + // This route is intended for accessing article details from contexts + // outside the main bottom navigation shell (e.g., from entity detail pages). + static const globalArticleDetails = '/article/:id'; // Top-level path + static const globalArticleDetailsName = 'globalArticleDetails'; + // --- Manage Followed Items Sub-Routes (relative to /account/manage-followed-items) --- static const followedCategoriesList = 'categories'; static const followedCategoriesListName = 'followedCategoriesList'; diff --git a/lib/shared/localization/ar_timeago_messages.dart b/lib/shared/localization/ar_timeago_messages.dart index c81091b5..617b01fa 100644 --- a/lib/shared/localization/ar_timeago_messages.dart +++ b/lib/shared/localization/ar_timeago_messages.dart @@ -16,27 +16,27 @@ class ArTimeagoMessages implements timeago.LookupMessages { @override String aboutAMinute(int minutes) => 'منذ 1د'; @override - String minutes(int minutes) => 'منذ ${minutes}د'; + String minutes(int minutes) => 'منذ $minutesد'; @override String aboutAnHour(int minutes) => 'منذ 1س'; @override - String hours(int hours) => 'منذ ${hours}س'; + String hours(int hours) => 'منذ $hoursس'; @override String aDay(int hours) => 'منذ 1ي'; // Or 'أمس' if preferred for exactly 1 day @override - String days(int days) => 'منذ ${days}ي'; + String days(int days) => 'منذ $daysي'; @override String aboutAMonth(int days) => 'منذ 1ش'; @override - String months(int months) => 'منذ ${months}ش'; + String months(int months) => 'منذ $monthsش'; @override String aboutAYear(int year) => 'منذ 1سنة'; // Using سنة for year @override - String years(int years) => 'منذ ${years}سنوات'; // Standard plural + String years(int years) => 'منذ $yearsسنوات'; // Standard plural @override String wordSeparator() => ' '; diff --git a/lib/shared/shared.dart b/lib/shared/shared.dart index 5bbcfd55..d0ae1fee 100644 --- a/lib/shared/shared.dart +++ b/lib/shared/shared.dart @@ -6,5 +6,5 @@ library; export 'constants/constants.dart'; export 'theme/theme.dart'; -export 'widgets/widgets.dart'; export 'utils/utils.dart'; // Added export for utils +export 'widgets/widgets.dart'; diff --git a/lib/shared/widgets/headline_tile_image_start.dart b/lib/shared/widgets/headline_tile_image_start.dart index 50e09e11..79d6c686 100644 --- a/lib/shared/widgets/headline_tile_image_start.dart +++ b/lib/shared/widgets/headline_tile_image_start.dart @@ -1,5 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; // Added +import 'package:ht_main/entity_details/models/entity_type.dart'; +import 'package:ht_main/entity_details/view/entity_details_page.dart'; // Added for Page Arguments import 'package:ht_main/l10n/l10n.dart'; +import 'package:ht_main/router/routes.dart'; // Added import 'package:ht_main/shared/constants/app_spacing.dart'; import 'package:ht_main/shared/utils/utils.dart'; // Import the new utility import 'package:ht_shared/ht_shared.dart' show Headline; @@ -15,6 +19,8 @@ class HeadlineTileImageStart extends StatelessWidget { super.key, this.onHeadlineTap, this.trailing, + this.currentContextEntityType, + this.currentContextEntityId, }); /// The headline data to display. @@ -26,6 +32,12 @@ class HeadlineTileImageStart extends StatelessWidget { /// An optional widget to display at the end of the tile. final Widget? trailing; + /// The type of the entity currently being viewed in detail (e.g., on a category page). + final EntityType? currentContextEntityType; + + /// The ID of the entity currently being viewed in detail. + final String? currentContextEntityId; + @override Widget build(BuildContext context) { final l10n = context.l10n; @@ -57,7 +69,7 @@ class HeadlineTileImageStart extends StatelessWidget { fit: BoxFit.cover, loadingBuilder: (context, child, loadingProgress) { if (loadingProgress == null) return child; - return Container( + return ColoredBox( color: colorScheme.surfaceContainerHighest, child: const Center( child: CircularProgressIndicator( @@ -67,7 +79,7 @@ class HeadlineTileImageStart extends StatelessWidget { ); }, errorBuilder: - (context, error, stackTrace) => Container( + (context, error, stackTrace) => ColoredBox( color: colorScheme.surfaceContainerHighest, child: Icon( Icons.broken_image_outlined, @@ -76,7 +88,7 @@ class HeadlineTileImageStart extends StatelessWidget { ), ), ) - : Container( + : ColoredBox( color: colorScheme.surfaceContainerHighest, child: Icon( Icons.image_not_supported_outlined, @@ -105,6 +117,10 @@ class HeadlineTileImageStart extends StatelessWidget { l10n: l10n, colorScheme: colorScheme, textTheme: textTheme, + currentContextEntityType: + currentContextEntityType, // Pass down + currentContextEntityId: + currentContextEntityId, // Pass down ), ], ), @@ -128,12 +144,16 @@ class _HeadlineMetadataRow extends StatelessWidget { required this.l10n, required this.colorScheme, required this.textTheme, + this.currentContextEntityType, + this.currentContextEntityId, }); final Headline headline; final AppLocalizations l10n; final ColorScheme colorScheme; final TextTheme textTheme; + final EntityType? currentContextEntityType; + final String? currentContextEntityId; @override Widget build(BuildContext context) { @@ -177,7 +197,10 @@ class _HeadlineMetadataRow extends StatelessWidget { ], ), ), - if (headline.category?.name != null) ...[ + // Conditionally render Category Chip + if (headline.category?.name != null && + !(currentContextEntityType == EntityType.category && + headline.category!.id == currentContextEntityId)) ...[ if (formattedDate.isNotEmpty) Padding( padding: const EdgeInsets.symmetric( @@ -187,15 +210,12 @@ class _HeadlineMetadataRow extends StatelessWidget { ), GestureDetector( onTap: () { - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar( - content: Text( - 'Tapped Category: ${headline.category!.name}', - ), - ), + if (headline.category != null) { + context.push( + Routes.categoryDetails, + extra: EntityDetailsPageArguments(entity: headline.category), ); + } }, child: Chip( label: Text(headline.category!.name), @@ -210,8 +230,14 @@ class _HeadlineMetadataRow extends StatelessWidget { ), ), ], - if (headline.source?.name != null) ...[ - if (formattedDate.isNotEmpty || headline.category?.name != null) + // Conditionally render Source Chip + if (headline.source?.name != null && + !(currentContextEntityType == EntityType.source && + headline.source!.id == currentContextEntityId)) ...[ + if (formattedDate.isNotEmpty || + (headline.category?.name != null && + !(currentContextEntityType == EntityType.category && + headline.category!.id == currentContextEntityId))) Padding( padding: const EdgeInsets.symmetric( horizontal: AppSpacing.xs / 2, @@ -220,13 +246,12 @@ class _HeadlineMetadataRow extends StatelessWidget { ), GestureDetector( onTap: () { - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar( - content: Text('Tapped Source: ${headline.source!.name}'), - ), + if (headline.source != null) { + context.push( + Routes.sourceDetails, + extra: EntityDetailsPageArguments(entity: headline.source), ); + } }, child: Chip( label: Text(headline.source!.name), diff --git a/lib/shared/widgets/headline_tile_image_top.dart b/lib/shared/widgets/headline_tile_image_top.dart index 3ee6560c..fbd65933 100644 --- a/lib/shared/widgets/headline_tile_image_top.dart +++ b/lib/shared/widgets/headline_tile_image_top.dart @@ -1,5 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; // Added +import 'package:ht_main/entity_details/models/entity_type.dart'; +import 'package:ht_main/entity_details/view/entity_details_page.dart'; // Added for Page Arguments import 'package:ht_main/l10n/l10n.dart'; +import 'package:ht_main/router/routes.dart'; // Added import 'package:ht_main/shared/constants/app_spacing.dart'; import 'package:ht_main/shared/utils/utils.dart'; // Import the new utility import 'package:ht_shared/ht_shared.dart' show Headline; @@ -15,6 +19,8 @@ class HeadlineTileImageTop extends StatelessWidget { super.key, this.onHeadlineTap, this.trailing, + this.currentContextEntityType, + this.currentContextEntityId, }); /// The headline data to display. @@ -26,6 +32,12 @@ class HeadlineTileImageTop extends StatelessWidget { /// An optional widget to display at the end of the tile (e.g., in line with title). final Widget? trailing; + /// The type of the entity currently being viewed in detail (e.g., on a category page). + final EntityType? currentContextEntityType; + + /// The ID of the entity currently being viewed in detail. + final String? currentContextEntityId; + @override Widget build(BuildContext context) { final l10n = context.l10n; @@ -123,6 +135,9 @@ class HeadlineTileImageTop extends StatelessWidget { l10n: l10n, colorScheme: colorScheme, textTheme: textTheme, + currentContextEntityType: + currentContextEntityType, // Pass down + currentContextEntityId: currentContextEntityId, // Pass down ), ], ), @@ -140,12 +155,16 @@ class _HeadlineMetadataRow extends StatelessWidget { required this.l10n, required this.colorScheme, required this.textTheme, + this.currentContextEntityType, + this.currentContextEntityId, }); final Headline headline; final AppLocalizations l10n; final ColorScheme colorScheme; final TextTheme textTheme; + final EntityType? currentContextEntityType; + final String? currentContextEntityId; @override Widget build(BuildContext context) { @@ -189,7 +208,10 @@ class _HeadlineMetadataRow extends StatelessWidget { ], ), ), - if (headline.category?.name != null) ...[ + // Conditionally render Category Chip + if (headline.category?.name != null && + !(currentContextEntityType == EntityType.category && + headline.category!.id == currentContextEntityId)) ...[ if (formattedDate.isNotEmpty) Padding( padding: const EdgeInsets.symmetric( @@ -199,15 +221,12 @@ class _HeadlineMetadataRow extends StatelessWidget { ), GestureDetector( onTap: () { - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar( - content: Text( - 'Tapped Category: ${headline.category!.name}', - ), - ), + if (headline.category != null) { + context.push( + Routes.categoryDetails, + extra: EntityDetailsPageArguments(entity: headline.category), ); + } }, child: Chip( label: Text(headline.category!.name), @@ -222,8 +241,14 @@ class _HeadlineMetadataRow extends StatelessWidget { ), ), ], - if (headline.source?.name != null) ...[ - if (formattedDate.isNotEmpty || headline.category?.name != null) + // Conditionally render Source Chip + if (headline.source?.name != null && + !(currentContextEntityType == EntityType.source && + headline.source!.id == currentContextEntityId)) ...[ + if (formattedDate.isNotEmpty || + (headline.category?.name != null && + !(currentContextEntityType == EntityType.category && + headline.category!.id == currentContextEntityId))) Padding( padding: const EdgeInsets.symmetric( horizontal: AppSpacing.xs / 2, @@ -232,13 +257,12 @@ class _HeadlineMetadataRow extends StatelessWidget { ), GestureDetector( onTap: () { - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar( - content: Text('Tapped Source: ${headline.source!.name}'), - ), + if (headline.source != null) { + context.push( + Routes.sourceDetails, + extra: EntityDetailsPageArguments(entity: headline.source), ); + } }, child: Chip( label: Text(headline.source!.name), diff --git a/lib/shared/widgets/headline_tile_text_only.dart b/lib/shared/widgets/headline_tile_text_only.dart index deda774f..450f1369 100644 --- a/lib/shared/widgets/headline_tile_text_only.dart +++ b/lib/shared/widgets/headline_tile_text_only.dart @@ -1,5 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; // Added +import 'package:ht_main/entity_details/models/entity_type.dart'; +import 'package:ht_main/entity_details/view/entity_details_page.dart'; // Added for Page Arguments import 'package:ht_main/l10n/l10n.dart'; +import 'package:ht_main/router/routes.dart'; // Added import 'package:ht_main/shared/constants/app_spacing.dart'; import 'package:ht_main/shared/utils/utils.dart'; // Import the new utility import 'package:ht_shared/ht_shared.dart' show Headline; @@ -17,6 +21,8 @@ class HeadlineTileTextOnly extends StatelessWidget { super.key, this.onHeadlineTap, this.trailing, + this.currentContextEntityType, + this.currentContextEntityId, }); /// The headline data to display. @@ -28,6 +34,12 @@ class HeadlineTileTextOnly extends StatelessWidget { /// An optional widget to display at the end of the tile. final Widget? trailing; + /// The type of the entity currently being viewed in detail (e.g., on a category page). + final EntityType? currentContextEntityType; + + /// The ID of the entity currently being viewed in detail. + final String? currentContextEntityId; + @override Widget build(BuildContext context) { final l10n = context.l10n; @@ -65,6 +77,10 @@ class HeadlineTileTextOnly extends StatelessWidget { l10n: l10n, colorScheme: colorScheme, textTheme: textTheme, + currentContextEntityType: + currentContextEntityType, // Pass down + currentContextEntityId: + currentContextEntityId, // Pass down ), ], ), @@ -88,12 +104,16 @@ class _HeadlineMetadataRow extends StatelessWidget { required this.l10n, required this.colorScheme, required this.textTheme, + this.currentContextEntityType, + this.currentContextEntityId, }); final Headline headline; final AppLocalizations l10n; final ColorScheme colorScheme; final TextTheme textTheme; + final EntityType? currentContextEntityType; + final String? currentContextEntityId; @override Widget build(BuildContext context) { @@ -137,7 +157,10 @@ class _HeadlineMetadataRow extends StatelessWidget { ], ), ), - if (headline.category?.name != null) ...[ + // Conditionally render Category Chip + if (headline.category?.name != null && + !(currentContextEntityType == EntityType.category && + headline.category!.id == currentContextEntityId)) ...[ if (formattedDate.isNotEmpty) Padding( padding: const EdgeInsets.symmetric( @@ -147,15 +170,12 @@ class _HeadlineMetadataRow extends StatelessWidget { ), GestureDetector( onTap: () { - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar( - content: Text( - 'Tapped Category: ${headline.category!.name}', - ), - ), + if (headline.category != null) { + context.push( + Routes.categoryDetails, + extra: EntityDetailsPageArguments(entity: headline.category), ); + } }, child: Chip( label: Text(headline.category!.name), @@ -170,8 +190,14 @@ class _HeadlineMetadataRow extends StatelessWidget { ), ), ], - if (headline.source?.name != null) ...[ - if (formattedDate.isNotEmpty || headline.category?.name != null) + // Conditionally render Source Chip + if (headline.source?.name != null && + !(currentContextEntityType == EntityType.source && + headline.source!.id == currentContextEntityId)) ...[ + if (formattedDate.isNotEmpty || + (headline.category?.name != null && + !(currentContextEntityType == EntityType.category && + headline.category!.id == currentContextEntityId))) Padding( padding: const EdgeInsets.symmetric( horizontal: AppSpacing.xs / 2, @@ -180,13 +206,12 @@ class _HeadlineMetadataRow extends StatelessWidget { ), GestureDetector( onTap: () { - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar( - content: Text('Tapped Source: ${headline.source!.name}'), - ), + if (headline.source != null) { + context.push( + Routes.sourceDetails, + extra: EntityDetailsPageArguments(entity: headline.source), ); + } }, child: Chip( label: Text(headline.source!.name), diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart index 2548c8c7..d75f81b2 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -3,8 +3,8 @@ library; export 'failure_state_widget.dart'; -export 'initial_state_widget.dart'; -export 'loading_state_widget.dart'; -export 'headline_tile_text_only.dart'; export 'headline_tile_image_start.dart'; export 'headline_tile_image_top.dart'; +export 'headline_tile_text_only.dart'; +export 'initial_state_widget.dart'; +export 'loading_state_widget.dart';