From 0147a648fcaae3c43d9ad0876a719b1d4dc9f435 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 31 May 2025 16:52:20 +0100 Subject: [PATCH 01/23] feat: Navigate to category/source details - Added category/source navigation - Passed context entity info - Conditional chip rendering --- .../widgets/headline_tile_text_only.dart | 59 +++++++++++++------ 1 file changed, 42 insertions(+), 17 deletions(-) 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), From 735ad253e7e51e3fdeb2ca5f5af3167847a209d4 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 31 May 2025 16:52:30 +0100 Subject: [PATCH 02/23] feat(tile): navigate to source/category details - Added navigation on chip tap - Avoid render current entity chip --- .../widgets/headline_tile_image_start.dart | 112 +++++++++------- .../widgets/headline_tile_image_top.dart | 120 +++++++++++------- 2 files changed, 139 insertions(+), 93 deletions(-) diff --git a/lib/shared/widgets/headline_tile_image_start.dart b/lib/shared/widgets/headline_tile_image_start.dart index 50e09e11..9a090ffd 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; @@ -50,40 +62,39 @@ class HeadlineTileImageStart extends StatelessWidget { height: 72, child: ClipRRect( borderRadius: BorderRadius.circular(AppSpacing.xs), - child: - headline.imageUrl != null - ? Image.network( - headline.imageUrl!, - fit: BoxFit.cover, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; - return Container( - color: colorScheme.surfaceContainerHighest, - child: const Center( - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ), - ); - }, - errorBuilder: - (context, error, stackTrace) => Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.broken_image_outlined, - color: colorScheme.onSurfaceVariant, - size: AppSpacing.xl, - ), + child: headline.imageUrl != null + ? Image.network( + headline.imageUrl!, + fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Container( + color: colorScheme.surfaceContainerHighest, + child: const Center( + child: CircularProgressIndicator( + strokeWidth: 2, ), - ) - : Container( + ), + ); + }, + errorBuilder: (context, error, stackTrace) => + Container( color: colorScheme.surfaceContainerHighest, child: Icon( - Icons.image_not_supported_outlined, + Icons.broken_image_outlined, color: colorScheme.onSurfaceVariant, size: AppSpacing.xl, ), ), + ) + : Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.image_not_supported_outlined, + color: colorScheme.onSurfaceVariant, + size: AppSpacing.xl, + ), + ), ), ), const SizedBox(width: AppSpacing.md), // Always add spacing @@ -105,6 +116,10 @@ class HeadlineTileImageStart extends StatelessWidget { l10n: l10n, colorScheme: colorScheme, textTheme: textTheme, + currentContextEntityType: + currentContextEntityType, // Pass down + currentContextEntityId: + currentContextEntityId, // Pass down ), ], ), @@ -128,12 +143,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 +196,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 +209,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 +229,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 +245,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..66981306 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; @@ -48,46 +60,44 @@ class HeadlineTileImageTop extends StatelessWidget { topLeft: Radius.circular(AppSpacing.xs), topRight: Radius.circular(AppSpacing.xs), ), - child: - headline.imageUrl != null - ? Image.network( - headline.imageUrl!, - width: double.infinity, - height: 180, // Standard large image height - fit: BoxFit.cover, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; - return Container( - width: double.infinity, - height: 180, - color: colorScheme.surfaceContainerHighest, - child: const Center( - child: CircularProgressIndicator(strokeWidth: 2), - ), - ); - }, - errorBuilder: - (context, error, stackTrace) => Container( - width: double.infinity, - height: 180, - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.broken_image_outlined, - color: colorScheme.onSurfaceVariant, - size: AppSpacing.xxl, - ), - ), - ) - : Container( + child: headline.imageUrl != null + ? Image.network( + headline.imageUrl!, + width: double.infinity, + height: 180, // Standard large image height + fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Container( + width: double.infinity, + height: 180, + color: colorScheme.surfaceContainerHighest, + child: const Center( + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + }, + errorBuilder: (context, error, stackTrace) => Container( width: double.infinity, height: 180, color: colorScheme.surfaceContainerHighest, child: Icon( - Icons.image_not_supported_outlined, + Icons.broken_image_outlined, color: colorScheme.onSurfaceVariant, size: AppSpacing.xxl, ), ), + ) + : Container( + width: double.infinity, + height: 180, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.image_not_supported_outlined, + color: colorScheme.onSurfaceVariant, + size: AppSpacing.xxl, + ), + ), ), ), Padding( @@ -123,6 +133,9 @@ class HeadlineTileImageTop extends StatelessWidget { l10n: l10n, colorScheme: colorScheme, textTheme: textTheme, + currentContextEntityType: + currentContextEntityType, // Pass down + currentContextEntityId: currentContextEntityId, // Pass down ), ], ), @@ -140,12 +153,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 +206,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 +219,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 +239,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 +255,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), From 0d25b7003389e02e8c42f5f3cc58b89fccb999ac Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 31 May 2025 16:53:09 +0100 Subject: [PATCH 03/23] feat: add entity details routes - Added category details route - Added source details route --- lib/router/router.dart | 29 +++++++++++++++++++++++++++++ lib/router/routes.dart | 6 ++++++ 2 files changed, 35 insertions(+) diff --git a/lib/router/router.dart b/lib/router/router.dart index 928f6789..ef2c5d93 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'; @@ -291,6 +292,34 @@ 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) { + // Handle missing arguments, perhaps redirect or show error + return const Scaffold( + body: Center(child: Text('Error: Missing category details arguments')), + ); + } + return 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 EntityDetailsPage(args: args); + }, + ), // --- Main App Shell --- StatefulShellRoute.indexedStack( builder: (context, state, navigationShell) { diff --git a/lib/router/routes.dart b/lib/router/routes.dart index b71b98ac..a2703b68 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'; From 59b965cdb402d0763b40fb23522c80c43fb4b9f8 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 31 May 2025 16:53:18 +0100 Subject: [PATCH 04/23] feat: add details page localization - Added details page title - Added follow/unfollow labels - Added no headlines message - Added load more error message - Added headlines section title --- lib/l10n/arb/app_ar.arb | 24 ++++++++++++++++++++++++ lib/l10n/arb/app_en.arb | 24 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+) 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" } } From 67a94708e5d395a18c456e86b853841d7d196340 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 31 May 2025 16:54:09 +0100 Subject: [PATCH 05/23] feat(search): navigate to source details page - Navigate to entity details - Pass source as entity argument --- lib/headlines-search/widgets/source_item_widget.dart | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) 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), ); }, ); From 0441379eeeae4aa146975f084ae7af65fbb02e68 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 31 May 2025 16:54:26 +0100 Subject: [PATCH 06/23] feat(search): navigate to category details - Implemented onTap navigation - Added go_router dependency - Passed category data to route --- .../widgets/category_item_widget.dart | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) 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), ); }, ); From 62e058329e4844d7cbc86791de082dfd72064511 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 31 May 2025 16:54:48 +0100 Subject: [PATCH 07/23] feat: Implement entity details page - Displays category/source details - Loads and displays headlines - Implements follow/unfollow logic --- .../view/entity_details_page.dart | 330 ++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 lib/entity_details/view/entity_details_page.dart 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..0a44b523 --- /dev/null +++ b/lib/entity_details/view/entity_details_page.dart @@ -0,0 +1,330 @@ +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({super.key, required this.args}); + + 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({super.key, required this.args}); // 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 String appBarTitle = state.entity is Category + ? (state.entity as Category).name + : state.entity is Source + ? (state.entity as Source).name + : l10n.detailsPageTitle; + + final String? description = state.entity is Category + ? (state.entity as Category).description + : state.entity is Source + ? (state.entity as Source).description + : null; + + final String? entityIconUrl = (state.entity is Category && (state.entity as Category).iconUrl != null) + ? (state.entity as Category).iconUrl + : null; // Source model does not have iconUrl + + return CustomScrollView( + controller: _scrollController, + slivers: [ + SliverAppBar( + title: Text(appBarTitle), + pinned: true, + expandedHeight: entityIconUrl != null ? 200.0 : kToolbarHeight, + flexibleSpace: entityIconUrl != null + ? FlexibleSpaceBar( + background: Image.network( + entityIconUrl, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + const Icon(Icons.image_not_supported_outlined, size: 48), + ), + ) + : null, + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.paddingMedium), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + appBarTitle, + style: theme.textTheme.headlineMedium, + ), + ), + const SizedBox(width: AppSpacing.md), + ElevatedButton.icon( + icon: Icon( + state.isFollowing + ? Icons.check_circle // Filled when following + : Icons.add_circle_outline, + ), + label: Text( + state.isFollowing + ? l10n.unfollowButtonLabel + : l10n.followButtonLabel, + ), + style: ElevatedButton.styleFrom( + backgroundColor: state.isFollowing + ? theme.colorScheme.secondaryContainer + : theme.colorScheme.primaryContainer, + foregroundColor: state.isFollowing + ? theme.colorScheme.onSecondaryContainer + : theme.colorScheme.onPrimaryContainer, + ), + onPressed: () { + context + .read() + .add(const EntityDetailsToggleFollowRequested()); + }, + ), + ], + ), + if (description != null && description.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.md), + Text(description, style: theme.textTheme.bodyMedium), + ], + const SizedBox(height: AppSpacing.lg), // Increased spacing + if (state.headlines.isNotEmpty || state.headlinesStatus == EntityHeadlinesStatus.loadingMore) + Text(l10n.headlinesSectionTitle, style: theme.textTheme.titleLarge), // Section title + if (state.headlines.isNotEmpty || state.headlinesStatus == EntityHeadlinesStatus.loadingMore) + 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.articleDetailsName, // Use named 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, + ); + break; + case HeadlineImageStyle.smallThumbnail: + tile = HeadlineTileImageStart( + headline: headline, + onHeadlineTap: () => context.pushNamed( + Routes.articleDetailsName, // Use named 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, + ); + break; + case HeadlineImageStyle.largeThumbnail: + tile = HeadlineTileImageTop( + headline: headline, + onHeadlineTap: () => context.pushNamed( + Routes.articleDetailsName, // Use named 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, + ); + break; + } + 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, + ), + ), + ), + ], + ); + }, + ), + ); + } +} From e9b5d012e547487641c56e140882b0ac15d1b72a Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 31 May 2025 16:54:59 +0100 Subject: [PATCH 08/23] feat: add entity type enum - Define entity types - Add category enum - Add source enum --- lib/entity_details/models/entity_type.dart | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 lib/entity_details/models/entity_type.dart 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, +} From f53ed5dc8a01b7ffefef75035f7a004a004d1803 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 31 May 2025 16:55:25 +0100 Subject: [PATCH 09/23] feat(entity_details): create entity details state - Define states and statuses - Add copyWith method - Implement Equatable --- .../bloc/entity_details_state.dart | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 lib/entity_details/bloc/entity_details_state.dart 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..acbca116 --- /dev/null +++ b/lib/entity_details/bloc/entity_details_state.dart @@ -0,0 +1,84 @@ +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, + ]; +} From c670fd0d29c720202cc23194b53a2222e071d0c7 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 31 May 2025 16:55:45 +0100 Subject: [PATCH 10/23] feat(entity_details): add events for bloc - Add load requested event - Add toggle follow event - Add load more headlines event --- .../bloc/entity_details_event.dart | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 lib/entity_details/bloc/entity_details_event.dart 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..3ab54c2f --- /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]; +} From 946db0f63771d503601fba55508123b642cb784d Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 31 May 2025 16:55:57 +0100 Subject: [PATCH 11/23] feat: Implement EntityDetailsBloc - Handles entity details loading - Implements follow/unfollow logic - Loads headlines for entity - Listens to account preferences --- .../bloc/entity_details_bloc.dart | 286 ++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 lib/entity_details/bloc/entity_details_bloc.dart 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..37137097 --- /dev/null +++ b/lib/entity_details/bloc/entity_details_bloc.dart @@ -0,0 +1,286 @@ +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; + EntityType? 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 + bool 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; + + bool 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(); + } +} From b1979f52bcef73013bd21bdfaa753184edfffdc9 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 31 May 2025 16:56:10 +0100 Subject: [PATCH 12/23] feat(account): navigate to source details - Added onTap to navigate to details --- .../sources/followed_sources_list_page.dart | 7 +++++++ 1 file changed, 7 insertions(+) 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..add52a97 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,12 @@ 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, From 8ec05da52b955c2b777e15ba7c3d17969f5e9fc2 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 31 May 2025 16:56:15 +0100 Subject: [PATCH 13/23] feat(account): navigate to category details - Added onTap to list item - Navigates to category details page --- .../categories/followed_categories_list_page.dart | 7 +++++++ 1 file changed, 7 insertions(+) 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..7df81286 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,12 @@ 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, From f106c4fb638d286b526fe23cf58d9e998c37a753 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 31 May 2025 17:19:55 +0100 Subject: [PATCH 14/23] refactor(router): share AccountBloc instance - Instantiate AccountBloc once - Pass to details pages - Remove duplicate provider - Simplify auth logic --- lib/router/router.dart | 49 +++++++++++++++++++++++++----------------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/lib/router/router.dart b/lib/router/router.dart index ef2c5d93..f9321c6b 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -62,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, @@ -178,21 +184,27 @@ 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.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 } @@ -299,12 +311,14 @@ GoRouter createRouter({ builder: (context, state) { final args = state.extra as EntityDetailsPageArguments?; if (args == null) { - // Handle missing arguments, perhaps redirect or show error return const Scaffold( body: Center(child: Text('Error: Missing category details arguments')), ); } - return EntityDetailsPage(args: args); + return BlocProvider.value( + value: accountBloc, + child: EntityDetailsPage(args: args), + ); }, ), GoRoute( @@ -317,7 +331,10 @@ GoRouter createRouter({ body: Center(child: Text('Error: Missing source details arguments')), ); } - return EntityDetailsPage(args: args); + return BlocProvider.value( + value: accountBloc, + child: EntityDetailsPage(args: args), + ); }, ), // --- Main App Shell --- @@ -326,6 +343,7 @@ GoRouter createRouter({ // Return the shell widget which contains the AdaptiveScaffold return MultiBlocProvider( providers: [ + BlocProvider.value(value: accountBloc), // Use the shared instance BlocProvider( create: (context) => HeadlinesFeedBloc( @@ -346,16 +364,7 @@ GoRouter createRouter({ context.read>(), ), ), - BlocProvider( - create: - (context) => AccountBloc( - authenticationRepository: - context.read(), - userContentPreferencesRepository: - context - .read>(), - ), - ), + // Removed separate AccountBloc creation here ], child: AppShell(navigationShell: navigationShell), ); From df67eda666d21fc746f775e297f9852221873062 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 31 May 2025 17:30:05 +0100 Subject: [PATCH 15/23] feat(entity_details): revamp entity details page - Improved UI for better readability - Added follow button to app bar - Display description in dialog on mobile --- .../view/entity_details_page.dart | 162 +++++++++++------- 1 file changed, 99 insertions(+), 63 deletions(-) diff --git a/lib/entity_details/view/entity_details_page.dart b/lib/entity_details/view/entity_details_page.dart index 0a44b523..b3824189 100644 --- a/lib/entity_details/view/entity_details_page.dart +++ b/lib/entity_details/view/entity_details_page.dart @@ -1,3 +1,4 @@ +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 @@ -145,81 +146,116 @@ class _EntityDetailsViewState extends State { ? (state.entity as Source).description : null; - final String? entityIconUrl = (state.entity is Category && (state.entity as Category).iconUrl != null) + final String? entityIconUrl = (state.entity is Category && + (state.entity as Category).iconUrl != null) ? (state.entity as Category).iconUrl - : null; // Source model does not have iconUrl + : null; + + final followButton = IconButton( + icon: Icon( + state.isFollowing + ? Icons.check_circle // Filled when following + : Icons.add_circle_outline, + color: state.isFollowing + ? theme.colorScheme.primary + : theme.colorScheme.onSurfaceVariant, + ), + 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, + ), + ), + if (description != null && description.isNotEmpty) + Tooltip( + message: description, + child: IconButton( + icon: Icon(Icons.info_outline, color: theme.colorScheme.onSurfaceVariant), + onPressed: () { + // On mobile, show dialog for description + if (!kIsWeb) { // kIsWeb can be used to differentiate behavior + showDialog( + context: context, + builder: (BuildContext dialogContext) { + return AlertDialog( + title: Text(appBarTitle), + content: SingleChildScrollView(child: Text(description)), + actions: [ + TextButton( + child: Text(MaterialLocalizations.of(dialogContext).closeButtonLabel), + onPressed: () { + Navigator.of(dialogContext).pop(); + }, + ), + ], + ); + }, + ); + } + }, + ), + ), + ], + ); return CustomScrollView( controller: _scrollController, slivers: [ SliverAppBar( - title: Text(appBarTitle), + title: appBarTitleWidget, pinned: true, - expandedHeight: entityIconUrl != null ? 200.0 : kToolbarHeight, - flexibleSpace: entityIconUrl != null - ? FlexibleSpaceBar( - background: Image.network( - entityIconUrl, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => - const Icon(Icons.image_not_supported_outlined, size: 48), - ), - ) - : null, + actions: [followButton], + // Removed expandedHeight and flexibleSpace for a standard AppBar ), - SliverToBoxAdapter( + SliverToBoxAdapter( // This adapter is now just for spacing and the section title/divider child: Padding( - padding: const EdgeInsets.all(AppSpacing.paddingMedium), + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.paddingMedium) + .copyWith(top: AppSpacing.lg), // Add top padding child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Text( - appBarTitle, - style: theme.textTheme.headlineMedium, - ), - ), - const SizedBox(width: AppSpacing.md), - ElevatedButton.icon( - icon: Icon( - state.isFollowing - ? Icons.check_circle // Filled when following - : Icons.add_circle_outline, - ), - label: Text( - state.isFollowing - ? l10n.unfollowButtonLabel - : l10n.followButtonLabel, - ), - style: ElevatedButton.styleFrom( - backgroundColor: state.isFollowing - ? theme.colorScheme.secondaryContainer - : theme.colorScheme.primaryContainer, - foregroundColor: state.isFollowing - ? theme.colorScheme.onSecondaryContainer - : theme.colorScheme.onPrimaryContainer, - ), - onPressed: () { - context - .read() - .add(const EntityDetailsToggleFollowRequested()); - }, - ), - ], - ), - if (description != null && description.isNotEmpty) ...[ - const SizedBox(height: AppSpacing.md), - Text(description, style: theme.textTheme.bodyMedium), - ], - const SizedBox(height: AppSpacing.lg), // Increased spacing - if (state.headlines.isNotEmpty || state.headlinesStatus == EntityHeadlinesStatus.loadingMore) - Text(l10n.headlinesSectionTitle, style: theme.textTheme.titleLarge), // Section title - if (state.headlines.isNotEmpty || state.headlinesStatus == EntityHeadlinesStatus.loadingMore) + if (state.headlines.isNotEmpty || state.headlinesStatus == EntityHeadlinesStatus.loadingMore) ...[ + Text(l10n.headlinesSectionTitle, style: theme.textTheme.titleLarge), const Divider(height: AppSpacing.md), + ] ], ), ), From e73571b44829089a2a90f3d1d8b759297c11af24 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 31 May 2025 17:30:17 +0100 Subject: [PATCH 16/23] feat(headline): link source/category to details - Added Category, Source to shared - Added tap to source/category chips - Navigates to details page --- .../view/headline_details_page.dart | 57 ++++++++++++------- 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/lib/headline-details/view/headline_details_page.dart b/lib/headline-details/view/headline_details_page.dart index 61e3793c..5e45a64b 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,7 @@ 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 Headline, HeadlineImageStyle, Category, 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 +395,26 @@ 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, ), ); } @@ -434,7 +443,7 @@ class _HeadlineDetailsPageState extends State { if (headline.source?.headquarters != null) { final country = headline.source!.headquarters!; chips.add( - Chip( + Chip( // Country chip is usually not tappable to a details page in this context avatar: CircleAvatar( radius: chipAvatarSize / 2, backgroundColor: Colors.transparent, @@ -453,13 +462,21 @@ 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, + ), ), ); } From 77a9a10ea0d782a845e152535ef2de8f7a390283 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 31 May 2025 17:36:50 +0100 Subject: [PATCH 17/23] feat(router): inject accountBloc into details pages - Provides account data to details pages - Uses existing accountBloc instance --- lib/router/router.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/router/router.dart b/lib/router/router.dart index f9321c6b..f08803e6 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -388,6 +388,7 @@ GoRouter createRouter({ return MultiBlocProvider( providers: [ + BlocProvider.value(value: accountBloc), // Added BlocProvider( create: (context) => HeadlineDetailsBloc( @@ -530,6 +531,7 @@ GoRouter createRouter({ final headlineIdFromPath = state.pathParameters['id']; return MultiBlocProvider( providers: [ + BlocProvider.value(value: accountBloc), // Added BlocProvider( create: (context) => HeadlineDetailsBloc( @@ -744,6 +746,7 @@ GoRouter createRouter({ final headlineIdFromPath = state.pathParameters['id']; return MultiBlocProvider( providers: [ + BlocProvider.value(value: accountBloc), // Added BlocProvider( create: (context) => HeadlineDetailsBloc( From 1c864c3cdf37ec7786da152b8fb76a134b93457f Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 31 May 2025 17:36:58 +0100 Subject: [PATCH 18/23] refactor(details): Simplify UI elements - Removed info icon - Adjusted follow button color - Moved description to main content --- .../view/entity_details_page.dart | 56 ++++++------------- 1 file changed, 17 insertions(+), 39 deletions(-) diff --git a/lib/entity_details/view/entity_details_page.dart b/lib/entity_details/view/entity_details_page.dart index b3824189..89383739 100644 --- a/lib/entity_details/view/entity_details_page.dart +++ b/lib/entity_details/view/entity_details_page.dart @@ -156,9 +156,7 @@ class _EntityDetailsViewState extends State { state.isFollowing ? Icons.check_circle // Filled when following : Icons.add_circle_outline, - color: state.isFollowing - ? theme.colorScheme.primary - : theme.colorScheme.onSurfaceVariant, + color: theme.colorScheme.primary, // Use primary for both states for accent ), tooltip: state.isFollowing ? l10n.unfollowButtonLabel @@ -204,35 +202,7 @@ class _EntityDetailsViewState extends State { overflow: TextOverflow.ellipsis, ), ), - if (description != null && description.isNotEmpty) - Tooltip( - message: description, - child: IconButton( - icon: Icon(Icons.info_outline, color: theme.colorScheme.onSurfaceVariant), - onPressed: () { - // On mobile, show dialog for description - if (!kIsWeb) { // kIsWeb can be used to differentiate behavior - showDialog( - context: context, - builder: (BuildContext dialogContext) { - return AlertDialog( - title: Text(appBarTitle), - content: SingleChildScrollView(child: Text(description)), - actions: [ - TextButton( - child: Text(MaterialLocalizations.of(dialogContext).closeButtonLabel), - onPressed: () { - Navigator.of(dialogContext).pop(); - }, - ), - ], - ); - }, - ); - } - }, - ), - ), + // Info icon removed from here ], ); @@ -243,19 +213,27 @@ class _EntityDetailsViewState extends State { title: appBarTitleWidget, pinned: true, actions: [followButton], - // Removed expandedHeight and flexibleSpace for a standard AppBar ), - SliverToBoxAdapter( // This adapter is now just for spacing and the section title/divider + SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.paddingMedium) - .copyWith(top: AppSpacing.lg), // Add top padding + padding: const EdgeInsets.all(AppSpacing.paddingMedium), // Consistent padding child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (state.headlines.isNotEmpty || state.headlinesStatus == EntityHeadlinesStatus.loadingMore) ...[ + 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), - ] + ] ], ), ), From 87bc809404675a98c73ff1e1322cb5aa183c6454 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 31 May 2025 18:02:50 +0100 Subject: [PATCH 19/23] feat: add global article details route --- lib/router/routes.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/router/routes.dart b/lib/router/routes.dart index a2703b68..c667b1d7 100644 --- a/lib/router/routes.dart +++ b/lib/router/routes.dart @@ -96,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'; From 859c255ca5c7c5fa2c2aa4ce7abee75fd57afdb2 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 31 May 2025 18:03:45 +0100 Subject: [PATCH 20/23] feat(router): Add global article details route - Allow access from outside shell - Avoids navigator context issues - Provides a direct path to details --- lib/router/router.dart | 61 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/lib/router/router.dart b/lib/router/router.dart index f08803e6..54711287 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -191,6 +191,7 @@ GoRouter createRouter({ 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}')) { @@ -337,6 +338,66 @@ GoRouter createRouter({ ); }, ), + // --- 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) { From 3da1508bbab5a279f0336d5d839f18024fa4f6dd Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 31 May 2025 18:03:50 +0100 Subject: [PATCH 21/23] refactor(entity_details): use global article route - Updated route to global article details --- lib/entity_details/view/entity_details_page.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/entity_details/view/entity_details_page.dart b/lib/entity_details/view/entity_details_page.dart index 89383739..f1183a4e 100644 --- a/lib/entity_details/view/entity_details_page.dart +++ b/lib/entity_details/view/entity_details_page.dart @@ -273,7 +273,7 @@ class _EntityDetailsViewState extends State { tile = HeadlineTileTextOnly( headline: headline, onHeadlineTap: () => context.pushNamed( - Routes.articleDetailsName, // Use named route + Routes.globalArticleDetailsName, // Use new global route pathParameters: {'id': headline.id}, extra: headline, ), @@ -289,7 +289,7 @@ class _EntityDetailsViewState extends State { tile = HeadlineTileImageStart( headline: headline, onHeadlineTap: () => context.pushNamed( - Routes.articleDetailsName, // Use named route + Routes.globalArticleDetailsName, // Use new global route pathParameters: {'id': headline.id}, extra: headline, ), @@ -305,7 +305,7 @@ class _EntityDetailsViewState extends State { tile = HeadlineTileImageTop( headline: headline, onHeadlineTap: () => context.pushNamed( - Routes.articleDetailsName, // Use named route + Routes.globalArticleDetailsName, // Use new global route pathParameters: {'id': headline.id}, extra: headline, ), From 7864a1967764ffea3bec68a313555850d099efd7 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 31 May 2025 18:05:05 +0100 Subject: [PATCH 22/23] style: misc --- .../followed_categories_list_page.dart | 3 +- .../sources/followed_sources_list_page.dart | 3 +- .../bloc/entity_details_bloc.dart | 45 ++-- .../bloc/entity_details_event.dart | 6 +- .../bloc/entity_details_state.dart | 39 +-- .../view/entity_details_page.dart | 243 +++++++++++------- .../view/headline_details_page.dart | 15 +- lib/router/router.dart | 42 ++- .../widgets/headline_tile_image_start.dart | 53 ++-- .../widgets/headline_tile_image_top.dart | 62 ++--- 10 files changed, 292 insertions(+), 219 deletions(-) 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 7df81286..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 @@ -112,7 +112,8 @@ class FollowedCategoriesListPage extends StatelessWidget { ) : const Icon(Icons.category_outlined), title: Text(category.name), - onTap: () { // Added onTap for navigation + onTap: () { + // Added onTap for navigation context.push( Routes.categoryDetails, extra: EntityDetailsPageArguments(entity: category), 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 add52a97..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 @@ -96,7 +96,8 @@ class FollowedSourcesListPage extends StatelessWidget { margin: const EdgeInsets.only(bottom: AppSpacing.sm), child: ListTile( title: Text(source.name), - onTap: () { // Added onTap for navigation + onTap: () { + // Added onTap for navigation context.push( Routes.sourceDetails, extra: EntityDetailsPageArguments(entity: source), diff --git a/lib/entity_details/bloc/entity_details_bloc.dart b/lib/entity_details/bloc/entity_details_bloc.dart index 37137097..77a596f5 100644 --- a/lib/entity_details/bloc/entity_details_bloc.dart +++ b/lib/entity_details/bloc/entity_details_bloc.dart @@ -16,13 +16,15 @@ class EntityDetailsBloc extends Bloc { required HtDataRepository categoryRepository, required HtDataRepository sourceRepository, required AccountBloc accountBloc, // Changed to AccountBloc - }) : _headlinesRepository = headlinesRepository, - _categoryRepository = categoryRepository, - _sourceRepository = sourceRepository, - _accountBloc = accountBloc, - super(const EntityDetailsState()) { + }) : _headlinesRepository = headlinesRepository, + _categoryRepository = categoryRepository, + _sourceRepository = sourceRepository, + _accountBloc = accountBloc, + super(const EntityDetailsState()) { on(_onEntityDetailsLoadRequested); - on(_onEntityDetailsToggleFollowRequested); + on( + _onEntityDetailsToggleFollowRequested, + ); on( _onEntityDetailsLoadMoreHeadlinesRequested, ); @@ -50,7 +52,9 @@ class EntityDetailsBloc extends Bloc { EntityDetailsLoadRequested event, Emitter emit, ) async { - emit(state.copyWith(status: EntityDetailsStatus.loading, clearEntity: true)); + emit( + state.copyWith(status: EntityDetailsStatus.loading, clearEntity: true), + ); dynamic entityToLoad = event.entity; EntityType? entityTypeToLoad = event.entityType; @@ -62,8 +66,7 @@ class EntityDetailsBloc extends Bloc { event.entityType != null) { entityTypeToLoad = event.entityType; // Ensure type is set if (event.entityType == EntityType.category) { - entityToLoad = - await _categoryRepository.read(id: event.entityId!); + entityToLoad = await _categoryRepository.read(id: event.entityId!); } else if (event.entityType == EntityType.source) { entityToLoad = await _sourceRepository.read(id: event.entityId!); } else { @@ -109,11 +112,15 @@ class EntityDetailsBloc extends Bloc { if (currentAccountState.preferences != null) { if (entityTypeToLoad == EntityType.category && entityToLoad is Category) { - isCurrentlyFollowing = currentAccountState.preferences!.followedCategories + isCurrentlyFollowing = currentAccountState + .preferences! + .followedCategories .any((cat) => cat.id == entityToLoad.id); } else if (entityTypeToLoad == EntityType.source && - entityToLoad is Source) { - isCurrentlyFollowing = currentAccountState.preferences!.followedSources + entityToLoad is Source) { + isCurrentlyFollowing = currentAccountState + .preferences! + .followedSources .any((src) => src.id == entityToLoad.id); } } @@ -173,7 +180,7 @@ class EntityDetailsBloc extends Bloc { AccountFollowCategoryToggled(category: state.entity as Category), ); } else if (state.entityType == EntityType.source && - state.entity is Source) { + state.entity is Source) { _accountBloc.add( AccountFollowSourceToggled(source: state.entity as Source), ); @@ -264,13 +271,15 @@ class EntityDetailsBloc extends Bloc { if (state.entityType == EntityType.category && state.entity is Category) { final currentCategory = state.entity as Category; - isCurrentlyFollowing = preferences.followedCategories - .any((cat) => cat.id == currentCategory.id); + isCurrentlyFollowing = preferences.followedCategories.any( + (cat) => cat.id == currentCategory.id, + ); } else if (state.entityType == EntityType.source && - state.entity is Source) { + state.entity is Source) { final currentSource = state.entity as Source; - isCurrentlyFollowing = preferences.followedSources - .any((src) => src.id == currentSource.id); + isCurrentlyFollowing = preferences.followedSources.any( + (src) => src.id == currentSource.id, + ); } if (state.isFollowing != isCurrentlyFollowing) { diff --git a/lib/entity_details/bloc/entity_details_event.dart b/lib/entity_details/bloc/entity_details_event.dart index 3ab54c2f..1bdc0953 100644 --- a/lib/entity_details/bloc/entity_details_event.dart +++ b/lib/entity_details/bloc/entity_details_event.dart @@ -15,9 +15,9 @@ class EntityDetailsLoadRequested extends EntityDetailsEvent { this.entityType, this.entity, }) : assert( - (entityId != null && entityType != null) || entity != null, - 'Either entityId/entityType or entity must be provided.', - ); + (entityId != null && entityType != null) || entity != null, + 'Either entityId/entityType or entity must be provided.', + ); final String? entityId; final EntityType? entityType; diff --git a/lib/entity_details/bloc/entity_details_state.dart b/lib/entity_details/bloc/entity_details_state.dart index acbca116..437e8750 100644 --- a/lib/entity_details/bloc/entity_details_state.dart +++ b/lib/entity_details/bloc/entity_details_state.dart @@ -1,20 +1,10 @@ part of 'entity_details_bloc.dart'; /// Status for the overall entity details page. -enum EntityDetailsStatus { - initial, - loading, - success, - failure, -} +enum EntityDetailsStatus { initial, loading, success, failure } /// Status for fetching headlines within the entity details page. -enum EntityHeadlinesStatus { - initial, - loadingMore, - success, - failure, -} +enum EntityHeadlinesStatus { initial, loadingMore, success, failure } class EntityDetailsState extends Equatable { const EntityDetailsState({ @@ -61,9 +51,8 @@ class EntityDetailsState extends Equatable { headlines: headlines ?? this.headlines, headlinesStatus: headlinesStatus ?? this.headlinesStatus, hasMoreHeadlines: hasMoreHeadlines ?? this.hasMoreHeadlines, - headlinesCursor: clearHeadlinesCursor - ? null - : headlinesCursor ?? this.headlinesCursor, + headlinesCursor: + clearHeadlinesCursor ? null : headlinesCursor ?? this.headlinesCursor, errorMessage: clearErrorMessage ? null : errorMessage ?? this.errorMessage, ); @@ -71,14 +60,14 @@ class EntityDetailsState extends Equatable { @override List get props => [ - status, - entityType, - entity, - isFollowing, - headlines, - headlinesStatus, - hasMoreHeadlines, - headlinesCursor, - errorMessage, - ]; + status, + entityType, + entity, + isFollowing, + headlines, + headlinesStatus, + hasMoreHeadlines, + headlinesCursor, + errorMessage, + ]; } diff --git a/lib/entity_details/view/entity_details_page.dart b/lib/entity_details/view/entity_details_page.dart index f1183a4e..910114ff 100644 --- a/lib/entity_details/view/entity_details_page.dart +++ b/lib/entity_details/view/entity_details_page.dart @@ -19,9 +19,9 @@ class EntityDetailsPageArguments { this.entityType, this.entity, }) : assert( - (entityId != null && entityType != null) || entity != null, - 'Either entityId/entityType or entity must be provided.', - ); + (entityId != null && entityType != null) || entity != null, + 'Either entityId/entityType or entity must be provided.', + ); final String? entityId; final EntityType? entityType; @@ -42,18 +42,19 @@ class EntityDetailsPage extends StatelessWidget { @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, + 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 ); } @@ -87,9 +88,9 @@ class _EntityDetailsViewState extends State { void _onScroll() { if (_isBottom) { - context - .read() - .add(const EntityDetailsLoadMoreHeadlinesRequested()); + context.read().add( + const EntityDetailsLoadMoreHeadlinesRequested(), + ); } } @@ -123,7 +124,8 @@ class _EntityDetailsViewState extends State { state.entity == null) { return FailureStateWidget( message: state.errorMessage ?? 'Failed to load details.', // l10n - onRetry: () => context.read().add( + onRetry: + () => context.read().add( EntityDetailsLoadRequested( entityId: widget.args.entityId, entityType: widget.args.entityType, @@ -134,37 +136,45 @@ class _EntityDetailsViewState extends State { } // At this point, state.entity should not be null if success or loading more - final String appBarTitle = state.entity is Category - ? (state.entity as Category).name - : state.entity is Source + final String appBarTitle = + state.entity is Category + ? (state.entity as Category).name + : state.entity is Source ? (state.entity as Source).name - : l10n.detailsPageTitle; + : l10n.detailsPageTitle; - final String? description = state.entity is Category - ? (state.entity as Category).description - : state.entity is Source + final String? description = + state.entity is Category + ? (state.entity as Category).description + : state.entity is Source ? (state.entity as Source).description : null; - - final String? entityIconUrl = (state.entity is Category && - (state.entity as Category).iconUrl != null) - ? (state.entity as Category).iconUrl - : null; + + final String? 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 + .check_circle // Filled when following : Icons.add_circle_outline, - color: theme.colorScheme.primary, // Use primary for both states for accent + color: + theme + .colorScheme + .primary, // Use primary for both states for accent ), - tooltip: state.isFollowing - ? l10n.unfollowButtonLabel - : l10n.followButtonLabel, + tooltip: + state.isFollowing + ? l10n.unfollowButtonLabel + : l10n.followButtonLabel, onPressed: () { - context - .read() - .add(const EntityDetailsToggleFollowRequested()); + context.read().add( + const EntityDetailsToggleFollowRequested(), + ); }, ); @@ -181,26 +191,34 @@ class _EntityDetailsViewState extends State { 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), + errorBuilder: + (context, error, stackTrace) => const Icon( + Icons.category_outlined, + size: kToolbarHeight - 20, + ), ), ), ) else if (state.entityType == EntityType.category) - Padding( + Padding( padding: const EdgeInsets.only(right: AppSpacing.sm), - child: Icon(Icons.category_outlined, size: kToolbarHeight - 20, color: theme.colorScheme.onSurface), + child: Icon( + Icons.category_outlined, + size: kToolbarHeight - 20, + color: theme.colorScheme.onSurface, + ), ) else if (state.entityType == EntityType.source) - Padding( + Padding( padding: const EdgeInsets.only(right: AppSpacing.sm), - child: Icon(Icons.source_outlined, size: kToolbarHeight - 20, color: theme.colorScheme.onSurface), + child: Icon( + Icons.source_outlined, + size: kToolbarHeight - 20, + color: theme.colorScheme.onSurface, + ), ), Flexible( - child: Text( - appBarTitle, - overflow: TextOverflow.ellipsis, - ), + child: Text(appBarTitle, overflow: TextOverflow.ellipsis), ), // Info icon removed from here ], @@ -216,7 +234,9 @@ class _EntityDetailsViewState extends State { ), SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.all(AppSpacing.paddingMedium), // Consistent padding + padding: const EdgeInsets.all( + AppSpacing.paddingMedium, + ), // Consistent padding child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -230,10 +250,14 @@ class _EntityDetailsViewState extends State { const SizedBox(height: AppSpacing.lg), ], if (state.headlines.isNotEmpty || - state.headlinesStatus == EntityHeadlinesStatus.loadingMore) ...[ - Text(l10n.headlinesSectionTitle, style: theme.textTheme.titleLarge), + state.headlinesStatus == + EntityHeadlinesStatus.loadingMore) ...[ + Text( + l10n.headlinesSectionTitle, + style: theme.textTheme.titleLarge, + ), const Divider(height: AppSpacing.md), - ] + ], ], ), ), @@ -242,7 +266,8 @@ class _EntityDetailsViewState extends State { state.headlinesStatus != EntityHeadlinesStatus.initial && state.headlinesStatus != EntityHeadlinesStatus.loadingMore && state.status == EntityDetailsStatus.success) - SliverFillRemaining( // Use SliverFillRemaining for empty state + SliverFillRemaining( + // Use SliverFillRemaining for empty state child: Center( child: Text( l10n.noHeadlinesFoundMessage, @@ -255,32 +280,43 @@ class _EntityDetailsViewState extends State { delegate: SliverChildBuilderDelegate( (context, index) { if (index >= state.headlines.length) { - return state.hasMoreHeadlines && state.headlinesStatus == EntityHeadlinesStatus.loadingMore + return state.hasMoreHeadlines && + state.headlinesStatus == + EntityHeadlinesStatus.loadingMore ? const Center( - child: Padding( - padding: EdgeInsets.all(AppSpacing.md), - child: CircularProgressIndicator(), - ), - ) - : const SizedBox.shrink(); + 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; - + 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, - ), + 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 + currentContextEntityId: + state.entity is Category + ? (state.entity as Category).id + : state.entity is Source ? (state.entity as Source).id : null, ); @@ -288,15 +324,18 @@ class _EntityDetailsViewState extends State { case HeadlineImageStyle.smallThumbnail: tile = HeadlineTileImageStart( headline: headline, - onHeadlineTap: () => context.pushNamed( - Routes.globalArticleDetailsName, // Use new global route - pathParameters: {'id': headline.id}, - extra: 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 + currentContextEntityId: + state.entity is Category + ? (state.entity as Category).id + : state.entity is Source ? (state.entity as Source).id : null, ); @@ -304,15 +343,18 @@ class _EntityDetailsViewState extends State { case HeadlineImageStyle.largeThumbnail: tile = HeadlineTileImageTop( headline: headline, - onHeadlineTap: () => context.pushNamed( - Routes.globalArticleDetailsName, // Use new global route - pathParameters: {'id': headline.id}, - extra: 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 + currentContextEntityId: + state.entity is Category + ? (state.entity as Category).id + : state.entity is Source ? (state.entity as Source).id : null, ); @@ -320,21 +362,28 @@ class _EntityDetailsViewState extends State { } return tile; }, - childCount: state.headlines.length + (state.hasMoreHeadlines && state.headlinesStatus == EntityHeadlinesStatus.loadingMore ? 1 : 0), + 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, - ), + // 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 5e45a64b..ec84f22a 100644 --- a/lib/headline-details/view/headline_details_page.dart +++ b/lib/headline-details/view/headline_details_page.dart @@ -15,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, Category, Source; // Added Category, Source + show + Headline, + HeadlineImageStyle, + Category, + 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'; @@ -395,7 +399,8 @@ class _HeadlineDetailsPageState extends State { if (headline.source != null) { chips.add( - GestureDetector( // Added GestureDetector + GestureDetector( + // Added GestureDetector onTap: () { context.push( Routes.sourceDetails, @@ -443,7 +448,8 @@ class _HeadlineDetailsPageState extends State { if (headline.source?.headquarters != null) { final country = headline.source!.headquarters!; chips.add( - Chip( // Country chip is usually not tappable to a details page in this context + Chip( + // Country chip is usually not tappable to a details page in this context avatar: CircleAvatar( radius: chipAvatarSize / 2, backgroundColor: Colors.transparent, @@ -462,7 +468,8 @@ class _HeadlineDetailsPageState extends State { if (headline.category != null) { chips.add( - GestureDetector( // Added GestureDetector + GestureDetector( + // Added GestureDetector onTap: () { context.push( Routes.categoryDetails, diff --git a/lib/router/router.dart b/lib/router/router.dart index 54711287..324f5eee 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -191,10 +191,18 @@ GoRouter createRouter({ 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}')) { + 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 to main app section or details page ($currentLocation).', ); @@ -313,7 +321,9 @@ GoRouter createRouter({ final args = state.extra as EntityDetailsPageArguments?; if (args == null) { return const Scaffold( - body: Center(child: Text('Error: Missing category details arguments')), + body: Center( + child: Text('Error: Missing category details arguments'), + ), ); } return BlocProvider.value( @@ -329,7 +339,9 @@ GoRouter createRouter({ final args = state.extra as EntityDetailsPageArguments?; if (args == null) { return const Scaffold( - body: Center(child: Text('Error: Missing source details arguments')), + body: Center( + child: Text('Error: Missing source details arguments'), + ), ); } return BlocProvider.value( @@ -379,16 +391,18 @@ GoRouter createRouter({ providers: [ BlocProvider.value(value: accountBloc), BlocProvider( - create: (context) => HeadlineDetailsBloc( - headlinesRepository: - context.read>(), - ), + create: + (context) => HeadlineDetailsBloc( + headlinesRepository: + context.read>(), + ), ), BlocProvider( - create: (context) => SimilarHeadlinesBloc( - headlinesRepository: - context.read>(), - ), + create: + (context) => SimilarHeadlinesBloc( + headlinesRepository: + context.read>(), + ), ), ], child: HeadlineDetailsPage( diff --git a/lib/shared/widgets/headline_tile_image_start.dart b/lib/shared/widgets/headline_tile_image_start.dart index 9a090ffd..6d618512 100644 --- a/lib/shared/widgets/headline_tile_image_start.dart +++ b/lib/shared/widgets/headline_tile_image_start.dart @@ -62,39 +62,40 @@ class HeadlineTileImageStart extends StatelessWidget { height: 72, child: ClipRRect( borderRadius: BorderRadius.circular(AppSpacing.xs), - child: headline.imageUrl != null - ? Image.network( - headline.imageUrl!, - fit: BoxFit.cover, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; - return Container( - color: colorScheme.surfaceContainerHighest, - child: const Center( - child: CircularProgressIndicator( - strokeWidth: 2, + child: + headline.imageUrl != null + ? Image.network( + headline.imageUrl!, + fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Container( + color: colorScheme.surfaceContainerHighest, + child: const Center( + child: CircularProgressIndicator( + strokeWidth: 2, + ), ), - ), - ); - }, - errorBuilder: (context, error, stackTrace) => - Container( + ); + }, + errorBuilder: + (context, error, stackTrace) => Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.broken_image_outlined, + color: colorScheme.onSurfaceVariant, + size: AppSpacing.xl, + ), + ), + ) + : Container( color: colorScheme.surfaceContainerHighest, child: Icon( - Icons.broken_image_outlined, + Icons.image_not_supported_outlined, color: colorScheme.onSurfaceVariant, size: AppSpacing.xl, ), ), - ) - : Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.image_not_supported_outlined, - color: colorScheme.onSurfaceVariant, - size: AppSpacing.xl, - ), - ), ), ), const SizedBox(width: AppSpacing.md), // Always add spacing diff --git a/lib/shared/widgets/headline_tile_image_top.dart b/lib/shared/widgets/headline_tile_image_top.dart index 66981306..fbd65933 100644 --- a/lib/shared/widgets/headline_tile_image_top.dart +++ b/lib/shared/widgets/headline_tile_image_top.dart @@ -60,44 +60,46 @@ class HeadlineTileImageTop extends StatelessWidget { topLeft: Radius.circular(AppSpacing.xs), topRight: Radius.circular(AppSpacing.xs), ), - child: headline.imageUrl != null - ? Image.network( - headline.imageUrl!, - width: double.infinity, - height: 180, // Standard large image height - fit: BoxFit.cover, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; - return Container( - width: double.infinity, - height: 180, - color: colorScheme.surfaceContainerHighest, - child: const Center( - child: CircularProgressIndicator(strokeWidth: 2), - ), - ); - }, - errorBuilder: (context, error, stackTrace) => Container( + child: + headline.imageUrl != null + ? Image.network( + headline.imageUrl!, + width: double.infinity, + height: 180, // Standard large image height + fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Container( + width: double.infinity, + height: 180, + color: colorScheme.surfaceContainerHighest, + child: const Center( + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + }, + errorBuilder: + (context, error, stackTrace) => Container( + width: double.infinity, + height: 180, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.broken_image_outlined, + color: colorScheme.onSurfaceVariant, + size: AppSpacing.xxl, + ), + ), + ) + : Container( width: double.infinity, height: 180, color: colorScheme.surfaceContainerHighest, child: Icon( - Icons.broken_image_outlined, + Icons.image_not_supported_outlined, color: colorScheme.onSurfaceVariant, size: AppSpacing.xxl, ), ), - ) - : Container( - width: double.infinity, - height: 180, - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.image_not_supported_outlined, - color: colorScheme.onSurfaceVariant, - size: AppSpacing.xxl, - ), - ), ), ), Padding( From b00ff451b27fe398a51c0b2a04ade82823759856 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 31 May 2025 18:07:20 +0100 Subject: [PATCH 23/23] lint: misc --- lib/account/view/saved_headlines_page.dart | 3 --- lib/entity_details/bloc/entity_details_bloc.dart | 6 +++--- lib/entity_details/view/entity_details_page.dart | 13 +++++-------- .../view/headline_details_page.dart | 11 ++++------- lib/headlines-feed/view/headlines_feed_page.dart | 3 --- .../bloc/headlines_search_bloc.dart | 8 ++++---- .../view/headlines_search_page.dart | 3 --- lib/shared/localization/ar_timeago_messages.dart | 10 +++++----- lib/shared/shared.dart | 2 +- lib/shared/widgets/headline_tile_image_start.dart | 6 +++--- lib/shared/widgets/widgets.dart | 6 +++--- 11 files changed, 28 insertions(+), 43 deletions(-) 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 index 77a596f5..4880ab81 100644 --- a/lib/entity_details/bloc/entity_details_bloc.dart +++ b/lib/entity_details/bloc/entity_details_bloc.dart @@ -57,7 +57,7 @@ class EntityDetailsBloc extends Bloc { ); dynamic entityToLoad = event.entity; - EntityType? entityTypeToLoad = event.entityType; + var entityTypeToLoad = event.entityType; try { // 1. Determine/Fetch Entity @@ -107,7 +107,7 @@ class EntityDetailsBloc extends Bloc { ); // 3. Determine isFollowing status - bool isCurrentlyFollowing = false; + var isCurrentlyFollowing = false; final currentAccountState = _accountBloc.state; if (currentAccountState.preferences != null) { if (entityTypeToLoad == EntityType.category && @@ -266,7 +266,7 @@ class EntityDetailsBloc extends Bloc { ) { if (state.entity == null || state.entityType == null) return; - bool isCurrentlyFollowing = false; + var isCurrentlyFollowing = false; final preferences = event.preferences; if (state.entityType == EntityType.category && state.entity is Category) { diff --git a/lib/entity_details/view/entity_details_page.dart b/lib/entity_details/view/entity_details_page.dart index 910114ff..5cdb4689 100644 --- a/lib/entity_details/view/entity_details_page.dart +++ b/lib/entity_details/view/entity_details_page.dart @@ -29,7 +29,7 @@ class EntityDetailsPageArguments { } class EntityDetailsPage extends StatelessWidget { - const EntityDetailsPage({super.key, required this.args}); + const EntityDetailsPage({required this.args, super.key}); final EntityDetailsPageArguments args; @@ -61,7 +61,7 @@ class EntityDetailsPage extends StatelessWidget { } class EntityDetailsView extends StatefulWidget { - const EntityDetailsView({super.key, required this.args}); // Accept args + const EntityDetailsView({required this.args, super.key}); // Accept args final EntityDetailsPageArguments args; // Store args @@ -136,21 +136,21 @@ class _EntityDetailsViewState extends State { } // At this point, state.entity should not be null if success or loading more - final String appBarTitle = + final appBarTitle = state.entity is Category ? (state.entity as Category).name : state.entity is Source ? (state.entity as Source).name : l10n.detailsPageTitle; - final String? description = + final description = state.entity is Category ? (state.entity as Category).description : state.entity is Source ? (state.entity as Source).description : null; - final String? entityIconUrl = + final entityIconUrl = (state.entity is Category && (state.entity as Category).iconUrl != null) ? (state.entity as Category).iconUrl @@ -320,7 +320,6 @@ class _EntityDetailsViewState extends State { ? (state.entity as Source).id : null, ); - break; case HeadlineImageStyle.smallThumbnail: tile = HeadlineTileImageStart( headline: headline, @@ -339,7 +338,6 @@ class _EntityDetailsViewState extends State { ? (state.entity as Source).id : null, ); - break; case HeadlineImageStyle.largeThumbnail: tile = HeadlineTileImageTop( headline: headline, @@ -358,7 +356,6 @@ class _EntityDetailsViewState extends State { ? (state.entity as Source).id : null, ); - break; } return tile; }, diff --git a/lib/headline-details/view/headline_details_page.dart b/lib/headline-details/view/headline_details_page.dart index ec84f22a..ef5b2826 100644 --- a/lib/headline-details/view/headline_details_page.dart +++ b/lib/headline-details/view/headline_details_page.dart @@ -16,9 +16,9 @@ import 'package:ht_main/router/routes.dart'; import 'package:ht_main/shared/shared.dart'; import 'package:ht_shared/ht_shared.dart' show + Category, Headline, HeadlineImageStyle, - Category, Source; // Added Category, Source import 'package:intl/intl.dart'; import 'package:share_plus/share_plus.dart'; // Import share_plus @@ -404,7 +404,7 @@ class _HeadlineDetailsPageState extends State { onTap: () { context.push( Routes.sourceDetails, - extra: EntityDetailsPageArguments(entity: headline.source!), + extra: EntityDetailsPageArguments(entity: headline.source), ); }, child: Chip( @@ -473,7 +473,7 @@ class _HeadlineDetailsPageState extends State { onTap: () { context.push( Routes.categoryDetails, - extra: EntityDetailsPageArguments(entity: headline.category!), + extra: EntityDetailsPageArguments(entity: headline.category), ); }, child: Chip( @@ -551,7 +551,6 @@ class _HeadlineDetailsPageState extends State { extra: similarHeadline, ), ); - break; case HeadlineImageStyle.smallThumbnail: tile = HeadlineTileImageStart( headline: similarHeadline, @@ -562,7 +561,6 @@ class _HeadlineDetailsPageState extends State { extra: similarHeadline, ), ); - break; case HeadlineImageStyle.largeThumbnail: tile = HeadlineTileImageTop( headline: similarHeadline, @@ -573,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/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 6d618512..79d6c686 100644 --- a/lib/shared/widgets/headline_tile_image_start.dart +++ b/lib/shared/widgets/headline_tile_image_start.dart @@ -69,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( @@ -79,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, @@ -88,7 +88,7 @@ class HeadlineTileImageStart extends StatelessWidget { ), ), ) - : Container( + : ColoredBox( color: colorScheme.surfaceContainerHighest, child: Icon( Icons.image_not_supported_outlined, 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';