diff --git a/lib/account/view/saved_headlines_page.dart b/lib/account/view/saved_headlines_page.dart index bcfda72e..cfee5eba 100644 --- a/lib/account/view/saved_headlines_page.dart +++ b/lib/account/view/saved_headlines_page.dart @@ -1,10 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; // Added GoRouter import 'package:ht_main/account/bloc/account_bloc.dart'; -import 'package:ht_main/headlines-feed/widgets/headline_item_widget.dart'; +import 'package:ht_main/app/bloc/app_bloc.dart'; // Added AppBloc +// HeadlineItemWidget import removed import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/router/routes.dart'; -import 'package:ht_main/shared/widgets/widgets.dart'; +import 'package:ht_main/shared/shared.dart'; // Imports new headline tiles +import 'package:ht_shared/ht_shared.dart' + show Headline, HeadlineImageStyle; // Added HeadlineImageStyle /// {@template saved_headlines_page} /// Displays the list of headlines saved by the user. @@ -68,20 +72,64 @@ class SavedHeadlinesPage extends StatelessWidget { separatorBuilder: (context, index) => const Divider(height: 1), itemBuilder: (context, index) { final headline = savedHeadlines[index]; - return HeadlineItemWidget( - headline: headline, - targetRouteName: Routes.accountArticleDetailsName, - trailing: IconButton( - // Changed from trailingWidget - icon: const Icon(Icons.delete_outline), - tooltip: 'Remove from saved', // Placeholder - onPressed: () { - context.read().add( - AccountSaveHeadlineToggled(headline: headline), - ); - }, - ), + final imageStyle = + context + .watch() + .state + .settings + .feedPreferences + .headlineImageStyle; + + final trailingButton = IconButton( + icon: const Icon(Icons.delete_outline), + tooltip: l10n.headlineDetailsRemoveFromSavedTooltip, // Use l10n + onPressed: () { + context.read().add( + AccountSaveHeadlineToggled(headline: headline), + ); + }, ); + + Widget tile; + switch (imageStyle) { + case HeadlineImageStyle.hidden: + tile = HeadlineTileTextOnly( + headline: headline, + onHeadlineTap: + () => context.goNamed( + Routes.accountArticleDetailsName, + pathParameters: {'id': headline.id}, + extra: headline, + ), + trailing: trailingButton, + ); + break; + case HeadlineImageStyle.smallThumbnail: + tile = HeadlineTileImageStart( + headline: headline, + onHeadlineTap: + () => context.goNamed( + Routes.accountArticleDetailsName, + pathParameters: {'id': headline.id}, + extra: headline, + ), + trailing: trailingButton, + ); + break; + case HeadlineImageStyle.largeThumbnail: + tile = HeadlineTileImageTop( + headline: headline, + onHeadlineTap: + () => context.goNamed( + Routes.accountArticleDetailsName, + pathParameters: {'id': headline.id}, + extra: headline, + ), + trailing: trailingButton, + ); + break; + } + return tile; }, ); }, diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 87c1bb7e..ec041cce 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -182,7 +182,8 @@ class _AppViewState extends State<_AppView> { previous.flexScheme != current.flexScheme || previous.fontFamily != current.fontFamily || previous.appTextScaleFactor != current.appTextScaleFactor || - previous.locale != current.locale, // Added locale check + previous.locale != current.locale || + previous.settings != current.settings, // Added settings check builder: (context, state) { print('[_AppViewState] Building MaterialApp.router'); print('[_AppViewState] state.fontFamily: ${state.fontFamily}'); diff --git a/lib/headline-details/view/headline_details_page.dart b/lib/headline-details/view/headline_details_page.dart index 188f3953..61e3793c 100644 --- a/lib/headline-details/view/headline_details_page.dart +++ b/lib/headline-details/view/headline_details_page.dart @@ -5,15 +5,16 @@ import 'package:flutter/foundation.dart' show kIsWeb; // Import kIsWeb import 'package:flutter/material.dart'; 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 AccountBloc -import 'package:ht_main/headline-details/bloc/headline_details_bloc.dart'; // Import BLoC -import 'package:ht_main/headline-details/bloc/similar_headlines_bloc.dart'; // Import SimilarHeadlinesBloc -import 'package:ht_main/headlines-feed/widgets/headline_item_widget.dart'; // Import HeadlineItemWidget +import 'package:ht_main/account/bloc/account_bloc.dart'; +import 'package:ht_main/app/bloc/app_bloc.dart'; // Added AppBloc +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 import 'package:ht_main/l10n/l10n.dart'; -import 'package:ht_main/router/routes.dart'; // Import Routes +import 'package:ht_main/router/routes.dart'; import 'package:ht_main/shared/shared.dart'; import 'package:ht_shared/ht_shared.dart' - show Headline; // Import Headline model + show Headline, HeadlineImageStyle; // Added HeadlineImageStyle import 'package:intl/intl.dart'; import 'package:share_plus/share_plus.dart'; // Import share_plus import 'package:url_launcher/url_launcher_string.dart'; @@ -268,45 +269,59 @@ class _HeadlineDetailsPageState extends State { child: Text(headline.title, style: textTheme.headlineMedium), ), ), - if (headline.imageUrl != null) - SliverPadding( - padding: const EdgeInsets.only( - top: AppSpacing.lg, - left: AppSpacing.paddingLarge, - right: AppSpacing.paddingLarge, - ), - sliver: SliverToBoxAdapter( - child: ClipRRect( - borderRadius: BorderRadius.circular(AppSpacing.md), - child: Image.network( - headline.imageUrl!, - width: double.infinity, - height: 200, - fit: BoxFit.cover, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; - return Container( - width: double.infinity, - height: 200, - color: colorScheme.surfaceContainerHighest, - child: const Center(child: CircularProgressIndicator()), - ); - }, - errorBuilder: - (context, error, stackTrace) => Container( + // Image or Placeholder Section + SliverPadding( + padding: const EdgeInsets.only( + top: AppSpacing.lg, + left: AppSpacing.paddingLarge, + right: AppSpacing.paddingLarge, + ), + sliver: SliverToBoxAdapter( + child: ClipRRect( + borderRadius: BorderRadius.circular(AppSpacing.md), + child: + headline.imageUrl != null + ? Image.network( + headline.imageUrl!, + width: double.infinity, + height: 200, + fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Container( + width: double.infinity, + height: 200, + color: colorScheme.surfaceContainerHighest, + child: const Center( + child: CircularProgressIndicator(), + ), + ); + }, + errorBuilder: + (context, error, stackTrace) => Container( + width: double.infinity, + height: 200, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.broken_image_outlined, + color: colorScheme.onSurfaceVariant, + size: AppSpacing.xxl, + ), + ), + ) + : Container( width: double.infinity, height: 200, color: colorScheme.surfaceContainerHighest, child: Icon( - Icons.broken_image, + Icons.image_not_supported_outlined, color: colorScheme.onSurfaceVariant, size: AppSpacing.xxl, ), ), - ), - ), ), ), + ), SliverPadding( padding: horizontalPadding.copyWith(top: AppSpacing.lg), sliver: SliverToBoxAdapter( @@ -490,17 +505,54 @@ class _HeadlineDetailsPageState extends State { horizontal: AppSpacing.paddingMedium, vertical: AppSpacing.sm, ), - child: HeadlineItemWidget( - headline: similarHeadline, - // Use the onTap callback for navigation - onTap: (tappedHeadline) { - context.pushNamed( - Routes.articleDetailsName, - pathParameters: {'id': tappedHeadline.id}, - extra: tappedHeadline, - ); + child: Builder( + // Use Builder to get a new context that can watch AppBloc + builder: (context) { + final imageStyle = + context + .watch() + .state + .settings + .feedPreferences + .headlineImageStyle; + Widget tile; + switch (imageStyle) { + case HeadlineImageStyle.hidden: + tile = HeadlineTileTextOnly( + headline: similarHeadline, + onHeadlineTap: + () => context.pushNamed( + Routes.articleDetailsName, + pathParameters: {'id': similarHeadline.id}, + extra: similarHeadline, + ), + ); + break; + case HeadlineImageStyle.smallThumbnail: + tile = HeadlineTileImageStart( + headline: similarHeadline, + onHeadlineTap: + () => context.pushNamed( + Routes.articleDetailsName, + pathParameters: {'id': similarHeadline.id}, + extra: similarHeadline, + ), + ); + break; + case HeadlineImageStyle.largeThumbnail: + tile = HeadlineTileImageTop( + headline: similarHeadline, + onHeadlineTap: + () => context.pushNamed( + Routes.articleDetailsName, + pathParameters: {'id': similarHeadline.id}, + extra: similarHeadline, + ), + ); + break; + } + return tile; }, - // targetRouteName: Routes.articleDetailsName, // No longer needed here ), ); }, childCount: loadedState.similarHeadlines.length), diff --git a/lib/headlines-feed/view/headlines_feed_page.dart b/lib/headlines-feed/view/headlines_feed_page.dart index dd617eaf..f415cb73 100644 --- a/lib/headlines-feed/view/headlines_feed_page.dart +++ b/lib/headlines-feed/view/headlines_feed_page.dart @@ -6,12 +6,16 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; // Import Category // Import Country +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:ht_main/app/bloc/app_bloc.dart'; // Added to access settings import 'package:ht_main/headlines-feed/bloc/headlines_feed_bloc.dart'; -import 'package:ht_main/headlines-feed/widgets/headline_item_widget.dart'; +// HeadlineItemWidget import removed import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/router/routes.dart'; import 'package:ht_main/shared/shared.dart'; -// Import Source +import 'package:ht_shared/ht_shared.dart' + show Headline, HeadlineImageStyle; // Added HeadlineImageStyle /// {@template headlines_feed_view} /// The core view widget for the headlines feed. @@ -216,10 +220,51 @@ class _HeadlinesFeedPageState extends State { } // Otherwise, build the headline item final headline = state.headlines[index]; - return HeadlineItemWidget( - headline: headline, - targetRouteName: Routes.articleDetailsName, - ); + final imageStyle = + context + .watch() + .state + .settings + .feedPreferences + .headlineImageStyle; + + Widget tile; + switch (imageStyle) { + case HeadlineImageStyle.hidden: + tile = HeadlineTileTextOnly( + headline: headline, + onHeadlineTap: + () => context.goNamed( + Routes.articleDetailsName, + pathParameters: {'id': headline.id}, + extra: headline, + ), + ); + break; + case HeadlineImageStyle.smallThumbnail: + tile = HeadlineTileImageStart( + headline: headline, + onHeadlineTap: + () => context.goNamed( + Routes.articleDetailsName, + pathParameters: {'id': headline.id}, + extra: headline, + ), + ); + break; + case HeadlineImageStyle.largeThumbnail: + tile = HeadlineTileImageTop( + headline: headline, + onHeadlineTap: + () => context.goNamed( + Routes.articleDetailsName, + pathParameters: {'id': headline.id}, + extra: headline, + ), + ); + break; + } + return tile; }, ), ); diff --git a/lib/headlines-feed/widgets/headline_item_widget.dart b/lib/headlines-feed/widgets/headline_item_widget.dart deleted file mode 100644 index d25e3918..00000000 --- a/lib/headlines-feed/widgets/headline_item_widget.dart +++ /dev/null @@ -1,274 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:ht_main/shared/constants/constants.dart'; // Import AppSpacing -import 'package:ht_shared/ht_shared.dart'; // Import models from ht_shared -import 'package:intl/intl.dart'; // For date formatting - -/// A widget that displays a single headline with enhanced styling. -class HeadlineItemWidget extends StatelessWidget { - /// Creates a [HeadlineItemWidget]. - const HeadlineItemWidget({ - required this.headline, - this.targetRouteName, // Make optional - this.onTap, // Add optional onTap callback - this.trailing, // Add optional trailing widget - super.key, - }) : assert( - targetRouteName != null || onTap != null, - 'Either targetRouteName or onTap must be provided', - ); - - /// The headline to display. - final Headline headline; - - /// The named route to navigate to when the item is tapped. - /// Used if [onTap] is null. - final String? targetRouteName; - - /// Optional callback to handle tap events. - /// If provided, this is used instead of navigating via [targetRouteName]. - final void Function(Headline headline)? onTap; - - /// An optional widget to display at the end of the row. - final Widget? trailing; - - // Helper for date formatting - static final _dateFormatter = DateFormat.yMd().add_jm(); - - @override - Widget build(BuildContext context) { - final textTheme = Theme.of(context).textTheme; - final colorScheme = Theme.of(context).colorScheme; - final cardTheme = Theme.of(context).cardTheme; - // Attempt to get radius, default if shape is not RoundedRectangleBorder - final borderRadius = - cardTheme.shape is RoundedRectangleBorder - ? (cardTheme.shape! as RoundedRectangleBorder).borderRadius - : BorderRadius.circular( - cardTheme.clipBehavior != Clip.none ? AppSpacing.xs : 0.0, - ); - - // Use InkWell for tap effect on the Card - return Card( - // The Card itself provides margin via the parent ListView's separator. - // Horizontal padding is handled by the parent ListView's padding. - clipBehavior: - cardTheme.clipBehavior ?? Clip.antiAlias, // Use theme's clip behavior - child: InkWell( - onTap: () { - if (onTap != null) { - onTap!(headline); - } else { - context.goNamed( - targetRouteName!, // Not null due to assert - pathParameters: {'id': headline.id}, - extra: headline, // Pass the full headline object - ); - } - }, - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), // Internal padding - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Image on the left - if (headline.imageUrl != null) - ClipRRect( - // Use the determined border radius - borderRadius: borderRadius.resolve( - Directionality.of(context), - ), - child: Image.network( - headline.imageUrl!, - width: 80, // Consistent size - height: 80, - fit: BoxFit.cover, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; - return Container( - width: 80, - height: 80, - color: colorScheme.surfaceContainerHighest, - child: const Center(child: CircularProgressIndicator()), - ); - }, - errorBuilder: - (context, error, stackTrace) => Container( - width: 80, - height: 80, - decoration: BoxDecoration( - color: colorScheme.errorContainer, - borderRadius: borderRadius.resolve( - Directionality.of(context), - ), - ), - child: Icon( - Icons.broken_image_outlined, - color: colorScheme.onErrorContainer, - ), - ), - ), - ) - else // Placeholder if no image URL - Container( - width: 80, - height: 80, - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: borderRadius.resolve( - Directionality.of(context), - ), - ), - child: Icon( - Icons.image_not_supported_outlined, - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: AppSpacing.md), - // Text content on the right - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - headline.title, - style: textTheme.titleLarge, // Use titleLarge per theme - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox( - height: AppSpacing.sm, - ), // Spacing between title and metadata - Wrap( - spacing: AppSpacing.md, // Horizontal space between items - runSpacing: AppSpacing.xs, // Vertical space if wraps - children: [ - if (headline.source != null) - _MetadataItem( - icon: Icons.source_outlined, - text: headline.source!.name, // Use source.name - ), - if (headline.publishedAt != null) - _MetadataItem( - icon: Icons.access_time_outlined, - text: _dateFormatter.format(headline.publishedAt!), - ), - if (headline.category != null) - _MetadataItem( - icon: Icons.category_outlined, - text: headline.category!.name, - ), - if (headline.source?.headquarters != - null) // Use source?.headquarters - _CountryMetadataItem( - flagUrl: - headline - .source! - .headquarters! - .flagUrl, // Access flagUrl from headquarters - countryName: - headline - .source! - .headquarters! - .name, // Access name from headquarters - ), - ], - ), - ], - ), - ), - // Add the trailing widget if provided - if (trailing != null) ...[ - const SizedBox(width: AppSpacing.md), - trailing!, - ], - ], - ), - ), - ), - ); - } -} - -/// Small helper widget for displaying country metadata with a flag avatar. -class _CountryMetadataItem extends StatelessWidget { - const _CountryMetadataItem({ - required this.flagUrl, - required this.countryName, - }); - - final String flagUrl; - final String countryName; - - @override - Widget build(BuildContext context) { - final textTheme = Theme.of(context).textTheme; - final colorScheme = Theme.of(context).colorScheme; - const avatarRadius = 7.0; // Small radius for the flag - - return Row( - mainAxisSize: MainAxisSize.min, // Take only needed space - children: [ - CircleAvatar( - radius: avatarRadius, - backgroundColor: Colors.transparent, // Avoid background color clash - backgroundImage: NetworkImage(flagUrl), - // Optional: Add error handling for the image - onBackgroundImageError: (exception, stackTrace) { - // Log error or display placeholder - debugPrint('Error loading flag image: $exception'); - }, - // Placeholder in case of error or while loading - child: Icon( - Icons.flag_circle_outlined, - size: avatarRadius * 2, - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: AppSpacing.xs), - Text( - countryName, - style: textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, // Subtle color - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ); - } -} - -/// Small helper widget for displaying metadata with an icon. -class _MetadataItem extends StatelessWidget { - const _MetadataItem({required this.icon, required this.text}); - - final IconData icon; - final String text; - - @override - Widget build(BuildContext context) { - final textTheme = Theme.of(context).textTheme; - final colorScheme = Theme.of(context).colorScheme; - - return Row( - mainAxisSize: MainAxisSize.min, // Take only needed space - children: [ - Icon( - icon, - size: 14, // Smaller icon size for metadata - color: colorScheme.onSurfaceVariant, // Subtle color - ), - const SizedBox(width: AppSpacing.xs), - Text( - text, - style: textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, // Subtle color - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ); - } -} diff --git a/lib/headlines-search/view/headlines_search_page.dart b/lib/headlines-search/view/headlines_search_page.dart index 9139bc13..7699b7e4 100644 --- a/lib/headlines-search/view/headlines_search_page.dart +++ b/lib/headlines-search/view/headlines_search_page.dart @@ -3,19 +3,20 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:ht_main/headlines-feed/widgets/headline_item_widget.dart'; +import 'package:go_router/go_router.dart'; // Import GoRouter for navigation +import 'package:ht_main/app/bloc/app_bloc.dart'; // Import AppBloc for settings +// HeadlineItemWidget import removed import 'package:ht_main/headlines-search/bloc/headlines_search_bloc.dart'; -import 'package:ht_main/headlines-search/models/search_model_type.dart'; // Import SearchModelType +import 'package:ht_main/headlines-search/models/search_model_type.dart'; // Import new item widgets import 'package:ht_main/headlines-search/widgets/category_item_widget.dart'; import 'package:ht_main/headlines-search/widgets/country_item_widget.dart'; import 'package:ht_main/headlines-search/widgets/source_item_widget.dart'; import 'package:ht_main/l10n/l10n.dart'; -import 'package:ht_main/router/routes.dart'; // Import Routes -import 'package:ht_main/shared/constants/app_spacing.dart'; // Import AppSpacing -import 'package:ht_main/shared/widgets/failure_state_widget.dart'; -import 'package:ht_main/shared/widgets/initial_state_widget.dart'; -import 'package:ht_shared/ht_shared.dart'; // Import shared models +import 'package:ht_main/router/routes.dart'; +import 'package:ht_main/shared/constants/app_spacing.dart'; +import 'package:ht_main/shared/shared.dart'; // Imports new headline tiles +import 'package:ht_shared/ht_shared.dart'; /// Page widget responsible for providing the BLoC for the headlines search feature. class HeadlinesSearchPage extends StatelessWidget { @@ -274,10 +275,51 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { final item = results[index]; switch (resultsModelType) { case SearchModelType.headline: - return HeadlineItemWidget( - headline: item as Headline, - targetRouteName: Routes.searchArticleDetailsName, - ); + final headline = item as Headline; + final imageStyle = + context + .watch() + .state + .settings + .feedPreferences + .headlineImageStyle; + Widget tile; + switch (imageStyle) { + case HeadlineImageStyle.hidden: + tile = HeadlineTileTextOnly( + headline: headline, + onHeadlineTap: + () => context.goNamed( + Routes.searchArticleDetailsName, + pathParameters: {'id': headline.id}, + extra: headline, + ), + ); + break; + case HeadlineImageStyle.smallThumbnail: + tile = HeadlineTileImageStart( + headline: headline, + onHeadlineTap: + () => context.goNamed( + Routes.searchArticleDetailsName, + pathParameters: {'id': headline.id}, + extra: headline, + ), + ); + break; + case HeadlineImageStyle.largeThumbnail: + tile = HeadlineTileImageTop( + headline: headline, + onHeadlineTap: + () => context.goNamed( + Routes.searchArticleDetailsName, + pathParameters: {'id': headline.id}, + extra: headline, + ), + ); + break; + } + return tile; case SearchModelType.category: return CategoryItemWidget(category: item as Category); case SearchModelType.source: diff --git a/lib/main.dart b/lib/main.dart index 11777e84..60cb4770 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,12 +9,19 @@ import 'package:ht_http_client/ht_http_client.dart'; import 'package:ht_kv_storage_shared_preferences/ht_kv_storage_shared_preferences.dart'; import 'package:ht_main/app/app.dart'; import 'package:ht_main/bloc_observer.dart'; +import 'package:ht_main/shared/localization/ar_timeago_messages.dart'; +import 'package:ht_main/shared/localization/en_timeago_messages.dart'; // Added import 'package:ht_shared/ht_shared.dart'; +import 'package:timeago/timeago.dart' as timeago; void main() async { WidgetsFlutterBinding.ensureInitialized(); Bloc.observer = const AppBlocObserver(); + // Initialize timeago custom locale messages + timeago.setLocaleMessages('en', EnTimeagoMessages()); // Added + timeago.setLocaleMessages('ar', ArTimeagoMessages()); + // 1. Instantiate KV Storage Service final kvStorage = await HtKvStorageSharedPreferences.getInstance(); diff --git a/lib/shared/localization/ar_timeago_messages.dart b/lib/shared/localization/ar_timeago_messages.dart new file mode 100644 index 00000000..c81091b5 --- /dev/null +++ b/lib/shared/localization/ar_timeago_messages.dart @@ -0,0 +1,43 @@ +import 'package:timeago/timeago.dart' as timeago; + +/// Custom Arabic lookup messages for the timeago package. +class ArTimeagoMessages implements timeago.LookupMessages { + @override + String prefixAgo() => ''; // No prefix, will include in string + @override + String prefixFromNow() => 'بعد '; // Prefix for future + @override + String suffixAgo() => ''; // No suffix + @override + String suffixFromNow() => ''; + + @override + String lessThanOneMinute(int seconds) => 'الآن'; + @override + String aboutAMinute(int minutes) => 'منذ 1د'; + @override + String minutes(int minutes) => 'منذ ${minutes}د'; + + @override + String aboutAnHour(int minutes) => 'منذ 1س'; + @override + String hours(int hours) => 'منذ ${hours}س'; + + @override + String aDay(int hours) => 'منذ 1ي'; // Or 'أمس' if preferred for exactly 1 day + @override + String days(int days) => 'منذ ${days}ي'; + + @override + String aboutAMonth(int days) => 'منذ 1ش'; + @override + String months(int months) => 'منذ ${months}ش'; + + @override + String aboutAYear(int year) => 'منذ 1سنة'; // Using سنة for year + @override + String years(int years) => 'منذ ${years}سنوات'; // Standard plural + + @override + String wordSeparator() => ' '; +} diff --git a/lib/shared/localization/en_timeago_messages.dart b/lib/shared/localization/en_timeago_messages.dart new file mode 100644 index 00000000..e42cc5fa --- /dev/null +++ b/lib/shared/localization/en_timeago_messages.dart @@ -0,0 +1,43 @@ +import 'package:timeago/timeago.dart' as timeago; + +/// Custom English lookup messages for the timeago package (concise). +class EnTimeagoMessages implements timeago.LookupMessages { + @override + String prefixAgo() => ''; // No prefix + @override + String prefixFromNow() => ''; // No prefix + @override + String suffixAgo() => ' ago'; // Suffix instead + @override + String suffixFromNow() => ' from now'; // Suffix instead + + @override + String lessThanOneMinute(int seconds) => 'now'; + @override + String aboutAMinute(int minutes) => '1m'; + @override + String minutes(int minutes) => '${minutes}m'; + + @override + String aboutAnHour(int minutes) => '1h'; + @override + String hours(int hours) => '${hours}h'; + + @override + String aDay(int hours) => '1d'; + @override + String days(int days) => '${days}d'; + + @override + String aboutAMonth(int days) => '1mo'; + @override + String months(int months) => '${months}mo'; + + @override + String aboutAYear(int year) => '1y'; + @override + String years(int years) => '${years}y'; + + @override + String wordSeparator() => ' '; +} diff --git a/lib/shared/shared.dart b/lib/shared/shared.dart index 04b223e7..5bbcfd55 100644 --- a/lib/shared/shared.dart +++ b/lib/shared/shared.dart @@ -7,3 +7,4 @@ library; export 'constants/constants.dart'; export 'theme/theme.dart'; export 'widgets/widgets.dart'; +export 'utils/utils.dart'; // Added export for utils diff --git a/lib/shared/utils/date_formatter.dart b/lib/shared/utils/date_formatter.dart new file mode 100644 index 00000000..3ac01296 --- /dev/null +++ b/lib/shared/utils/date_formatter.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; +import 'package:timeago/timeago.dart' as timeago; + +/// Formats the given [dateTime] into a relative time string +/// (e.g., "5m ago", "Yesterday", "now"). +/// +/// Uses the current locale from [context] to format appropriately. +/// Returns an empty string if [dateTime] is null. +String formatRelativeTime(BuildContext context, DateTime? dateTime) { + if (dateTime == null) { + return ''; + } + final locale = Localizations.localeOf(context).languageCode; + return timeago.format(dateTime, locale: locale); +} diff --git a/lib/shared/utils/utils.dart b/lib/shared/utils/utils.dart new file mode 100644 index 00000000..46a14dda --- /dev/null +++ b/lib/shared/utils/utils.dart @@ -0,0 +1,4 @@ +/// Barrel file for shared utility functions. +library; + +export 'date_formatter.dart'; diff --git a/lib/shared/widgets/headline_tile_image_start.dart b/lib/shared/widgets/headline_tile_image_start.dart new file mode 100644 index 00000000..50e09e11 --- /dev/null +++ b/lib/shared/widgets/headline_tile_image_start.dart @@ -0,0 +1,247 @@ +import 'package:flutter/material.dart'; +import 'package:ht_main/l10n/l10n.dart'; +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; +// timeago import removed from here, handled by utility + +/// {@template headline_tile_image_start} +/// A shared widget to display a headline item with a small image at the start. +/// {@endtemplate} +class HeadlineTileImageStart extends StatelessWidget { + /// {@macro headline_tile_image_start} + const HeadlineTileImageStart({ + required this.headline, + super.key, + this.onHeadlineTap, + this.trailing, + }); + + /// The headline data to display. + final Headline headline; + + /// Callback when the main content of the headline (e.g., title area) is tapped. + final VoidCallback? onHeadlineTap; + + /// An optional widget to display at the end of the tile. + final Widget? trailing; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final colorScheme = theme.colorScheme; + + return Card( + margin: const EdgeInsets.symmetric( + horizontal: AppSpacing.paddingMedium, + vertical: AppSpacing.xs, + ), + child: InkWell( + onTap: onHeadlineTap, // Main tap for image + title area + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 72, // Standard small image size + 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, + ), + ), + ) + : 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 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + headline.title, + style: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: AppSpacing.sm), + _HeadlineMetadataRow( + headline: headline, + l10n: l10n, + colorScheme: colorScheme, + textTheme: textTheme, + ), + ], + ), + ), + if (trailing != null) ...[ + const SizedBox(width: AppSpacing.sm), + trailing!, + ], + ], + ), + ), + ), + ); + } +} + +/// Private helper widget to build the metadata row. +class _HeadlineMetadataRow extends StatelessWidget { + const _HeadlineMetadataRow({ + required this.headline, + required this.l10n, + required this.colorScheme, + required this.textTheme, + }); + + final Headline headline; + final AppLocalizations l10n; + final ColorScheme colorScheme; + final TextTheme textTheme; + + @override + Widget build(BuildContext context) { + final formattedDate = formatRelativeTime(context, headline.publishedAt); + + final metadataStyle = textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ); + final chipLabelStyle = textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ); + final chipBackgroundColor = colorScheme.surfaceContainerHighest.withOpacity( + 0.5, + ); + const iconSize = 12.0; // Kept for date icon + + return Wrap( + spacing: AppSpacing.sm, // Reduced spacing for more compactness + runSpacing: AppSpacing.xs, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (formattedDate.isNotEmpty) + GestureDetector( + onTap: () { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text('Tapped Date: $formattedDate')), + ); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.calendar_today_outlined, + size: iconSize, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: AppSpacing.xs), + Text(formattedDate, style: metadataStyle), + ], + ), + ), + if (headline.category?.name != null) ...[ + if (formattedDate.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xs / 2, + ), + child: Text('•', style: metadataStyle), + ), + GestureDetector( + onTap: () { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + 'Tapped Category: ${headline.category!.name}', + ), + ), + ); + }, + child: Chip( + label: Text(headline.category!.name), + labelStyle: chipLabelStyle, + backgroundColor: chipBackgroundColor, + padding: EdgeInsets.zero, // Changed + labelPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xs, + ), // Added + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ], + if (headline.source?.name != null) ...[ + if (formattedDate.isNotEmpty || headline.category?.name != null) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xs / 2, + ), + child: Text('•', style: metadataStyle), + ), + GestureDetector( + onTap: () { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text('Tapped Source: ${headline.source!.name}'), + ), + ); + }, + child: Chip( + label: Text(headline.source!.name), + labelStyle: chipLabelStyle, + backgroundColor: chipBackgroundColor, + padding: EdgeInsets.zero, // Changed + labelPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xs, + ), // Added + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ], + ], + ); + } +} diff --git a/lib/shared/widgets/headline_tile_image_top.dart b/lib/shared/widgets/headline_tile_image_top.dart new file mode 100644 index 00000000..3ee6560c --- /dev/null +++ b/lib/shared/widgets/headline_tile_image_top.dart @@ -0,0 +1,259 @@ +import 'package:flutter/material.dart'; +import 'package:ht_main/l10n/l10n.dart'; +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; +// timeago import removed from here, handled by utility + +/// {@template headline_tile_image_top} +/// A shared widget to display a headline item with a large image at the top. +/// {@endtemplate} +class HeadlineTileImageTop extends StatelessWidget { + /// {@macro headline_tile_image_top} + const HeadlineTileImageTop({ + required this.headline, + super.key, + this.onHeadlineTap, + this.trailing, + }); + + /// The headline data to display. + final Headline headline; + + /// Callback when the main content of the headline (e.g., title area) is tapped. + final VoidCallback? onHeadlineTap; + + /// An optional widget to display at the end of the tile (e.g., in line with title). + final Widget? trailing; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final colorScheme = theme.colorScheme; + + return Card( + margin: const EdgeInsets.symmetric( + horizontal: AppSpacing.paddingMedium, + vertical: AppSpacing.xs, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: onHeadlineTap, // Image area is part of the main tap area + child: ClipRRect( + borderRadius: const BorderRadius.only( + 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( + width: double.infinity, + height: 180, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.image_not_supported_outlined, + color: colorScheme.onSurfaceVariant, + size: AppSpacing.xxl, + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: InkWell( + onTap: onHeadlineTap, // Title is part of main tap area + child: Text( + headline.title, + style: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + ), + if (trailing != null) ...[ + const SizedBox(width: AppSpacing.sm), + trailing!, + ], + ], + ), + const SizedBox(height: AppSpacing.sm), + _HeadlineMetadataRow( + headline: headline, + l10n: l10n, + colorScheme: colorScheme, + textTheme: textTheme, + ), + ], + ), + ), + ], + ), + ); + } +} + +/// Private helper widget to build the metadata row. +class _HeadlineMetadataRow extends StatelessWidget { + const _HeadlineMetadataRow({ + required this.headline, + required this.l10n, + required this.colorScheme, + required this.textTheme, + }); + + final Headline headline; + final AppLocalizations l10n; + final ColorScheme colorScheme; + final TextTheme textTheme; + + @override + Widget build(BuildContext context) { + final formattedDate = formatRelativeTime(context, headline.publishedAt); + + final metadataStyle = textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ); + final chipLabelStyle = textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ); + final chipBackgroundColor = colorScheme.surfaceContainerHighest.withOpacity( + 0.5, + ); + const iconSize = 12.0; // Kept for date icon + + return Wrap( + spacing: AppSpacing.sm, // Reduced spacing for more compactness + runSpacing: AppSpacing.xs, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (formattedDate.isNotEmpty) + GestureDetector( + onTap: () { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text('Tapped Date: $formattedDate')), + ); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.calendar_today_outlined, + size: iconSize, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: AppSpacing.xs), + Text(formattedDate, style: metadataStyle), + ], + ), + ), + if (headline.category?.name != null) ...[ + if (formattedDate.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xs / 2, + ), + child: Text('•', style: metadataStyle), + ), + GestureDetector( + onTap: () { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + 'Tapped Category: ${headline.category!.name}', + ), + ), + ); + }, + child: Chip( + label: Text(headline.category!.name), + labelStyle: chipLabelStyle, + backgroundColor: chipBackgroundColor, + padding: EdgeInsets.zero, // Changed + labelPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xs, + ), // Added + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ], + if (headline.source?.name != null) ...[ + if (formattedDate.isNotEmpty || headline.category?.name != null) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xs / 2, + ), + child: Text('•', style: metadataStyle), + ), + GestureDetector( + onTap: () { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text('Tapped Source: ${headline.source!.name}'), + ), + ); + }, + child: Chip( + label: Text(headline.source!.name), + labelStyle: chipLabelStyle, + backgroundColor: chipBackgroundColor, + padding: EdgeInsets.zero, // Changed + labelPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xs, + ), // Added + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ], + ], + ); + } +} diff --git a/lib/shared/widgets/headline_tile_text_only.dart b/lib/shared/widgets/headline_tile_text_only.dart new file mode 100644 index 00000000..deda774f --- /dev/null +++ b/lib/shared/widgets/headline_tile_text_only.dart @@ -0,0 +1,207 @@ +import 'package:flutter/material.dart'; +import 'package:ht_main/l10n/l10n.dart'; +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; +// timeago import removed from here, handled by utility + +/// {@template headline_tile_text_only} +/// A widget to display a headline item with text only. +/// +/// Used in feeds, search results, etc., when the image style is set to hidden. +/// {@endtemplate} +class HeadlineTileTextOnly extends StatelessWidget { + /// {@macro headline_tile_text_only} + const HeadlineTileTextOnly({ + required this.headline, + super.key, + this.onHeadlineTap, + this.trailing, + }); + + /// The headline data to display. + final Headline headline; + + /// Callback when the main content of the headline (e.g., title) is tapped. + final VoidCallback? onHeadlineTap; + + /// An optional widget to display at the end of the tile. + final Widget? trailing; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final colorScheme = theme.colorScheme; + + return Card( + margin: const EdgeInsets.symmetric( + horizontal: AppSpacing.paddingMedium, + vertical: AppSpacing.xs, + ), + child: InkWell( + onTap: onHeadlineTap, + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + headline.title, + style: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + maxLines: 3, // Allow more lines for text-only + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: AppSpacing.sm), + _HeadlineMetadataRow( + headline: headline, + l10n: l10n, + colorScheme: colorScheme, + textTheme: textTheme, + ), + ], + ), + ), + if (trailing != null) ...[ + const SizedBox(width: AppSpacing.sm), + trailing!, + ], + ], + ), + ), + ), + ); + } +} + +/// Private helper widget to build the metadata row. +class _HeadlineMetadataRow extends StatelessWidget { + const _HeadlineMetadataRow({ + required this.headline, + required this.l10n, + required this.colorScheme, + required this.textTheme, + }); + + final Headline headline; + final AppLocalizations l10n; + final ColorScheme colorScheme; + final TextTheme textTheme; + + @override + Widget build(BuildContext context) { + final formattedDate = formatRelativeTime(context, headline.publishedAt); + + final metadataStyle = textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ); + final chipLabelStyle = textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ); + final chipBackgroundColor = colorScheme.surfaceContainerHighest.withOpacity( + 0.5, + ); + const iconSize = 12.0; // Kept for date icon + + return Wrap( + spacing: AppSpacing.sm, // Reduced spacing for more compactness + runSpacing: AppSpacing.xs, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (formattedDate.isNotEmpty) + GestureDetector( + onTap: () { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text('Tapped Date: $formattedDate')), + ); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.calendar_today_outlined, + size: iconSize, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: AppSpacing.xs), + Text(formattedDate, style: metadataStyle), + ], + ), + ), + if (headline.category?.name != null) ...[ + if (formattedDate.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xs / 2, + ), + child: Text('•', style: metadataStyle), + ), + GestureDetector( + onTap: () { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + 'Tapped Category: ${headline.category!.name}', + ), + ), + ); + }, + child: Chip( + label: Text(headline.category!.name), + labelStyle: chipLabelStyle, + backgroundColor: chipBackgroundColor, + padding: EdgeInsets.zero, // Changed + labelPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xs, + ), // Added + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ], + if (headline.source?.name != null) ...[ + if (formattedDate.isNotEmpty || headline.category?.name != null) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xs / 2, + ), + child: Text('•', style: metadataStyle), + ), + GestureDetector( + onTap: () { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text('Tapped Source: ${headline.source!.name}'), + ), + ); + }, + child: Chip( + label: Text(headline.source!.name), + labelStyle: chipLabelStyle, + backgroundColor: chipBackgroundColor, + padding: EdgeInsets.zero, // Changed + labelPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xs, + ), // Added + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ], + ], + ); + } +} diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart index 08c4d251..2548c8c7 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -5,3 +5,6 @@ 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'; diff --git a/pubspec.lock b/pubspec.lock index 0d77c075..dc69c282 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -900,6 +900,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.8" + timeago: + dependency: "direct main" + description: + name: timeago + sha256: b05159406a97e1cbb2b9ee4faa9fb096fe0e2dfcd8b08fcd2a00553450d3422e + url: "https://pub.dev" + source: hosted + version: "3.7.1" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ca2638d5..68381f13 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -54,6 +54,7 @@ dependencies: meta: ^1.16.0 share_plus: ^11.0.0 stream_transform: ^2.1.1 + timeago: ^3.7.1 url_launcher: ^6.3.1 dev_dependencies: