diff --git a/README.md b/README.md index 66d9b924..c4e9b937 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,10 @@ Developed with best practices for a maintainable and scalable codebase: * **GoRouter:** Well-structured and powerful navigation. * **Benefit for you:** An easy-to-understand, extendable, and testable foundation for your project. 📈 +#### ⚙️ **Flexible Environment Configuration** +Easily switch between development (in-memory data or local API) and production environments with a simple code change. This empowers rapid prototyping, robust testing, and seamless deployment. +* **Benefit for you:** Accelerate your development cycle and ensure your app is always ready for any deployment scenario. 🚀 + #### 🌍 **Localization Ready** Fully internationalized with working English and Arabic localizations (`.arb` files). Adding more languages is straightforward. * **Benefit for you:** Easily adapt your application for a global audience. 🌐 diff --git a/lib/account/bloc/account_bloc.dart b/lib/account/bloc/account_bloc.dart index 2100a401..f895ae78 100644 --- a/lib/account/bloc/account_bloc.dart +++ b/lib/account/bloc/account_bloc.dart @@ -13,13 +13,14 @@ class AccountBloc extends Bloc { AccountBloc({ required HtAuthRepository authenticationRepository, required HtDataRepository - userContentPreferencesRepository, - }) : _authenticationRepository = authenticationRepository, - _userContentPreferencesRepository = userContentPreferencesRepository, - super(const AccountState()) { + userContentPreferencesRepository, + }) : _authenticationRepository = authenticationRepository, + _userContentPreferencesRepository = userContentPreferencesRepository, + super(const AccountState()) { // Listen to user changes from HtAuthRepository - _userSubscription = - _authenticationRepository.authStateChanges.listen((user) { + _userSubscription = _authenticationRepository.authStateChanges.listen(( + user, + ) { add(AccountUserChanged(user)); }); @@ -35,7 +36,7 @@ class AccountBloc extends Bloc { final HtAuthRepository _authenticationRepository; final HtDataRepository - _userContentPreferencesRepository; + _userContentPreferencesRepository; late StreamSubscription _userSubscription; Future _onAccountUserChanged( @@ -47,7 +48,9 @@ class AccountBloc extends Bloc { add(AccountLoadUserPreferences(userId: event.user!.id)); } else { // Clear preferences if user is null (logged out) - emit(state.copyWith(clearPreferences: true, status: AccountStatus.initial)); + emit( + state.copyWith(clearPreferences: true, status: AccountStatus.initial), + ); } } @@ -113,8 +116,9 @@ class AccountBloc extends Bloc { emit(state.copyWith(status: AccountStatus.loading)); final currentPrefs = state.preferences!; - final isCurrentlySaved = - currentPrefs.savedHeadlines.any((h) => h.id == event.headline.id); + final isCurrentlySaved = currentPrefs.savedHeadlines.any( + (h) => h.id == event.headline.id, + ); final List updatedSavedHeadlines; if (isCurrentlySaved) { @@ -125,8 +129,9 @@ class AccountBloc extends Bloc { ..add(event.headline); } - final updatedPrefs = - currentPrefs.copyWith(savedHeadlines: updatedSavedHeadlines); + final updatedPrefs = currentPrefs.copyWith( + savedHeadlines: updatedSavedHeadlines, + ); try { await _userContentPreferencesRepository.update( @@ -163,8 +168,9 @@ class AccountBloc extends Bloc { emit(state.copyWith(status: AccountStatus.loading)); final currentPrefs = state.preferences!; - final isCurrentlyFollowed = currentPrefs.followedCategories - .any((c) => c.id == event.category.id); + final isCurrentlyFollowed = currentPrefs.followedCategories.any( + (c) => c.id == event.category.id, + ); final List updatedFollowedCategories; if (isCurrentlyFollowed) { @@ -175,8 +181,9 @@ class AccountBloc extends Bloc { ..add(event.category); } - final updatedPrefs = - currentPrefs.copyWith(followedCategories: updatedFollowedCategories); + final updatedPrefs = currentPrefs.copyWith( + followedCategories: updatedFollowedCategories, + ); try { await _userContentPreferencesRepository.update( @@ -213,8 +220,9 @@ class AccountBloc extends Bloc { emit(state.copyWith(status: AccountStatus.loading)); final currentPrefs = state.preferences!; - final isCurrentlyFollowed = - currentPrefs.followedSources.any((s) => s.id == event.source.id); + final isCurrentlyFollowed = currentPrefs.followedSources.any( + (s) => s.id == event.source.id, + ); final List updatedFollowedSources; if (isCurrentlyFollowed) { @@ -225,8 +233,9 @@ class AccountBloc extends Bloc { ..add(event.source); } - final updatedPrefs = - currentPrefs.copyWith(followedSources: updatedFollowedSources); + final updatedPrefs = currentPrefs.copyWith( + followedSources: updatedFollowedSources, + ); try { await _userContentPreferencesRepository.update( diff --git a/lib/account/bloc/account_event.dart b/lib/account/bloc/account_event.dart index 435ea886..c6132a28 100644 --- a/lib/account/bloc/account_event.dart +++ b/lib/account/bloc/account_event.dart @@ -7,7 +7,8 @@ abstract class AccountEvent extends Equatable { List get props => []; } -class AccountUserChanged extends AccountEvent { // Corrected name +class AccountUserChanged extends AccountEvent { + // Corrected name const AccountUserChanged(this.user); final User? user; @@ -15,7 +16,8 @@ class AccountUserChanged extends AccountEvent { // Corrected name List get props => [user]; } -class AccountLoadUserPreferences extends AccountEvent { // Corrected name +class AccountLoadUserPreferences extends AccountEvent { + // Corrected name const AccountLoadUserPreferences({required this.userId}); final String userId; diff --git a/lib/account/bloc/account_state.dart b/lib/account/bloc/account_state.dart index ea81052f..08f2b649 100644 --- a/lib/account/bloc/account_state.dart +++ b/lib/account/bloc/account_state.dart @@ -27,8 +27,7 @@ class AccountState extends Equatable { return AccountState( status: status ?? this.status, user: clearUser ? null : user ?? this.user, - preferences: - clearPreferences ? null : preferences ?? this.preferences, + preferences: clearPreferences ? null : preferences ?? this.preferences, errorMessage: clearErrorMessage ? null : errorMessage ?? this.errorMessage, ); diff --git a/lib/account/view/account_page.dart b/lib/account/view/account_page.dart index 27b5752e..cd9ef669 100644 --- a/lib/account/view/account_page.dart +++ b/lib/account/view/account_page.dart @@ -35,12 +35,17 @@ class AccountPage extends StatelessWidget { ), ), body: ListView( - padding: const EdgeInsets.all(AppSpacing.paddingMedium), // Adjusted padding + padding: const EdgeInsets.all( + AppSpacing.paddingMedium, + ), // Adjusted padding children: [ _buildUserHeader(context, user, isAnonymous), const SizedBox(height: AppSpacing.lg), // Adjusted spacing ListTile( - leading: Icon(Icons.tune_outlined, color: theme.colorScheme.primary), + leading: Icon( + Icons.tune_outlined, + color: theme.colorScheme.primary, + ), title: Text( l10n.accountContentPreferencesTile, style: textTheme.titleMedium, @@ -50,9 +55,15 @@ class AccountPage extends StatelessWidget { context.goNamed(Routes.manageFollowedItemsName); }, ), - const Divider(indent: AppSpacing.paddingMedium, endIndent: AppSpacing.paddingMedium), + const Divider( + indent: AppSpacing.paddingMedium, + endIndent: AppSpacing.paddingMedium, + ), ListTile( - leading: Icon(Icons.bookmark_outline, color: theme.colorScheme.primary), + leading: Icon( + Icons.bookmark_outline, + color: theme.colorScheme.primary, + ), title: Text( l10n.accountSavedHeadlinesTile, style: textTheme.titleMedium, @@ -62,9 +73,15 @@ class AccountPage extends StatelessWidget { context.goNamed(Routes.accountSavedHeadlinesName); }, ), - const Divider(indent: AppSpacing.paddingMedium, endIndent: AppSpacing.paddingMedium), + const Divider( + indent: AppSpacing.paddingMedium, + endIndent: AppSpacing.paddingMedium, + ), _buildSettingsTile(context), - const Divider(indent: AppSpacing.paddingMedium, endIndent: AppSpacing.paddingMedium), + const Divider( + indent: AppSpacing.paddingMedium, + endIndent: AppSpacing.paddingMedium, + ), ], ), ); @@ -89,12 +106,14 @@ class AccountPage extends StatelessWidget { displayName = l10n.accountAnonymousUser; statusWidget = Padding( padding: const EdgeInsets.only(top: AppSpacing.md), // Increased padding - child: ElevatedButton.icon( // Changed to ElevatedButton + child: ElevatedButton.icon( + // Changed to ElevatedButton icon: const Icon(Icons.link_outlined), label: Text(l10n.accountSignInPromptButton), style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.lg, vertical: AppSpacing.sm, + horizontal: AppSpacing.lg, + vertical: AppSpacing.sm, ), textStyle: textTheme.labelLarge, ), @@ -111,7 +130,8 @@ class AccountPage extends StatelessWidget { statusWidget = Column( mainAxisSize: MainAxisSize.min, // To keep column tight children: [ - if (user?.role != null) ...[ // Show role only if available + if (user?.role != null) ...[ + // Show role only if available const SizedBox(height: AppSpacing.xs), Text( l10n.accountRoleLabel(user!.role.name), @@ -122,21 +142,23 @@ class AccountPage extends StatelessWidget { ), ], const SizedBox(height: AppSpacing.md), // Consistent spacing - OutlinedButton.icon( // Changed to OutlinedButton.icon + OutlinedButton.icon( + // Changed to OutlinedButton.icon icon: Icon(Icons.logout, color: colorScheme.error), label: Text(l10n.accountSignOutTile), style: OutlinedButton.styleFrom( foregroundColor: colorScheme.error, side: BorderSide(color: colorScheme.error.withOpacity(0.5)), padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.lg, vertical: AppSpacing.sm, + horizontal: AppSpacing.lg, + vertical: AppSpacing.sm, ), textStyle: textTheme.labelLarge, ), onPressed: () { - context - .read() - .add(const AuthenticationSignOutRequested()); + context.read().add( + const AuthenticationSignOutRequested(), + ); }, ), ], diff --git a/lib/account/view/manage_followed_items/categories/add_category_to_follow_page.dart b/lib/account/view/manage_followed_items/categories/add_category_to_follow_page.dart index 44251617..e5bbbcdf 100644 --- a/lib/account/view/manage_followed_items/categories/add_category_to_follow_page.dart +++ b/lib/account/view/manage_followed_items/categories/add_category_to_follow_page.dart @@ -22,9 +22,10 @@ class AddCategoryToFollowPage extends StatelessWidget { final textTheme = theme.textTheme; // Get textTheme return BlocProvider( - create: (context) => CategoriesFilterBloc( - categoriesRepository: context.read>(), - )..add(CategoriesFilterRequested()), + create: + (context) => CategoriesFilterBloc( + categoriesRepository: context.read>(), + )..add(CategoriesFilterRequested()), child: Scaffold( appBar: AppBar( title: Text( @@ -35,7 +36,8 @@ class AddCategoryToFollowPage extends StatelessWidget { body: BlocBuilder( builder: (context, categoriesState) { if (categoriesState.status == CategoriesFilterStatus.loading && - categoriesState.categories.isEmpty) { // Show full loading only if list is empty + categoriesState.categories.isEmpty) { + // Show full loading only if list is empty return LoadingStateWidget( icon: Icons.category_outlined, headline: l10n.categoryFilterLoadingHeadline, @@ -43,7 +45,8 @@ class AddCategoryToFollowPage extends StatelessWidget { ); } if (categoriesState.status == CategoriesFilterStatus.failure && - categoriesState.categories.isEmpty) { // Show full error only if list is empty + categoriesState.categories.isEmpty) { + // Show full error only if list is empty var errorMessage = l10n.categoryFilterError; if (categoriesState.error is HtHttpException) { errorMessage = @@ -53,14 +56,17 @@ class AddCategoryToFollowPage extends StatelessWidget { } return FailureStateWidget( message: errorMessage, - onRetry: () => context - .read() - .add(CategoriesFilterRequested()), + onRetry: + () => context.read().add( + CategoriesFilterRequested(), + ), ); } if (categoriesState.categories.isEmpty && - categoriesState.status == CategoriesFilterStatus.success) { // Show empty only on success - return InitialStateWidget( // Use InitialStateWidget for empty + categoriesState.status == CategoriesFilterStatus.success) { + // Show empty only on success + return InitialStateWidget( + // Use InitialStateWidget for empty icon: Icons.search_off_outlined, headline: l10n.categoryFilterEmptyHeadline, subheadline: l10n.categoryFilterEmptySubheadline, @@ -69,22 +75,27 @@ class AddCategoryToFollowPage extends StatelessWidget { // Handle loading more at the bottom or list display final categories = categoriesState.categories; - final isLoadingMore = categoriesState.status == CategoriesFilterStatus.loadingMore; + final isLoadingMore = + categoriesState.status == CategoriesFilterStatus.loadingMore; return BlocBuilder( - buildWhen: (previous, current) => - previous.preferences?.followedCategories != - current.preferences?.followedCategories || - previous.status != current.status, + buildWhen: + (previous, current) => + previous.preferences?.followedCategories != + current.preferences?.followedCategories || + previous.status != current.status, builder: (context, accountState) { final followedCategories = accountState.preferences?.followedCategories ?? []; return ListView.builder( - padding: const EdgeInsets.symmetric( // Consistent padding + padding: const EdgeInsets.symmetric( + // Consistent padding horizontal: AppSpacing.paddingMedium, vertical: AppSpacing.paddingSmall, - ).copyWith(bottom: AppSpacing.xxl), // Ensure bottom space for loader + ).copyWith( + bottom: AppSpacing.xxl, + ), // Ensure bottom space for loader itemCount: categories.length + (isLoadingMore ? 1 : 0), itemBuilder: (context, index) { if (index == categories.length && isLoadingMore) { @@ -93,11 +104,14 @@ class AddCategoryToFollowPage extends StatelessWidget { child: Center(child: CircularProgressIndicator()), ); } - if (index >= categories.length) return const SizedBox.shrink(); + if (index >= categories.length) { + return const SizedBox.shrink(); + } final category = categories[index]; - final isFollowed = - followedCategories.any((fc) => fc.id == category.id); + final isFollowed = followedCategories.any( + (fc) => fc.id == category.id, + ); final colorScheme = Theme.of(context).colorScheme; return Card( @@ -105,60 +119,92 @@ class AddCategoryToFollowPage extends StatelessWidget { elevation: 0.5, // Subtle elevation shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppSpacing.sm), - side: BorderSide(color: colorScheme.outlineVariant.withOpacity(0.3)), + side: BorderSide( + color: colorScheme.outlineVariant.withOpacity(0.3), + ), ), child: ListTile( - leading: SizedBox( // Standardized leading icon/image size + leading: SizedBox( + // Standardized leading icon/image size width: AppSpacing.xl + AppSpacing.xs, // 36 height: AppSpacing.xl + AppSpacing.xs, - child: category.iconUrl != null && - Uri.tryParse(category.iconUrl!)?.isAbsolute == true - ? ClipRRect( - borderRadius: BorderRadius.circular(AppSpacing.xs), - child: Image.network( - category.iconUrl!, - fit: BoxFit.contain, - errorBuilder: (context, error, stackTrace) => - Icon( - Icons.category_outlined, - color: colorScheme.onSurfaceVariant, - size: AppSpacing.lg, + child: + category.iconUrl != null && + Uri.tryParse( + category.iconUrl!, + )?.isAbsolute == + true + ? ClipRRect( + borderRadius: BorderRadius.circular( + AppSpacing.xs, ), - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; - return Center( - child: CircularProgressIndicator( - strokeWidth: 2, - value: loadingProgress.expectedTotalBytes != null - ? loadingProgress.cumulativeBytesLoaded / - loadingProgress.expectedTotalBytes! - : null, - ), - ); - }, + child: Image.network( + category.iconUrl!, + fit: BoxFit.contain, + errorBuilder: + (context, error, stackTrace) => Icon( + Icons.category_outlined, + color: colorScheme.onSurfaceVariant, + size: AppSpacing.lg, + ), + loadingBuilder: ( + context, + child, + loadingProgress, + ) { + if (loadingProgress == null) { + return child; + } + return Center( + child: CircularProgressIndicator( + strokeWidth: 2, + value: + loadingProgress + .expectedTotalBytes != + null + ? loadingProgress + .cumulativeBytesLoaded / + loadingProgress + .expectedTotalBytes! + : null, + ), + ); + }, + ), + ) + : Icon( + Icons.category_outlined, + color: colorScheme.onSurfaceVariant, + size: AppSpacing.lg, ), - ) - : Icon( - Icons.category_outlined, - color: colorScheme.onSurfaceVariant, - size: AppSpacing.lg, - ), ), - title: Text(category.name, style: textTheme.titleMedium), + title: Text( + category.name, + style: textTheme.titleMedium, + ), trailing: IconButton( - icon: isFollowed - ? Icon(Icons.check_circle, color: colorScheme.primary) - : Icon(Icons.add_circle_outline, color: colorScheme.onSurfaceVariant), - tooltip: isFollowed - ? l10n.unfollowCategoryTooltip(category.name) - : l10n.followCategoryTooltip(category.name), + icon: + isFollowed + ? Icon( + Icons.check_circle, + color: colorScheme.primary, + ) + : Icon( + Icons.add_circle_outline, + color: colorScheme.onSurfaceVariant, + ), + tooltip: + isFollowed + ? l10n.unfollowCategoryTooltip(category.name) + : l10n.followCategoryTooltip(category.name), onPressed: () { context.read().add( - AccountFollowCategoryToggled(category: category), - ); + AccountFollowCategoryToggled(category: category), + ); }, ), - contentPadding: const EdgeInsets.symmetric( // Consistent padding + contentPadding: const EdgeInsets.symmetric( + // Consistent padding horizontal: AppSpacing.paddingMedium, vertical: AppSpacing.xs, ), 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 fb9a7d9b..f6958044 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 @@ -19,7 +19,7 @@ class FollowedCategoriesListPage extends StatelessWidget { final l10n = context.l10n; final followedCategories = context.watch().state.preferences?.followedCategories ?? - []; + []; return Scaffold( appBar: AppBar( @@ -48,14 +48,14 @@ class FollowedCategoriesListPage extends StatelessWidget { if (state.status == AccountStatus.failure && state.preferences == null) { return FailureStateWidget( - message: state.errorMessage ?? 'Could not load followed categories.', // Placeholder + message: + state.errorMessage ?? + 'Could not load followed categories.', // Placeholder onRetry: () { if (state.user?.id != null) { context.read().add( - AccountLoadUserPreferences( - userId: state.user!.id, - ), - ); + AccountLoadUserPreferences(userId: state.user!.id), + ); } }, ); @@ -65,7 +65,8 @@ class FollowedCategoriesListPage extends StatelessWidget { return const InitialStateWidget( icon: Icons.no_sim_outlined, // Placeholder icon headline: 'No Followed Categories', // Placeholder - subheadline: 'Start following categories to see them here.', // Placeholder + subheadline: + 'Start following categories to see them here.', // Placeholder ); } @@ -74,32 +75,38 @@ class FollowedCategoriesListPage extends StatelessWidget { itemBuilder: (context, index) { final category = followedCategories[index]; return ListTile( - leading: category.iconUrl != null - ? SizedBox( - width: 40, - height: 40, - child: Image.network( - category.iconUrl!, - errorBuilder: (context, error, stackTrace) => - const Icon(Icons.category_outlined), - ), - ) - : const Icon(Icons.category_outlined), + leading: + category.iconUrl != null + ? SizedBox( + width: 40, + height: 40, + child: Image.network( + category.iconUrl!, + errorBuilder: + (context, error, stackTrace) => + const Icon(Icons.category_outlined), + ), + ) + : const Icon(Icons.category_outlined), title: Text(category.name), - subtitle: category.description != null - ? Text( - category.description!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ) - : null, + subtitle: + category.description != null + ? Text( + category.description!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + : null, trailing: IconButton( - icon: const Icon(Icons.remove_circle_outline, color: Colors.red), + icon: const Icon( + Icons.remove_circle_outline, + color: Colors.red, + ), tooltip: 'Unfollow Category', // Placeholder onPressed: () { context.read().add( - AccountFollowCategoryToggled(category: category), - ); + AccountFollowCategoryToggled(category: category), + ); }, ), onTap: () { diff --git a/lib/account/view/manage_followed_items/manage_followed_items_page.dart b/lib/account/view/manage_followed_items/manage_followed_items_page.dart index 57e6305c..6b627385 100644 --- a/lib/account/view/manage_followed_items/manage_followed_items_page.dart +++ b/lib/account/view/manage_followed_items/manage_followed_items_page.dart @@ -27,7 +27,9 @@ class ManageFollowedItemsPage extends StatelessWidget { ), ), body: ListView( - padding: const EdgeInsets.symmetric(vertical: AppSpacing.paddingSmall), // Adjusted padding + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.paddingSmall, + ), // Adjusted padding children: [ ListTile( leading: Icon(Icons.category_outlined, color: colorScheme.primary), 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 2a7fc198..3078edf1 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 @@ -47,14 +47,17 @@ class FollowedSourcesListPage extends StatelessWidget { if (state.status == AccountStatus.failure && state.preferences == null) { return FailureStateWidget( - message: state.errorMessage ?? 'Could not load followed sources.', // Placeholder + message: + state.errorMessage ?? + 'Could not load followed sources.', // Placeholder onRetry: () { if (state.user?.id != null) { context.read().add( - AccountLoadUserPreferences( // Corrected event name - userId: state.user!.id, - ), - ); + AccountLoadUserPreferences( + // Corrected event name + userId: state.user!.id, + ), + ); } }, ); @@ -64,7 +67,8 @@ class FollowedSourcesListPage extends StatelessWidget { return const InitialStateWidget( icon: Icons.no_sim_outlined, // Placeholder icon headline: 'No Followed Sources', // Placeholder - subheadline: 'Start following sources to see them here.', // Placeholder + subheadline: + 'Start following sources to see them here.', // Placeholder ); } @@ -75,20 +79,24 @@ class FollowedSourcesListPage extends StatelessWidget { return ListTile( leading: const Icon(Icons.source_outlined), // Generic icon title: Text(source.name), - subtitle: source.description != null - ? Text( - source.description!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ) - : null, + subtitle: + source.description != null + ? Text( + source.description!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + : null, trailing: IconButton( - icon: const Icon(Icons.remove_circle_outline, color: Colors.red), + icon: const Icon( + Icons.remove_circle_outline, + color: Colors.red, + ), tooltip: 'Unfollow Source', // Placeholder onPressed: () { context.read().add( - AccountFollowSourceToggled(source: source), - ); + AccountFollowSourceToggled(source: source), + ); }, ), onTap: () { diff --git a/lib/account/view/saved_headlines_page.dart b/lib/account/view/saved_headlines_page.dart index 32dbdef5..873e7ebb 100644 --- a/lib/account/view/saved_headlines_page.dart +++ b/lib/account/view/saved_headlines_page.dart @@ -36,7 +36,8 @@ class SavedHeadlinesPage extends StatelessWidget { ), body: BlocBuilder( builder: (context, state) { - if (state.status == AccountStatus.loading && state.preferences == null) { + if (state.status == AccountStatus.loading && + state.preferences == null) { return LoadingStateWidget( icon: Icons.bookmarks_outlined, headline: l10n.savedHeadlinesLoadingHeadline, // Use l10n @@ -44,16 +45,17 @@ class SavedHeadlinesPage extends StatelessWidget { ); } - if (state.status == AccountStatus.failure && state.preferences == null) { + if (state.status == AccountStatus.failure && + state.preferences == null) { return FailureStateWidget( - message: state.errorMessage ?? l10n.savedHeadlinesErrorHeadline, // Use l10n + message: + state.errorMessage ?? + l10n.savedHeadlinesErrorHeadline, // Use l10n onRetry: () { if (state.user?.id != null) { context.read().add( - AccountLoadUserPreferences( - userId: state.user!.id, - ), - ); + AccountLoadUserPreferences(userId: state.user!.id), + ); } }, ); @@ -70,29 +72,36 @@ class SavedHeadlinesPage extends StatelessWidget { } return ListView.separated( - padding: const EdgeInsets.symmetric(vertical: AppSpacing.paddingSmall), // Add padding + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.paddingSmall, + ), // Add padding itemCount: savedHeadlines.length, - separatorBuilder: (context, index) => const Divider( - height: 1, - indent: AppSpacing.paddingMedium, // Indent divider - endIndent: AppSpacing.paddingMedium, - ), + separatorBuilder: + (context, index) => const Divider( + height: 1, + indent: AppSpacing.paddingMedium, // Indent divider + endIndent: AppSpacing.paddingMedium, + ), itemBuilder: (context, index) { final headline = savedHeadlines[index]; - final imageStyle = context - .watch() - .state - .settings - .feedPreferences - .headlineImageStyle; + final imageStyle = + context + .watch() + .state + .settings + .feedPreferences + .headlineImageStyle; final trailingButton = IconButton( - icon: Icon(Icons.delete_outline, color: colorScheme.error), // Themed icon + icon: Icon( + Icons.delete_outline, + color: colorScheme.error, + ), // Themed icon tooltip: l10n.headlineDetailsRemoveFromSavedTooltip, onPressed: () { context.read().add( - AccountSaveHeadlineToggled(headline: headline), - ); + AccountSaveHeadlineToggled(headline: headline), + ); }, ); diff --git a/lib/app/bloc/app_bloc.dart b/lib/app/bloc/app_bloc.dart index 59a11508..9a260ca2 100644 --- a/lib/app/bloc/app_bloc.dart +++ b/lib/app/bloc/app_bloc.dart @@ -16,19 +16,19 @@ class AppBloc extends Bloc { required HtAuthRepository authenticationRepository, required HtDataRepository userAppSettingsRepository, required HtDataRepository appConfigRepository, // Added - }) : _authenticationRepository = authenticationRepository, - _userAppSettingsRepository = userAppSettingsRepository, - _appConfigRepository = appConfigRepository, // Added - // Initialize with default state, load settings after user is known - // Provide a default UserAppSettings instance - super( - // AppConfig will be null initially, fetched later - const AppState( - settings: UserAppSettings(id: 'default'), - selectedBottomNavigationIndex: 0, - appConfig: null, - ), - ) { + }) : _authenticationRepository = authenticationRepository, + _userAppSettingsRepository = userAppSettingsRepository, + _appConfigRepository = appConfigRepository, // Added + // Initialize with default state, load settings after user is known + // Provide a default UserAppSettings instance + super( + // AppConfig will be null initially, fetched later + const AppState( + settings: UserAppSettings(id: 'default'), + selectedBottomNavigationIndex: 0, + appConfig: null, + ), + ) { on(_onAppUserChanged); on(_onAppSettingsRefreshed); on(_onAppConfigFetchRequested); @@ -79,7 +79,13 @@ class AppBloc extends Bloc { // Clear appConfig if user is logged out, as it might be tied to auth context // or simply to ensure fresh fetch on next login. // Also ensure status is unauthenticated. - emit(state.copyWith(appConfig: null, clearAppConfig: true, status: AppStatus.unauthenticated)); + emit( + state.copyWith( + appConfig: null, + clearAppConfig: true, + status: AppStatus.unauthenticated, + ), + ); } } @@ -306,41 +312,81 @@ class AppBloc extends Bloc { ) async { // Guard: Only fetch if a user (authenticated or anonymous) is present. if (state.user == null) { - print('[AppBloc] User is null. Skipping AppConfig fetch because it requires authentication.'); + print( + '[AppBloc] User is null. Skipping AppConfig fetch because it requires authentication.', + ); // If AppConfig was somehow present without a user, clear it. // And ensure status isn't stuck on configFetching if this event was dispatched erroneously. if (state.appConfig != null || state.status == AppStatus.configFetching) { - emit(state.copyWith(appConfig: null, clearAppConfig: true, status: AppStatus.unauthenticated)); + emit( + state.copyWith( + appConfig: null, + clearAppConfig: true, + status: AppStatus.unauthenticated, + ), + ); } return; } // Avoid refetching if already loaded for the current user session, unless explicitly trying to recover from a failed state. - if (state.appConfig != null && state.status != AppStatus.configFetchFailed) { - print('[AppBloc] AppConfig already loaded for user ${state.user?.id} and not in a failed state. Skipping fetch.'); + if (state.appConfig != null && + state.status != AppStatus.configFetchFailed) { + print( + '[AppBloc] AppConfig already loaded for user ${state.user?.id} and not in a failed state. Skipping fetch.', + ); return; } - print('[AppBloc] Attempting to fetch AppConfig for user: ${state.user!.id}...'); - emit(state.copyWith(status: AppStatus.configFetching, appConfig: null, clearAppConfig: true)); + print( + '[AppBloc] Attempting to fetch AppConfig for user: ${state.user!.id}...', + ); + emit( + state.copyWith( + status: AppStatus.configFetching, + appConfig: null, + clearAppConfig: true, + ), + ); try { - final appConfig = await _appConfigRepository.read(id: 'app_config'); // API requires auth, so token will be used - print('[AppBloc] AppConfig fetched successfully. ID: ${appConfig.id} for user: ${state.user!.id}'); - + final appConfig = await _appConfigRepository.read( + id: 'app_config', + ); // API requires auth, so token will be used + print( + '[AppBloc] AppConfig fetched successfully. ID: ${appConfig.id} for user: ${state.user!.id}', + ); + // Determine the correct status based on the existing user's role. // This ensures that successfully fetching config doesn't revert auth status to 'initial'. - final newStatusBasedOnUser = state.user!.role == UserRole.standardUser - ? AppStatus.authenticated - : AppStatus.anonymous; + final newStatusBasedOnUser = + state.user!.role == UserRole.standardUser + ? AppStatus.authenticated + : AppStatus.anonymous; emit(state.copyWith(appConfig: appConfig, status: newStatusBasedOnUser)); } on HtHttpException catch (e) { - print('[AppBloc] Failed to fetch AppConfig (HtHttpException) for user ${state.user?.id}: ${e.runtimeType} - ${e.message}'); - emit(state.copyWith(status: AppStatus.configFetchFailed, appConfig: null, clearAppConfig: true)); + print( + '[AppBloc] Failed to fetch AppConfig (HtHttpException) for user ${state.user?.id}: ${e.runtimeType} - ${e.message}', + ); + emit( + state.copyWith( + status: AppStatus.configFetchFailed, + appConfig: null, + clearAppConfig: true, + ), + ); } catch (e, s) { - print('[AppBloc] Unexpected error fetching AppConfig for user ${state.user?.id}: $e'); + print( + '[AppBloc] Unexpected error fetching AppConfig for user ${state.user?.id}: $e', + ); print('[AppBloc] Stacktrace: $s'); - emit(state.copyWith(status: AppStatus.configFetchFailed, appConfig: null, clearAppConfig: true)); + emit( + state.copyWith( + status: AppStatus.configFetchFailed, + appConfig: null, + clearAppConfig: true, + ), + ); } } @@ -352,8 +398,8 @@ class AppBloc extends Bloc { final now = DateTime.now(); // Optimistically update the local user state. // Corrected parameter name for copyWith as per User model in models.txt - final updatedUser = state.user!.copyWith(lastEngagementShownAt: now); - + final updatedUser = state.user!.copyWith(lastEngagementShownAt: now); + // Emit the change so UI can react if needed, and other BLoCs get the update. // This also ensures that FeedInjectorService will see the updated timestamp immediately. emit(state.copyWith(user: updatedUser)); diff --git a/lib/app/config/app_config.dart b/lib/app/config/app_config.dart new file mode 100644 index 00000000..2296d2a2 --- /dev/null +++ b/lib/app/config/app_config.dart @@ -0,0 +1,31 @@ +import 'package:ht_main/app/config/app_environment.dart'; + +class AppConfig { + const AppConfig({ + required this.environment, + required this.baseUrl, + // Add other environment-specific configs here (e.g., analytics keys) + }); + + // Factory constructors for different environments + factory AppConfig.production() => const AppConfig( + environment: AppEnvironment.production, + baseUrl: + 'http://api.yourproductiondomain.com', // Replace with actual production URL + ); + + factory AppConfig.developmentInMemory() => const AppConfig( + environment: AppEnvironment.developmentInMemory, + baseUrl: + 'http://localhost:8080', // Base URL still needed for Auth API client, even if data is in-memory + ); + + factory AppConfig.developmentApi() => const AppConfig( + // New: For local Dart Frog API + environment: AppEnvironment.developmentApi, + baseUrl: 'http://localhost:8080', // Default Dart Frog local URL + ); + + final AppEnvironment environment; + final String baseUrl; +} diff --git a/lib/app/config/app_environment.dart b/lib/app/config/app_environment.dart new file mode 100644 index 00000000..2a98bc36 --- /dev/null +++ b/lib/app/config/app_environment.dart @@ -0,0 +1,6 @@ +enum AppEnvironment { + production, + developmentInMemory, + developmentApi, // New: For local Dart Frog API + // Add other environments like staging, etc. as needed +} diff --git a/lib/app/config/config.dart b/lib/app/config/config.dart new file mode 100644 index 00000000..6195435a --- /dev/null +++ b/lib/app/config/config.dart @@ -0,0 +1,2 @@ +export 'app_config.dart'; +export 'app_environment.dart'; diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index b28f4fa0..0bc88c92 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -197,16 +197,20 @@ class _AppViewState extends State<_AppView> { appFontWeight: AppFontWeight.regular, // Default fontFamily: null, // System default font ), - themeMode: state.themeMode, // Still respect light/dark if available from system + themeMode: + state + .themeMode, // Still respect light/dark if available from system localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( - body: Builder( // Use Builder to get context under MaterialApp + body: Builder( + // Use Builder to get context under MaterialApp builder: (innerContext) { final l10n = innerContext.l10n; return LoadingStateWidget( icon: Icons.settings_applications_outlined, - headline: l10n.headlinesFeedLoadingHeadline, // "Loading..." + headline: + l10n.headlinesFeedLoadingHeadline, // "Loading..." subheadline: l10n.pleaseWait, // "Please wait..." ); }, @@ -234,17 +238,19 @@ class _AppViewState extends State<_AppView> { localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( - body: Builder( // Use Builder to get context under MaterialApp + body: Builder( + // Use Builder to get context under MaterialApp builder: (innerContext) { final l10n = innerContext.l10n; return FailureStateWidget( - message: l10n.unknownError, // "An unknown error occurred." + message: + l10n.unknownError, // "An unknown error occurred." retryButtonText: 'Retry', // Hardcoded for now onRetry: () { // Use outer context for BLoC access - context - .read() - .add(const AppConfigFetchRequested()); + context.read().add( + const AppConfigFetchRequested(), + ); }, ); }, @@ -252,12 +258,12 @@ class _AppViewState extends State<_AppView> { ), ); } - + // If config is loaded (or not in a failed/fetching state for config), proceed with main app UI // It's safe to access l10n here if needed for print statements, // as this path implies we are about to build the main MaterialApp.router // which provides localizations. - // final l10n = context.l10n; + // final l10n = context.l10n; print('[_AppViewState] Building MaterialApp.router'); print('[_AppViewState] state.fontFamily: ${state.fontFamily}'); print( diff --git a/lib/authentication/bloc/authentication_bloc.dart b/lib/authentication/bloc/authentication_bloc.dart index fa37c328..531b2cf8 100644 --- a/lib/authentication/bloc/authentication_bloc.dart +++ b/lib/authentication/bloc/authentication_bloc.dart @@ -84,7 +84,10 @@ class AuthenticationBloc emit(AuthenticationFailure('Operation failed: ${e.message}')); } on HtHttpException catch (e) { // Catch any other HtHttpException subtypes - final message = e.message.isNotEmpty ? e.message : 'An unspecified HTTP error occurred.'; + final message = + e.message.isNotEmpty + ? e.message + : 'An unspecified HTTP error occurred.'; emit(AuthenticationFailure('HTTP error: $message')); } catch (e) { // Catch any other unexpected errors diff --git a/lib/authentication/view/authentication_page.dart b/lib/authentication/view/authentication_page.dart index e9ec5ad4..ef0edb4e 100644 --- a/lib/authentication/view/authentication_page.dart +++ b/lib/authentication/view/authentication_page.dart @@ -115,7 +115,9 @@ class AuthenticationPage extends StatelessWidget { ), textAlign: TextAlign.center, ), - const SizedBox(height: AppSpacing.md), // Increased spacing + const SizedBox( + height: AppSpacing.md, + ), // Increased spacing Text( subHeadline, style: textTheme.bodyLarge?.copyWith( @@ -128,14 +130,15 @@ class AuthenticationPage extends StatelessWidget { // --- Email Sign-In Button --- ElevatedButton.icon( icon: const Icon(Icons.email_outlined), - onPressed: isLoading - ? null - : () { - context.goNamed( - Routes.requestCodeName, - extra: isLinkingContext, - ); - }, + onPressed: + isLoading + ? null + : () { + context.goNamed( + Routes.requestCodeName, + extra: isLinkingContext, + ); + }, label: Text(l10n.authenticationEmailSignInButton), style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric( @@ -150,9 +153,10 @@ class AuthenticationPage extends StatelessWidget { if (showAnonymousButton) ...[ OutlinedButton.icon( icon: const Icon(Icons.person_outline), - onPressed: isLoading - ? null - : () => context.read().add( + onPressed: + isLoading + ? null + : () => context.read().add( const AuthenticationAnonymousSignInRequested(), ), label: Text(l10n.authenticationAnonymousSignInButton), @@ -166,7 +170,8 @@ class AuthenticationPage extends StatelessWidget { ], // --- Loading Indicator --- - if (isLoading && state is! AuthenticationRequestCodeLoading) ...[ + if (isLoading && + state is! AuthenticationRequestCodeLoading) ...[ const Padding( padding: EdgeInsets.only(top: AppSpacing.xl), child: Center(child: CircularProgressIndicator()), diff --git a/lib/authentication/view/email_code_verification_page.dart b/lib/authentication/view/email_code_verification_page.dart index cce53b81..447c5405 100644 --- a/lib/authentication/view/email_code_verification_page.dart +++ b/lib/authentication/view/email_code_verification_page.dart @@ -48,7 +48,8 @@ class EmailCodeVerificationPage extends StatelessWidget { child: SingleChildScrollView( child: Column( mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, // Stretch buttons + crossAxisAlignment: + CrossAxisAlignment.stretch, // Stretch buttons children: [ Icon( Icons.mark_email_read_outlined, @@ -67,11 +68,13 @@ class EmailCodeVerificationPage extends StatelessWidget { Text( l10n.emailCodeSentInstructions, style: textTheme.bodyLarge?.copyWith( - color: colorScheme - .onSurfaceVariant,), // Softer color + color: colorScheme.onSurfaceVariant, + ), // Softer color textAlign: TextAlign.center, ), - const SizedBox(height: AppSpacing.xl), // Increased spacing + const SizedBox( + height: AppSpacing.xl, + ), // Increased spacing _EmailCodeVerificationForm( email: email, isLoading: isLoading, @@ -173,16 +176,18 @@ class _EmailCodeVerificationFormState textStyle: textTheme.labelLarge, ), onPressed: widget.isLoading ? null : _submitForm, - child: widget.isLoading - ? const SizedBox( - height: AppSpacing.xl, // Consistent size with text - width: AppSpacing.xl, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, // Explicit color for loader on button - ), - ) - : Text(l10n.emailCodeVerificationButtonLabel), + child: + widget.isLoading + ? const SizedBox( + height: AppSpacing.xl, // Consistent size with text + width: AppSpacing.xl, + child: CircularProgressIndicator( + strokeWidth: 2, + color: + Colors.white, // Explicit color for loader on button + ), + ) + : Text(l10n.emailCodeVerificationButtonLabel), ), ], ), diff --git a/lib/authentication/view/request_code_page.dart b/lib/authentication/view/request_code_page.dart index b35d1072..18548184 100644 --- a/lib/authentication/view/request_code_page.dart +++ b/lib/authentication/view/request_code_page.dart @@ -128,8 +128,9 @@ class _RequestCodeView extends StatelessWidget { const SizedBox(height: AppSpacing.md), Text( l10n.requestCodePageSubheadline, // Using a more descriptive subheadline - style: textTheme.bodyLarge - ?.copyWith(color: colorScheme.onSurfaceVariant), + style: textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + ), textAlign: TextAlign.center, ), const SizedBox(height: AppSpacing.xxl), @@ -215,16 +216,20 @@ class _EmailLinkFormState extends State<_EmailLinkForm> { padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), textStyle: textTheme.labelLarge, ), - child: widget.isLoading - ? SizedBox( - height: AppSpacing.xl, // Consistent size with text - width: AppSpacing.xl, - child: CircularProgressIndicator( - strokeWidth: 2, - color: colorScheme.onPrimary, // Color for loader on button - ), - ) - : Text(l10n.requestCodeSendCodeButton), // More specific button text + child: + widget.isLoading + ? SizedBox( + height: AppSpacing.xl, // Consistent size with text + width: AppSpacing.xl, + child: CircularProgressIndicator( + strokeWidth: 2, + color: + colorScheme.onPrimary, // Color for loader on button + ), + ) + : Text( + l10n.requestCodeSendCodeButton, + ), // More specific button text ), ], ), diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart new file mode 100644 index 00000000..f738353f --- /dev/null +++ b/lib/bootstrap.dart @@ -0,0 +1,224 @@ +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:ht_auth_api/ht_auth_api.dart'; +import 'package:ht_auth_repository/ht_auth_repository.dart'; +import 'package:ht_data_api/ht_data_api.dart'; +import 'package:ht_data_client/ht_data_client.dart'; +import 'package:ht_data_inmemory/ht_data_inmemory.dart'; +import 'package:ht_data_repository/ht_data_repository.dart'; +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/app/config/config.dart' as app_config; +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'; +import 'package:ht_shared/ht_shared.dart'; // Keep this import as is for other shared models +import 'package:timeago/timeago.dart' as timeago; + +Future bootstrap(app_config.AppConfig appConfig) async { + // Use prefixed AppConfig here + WidgetsFlutterBinding.ensureInitialized(); + Bloc.observer = const AppBlocObserver(); + + timeago.setLocaleMessages('en', EnTimeagoMessages()); + timeago.setLocaleMessages('ar', ArTimeagoMessages()); + + final kvStorage = await HtKvStorageSharedPreferences.getInstance(); + + late final HtAuthRepository authenticationRepository; + + Future tokenProvider() async { + return authenticationRepository.getAuthToken(); + } + + final httpClient = HtHttpClient( + baseUrl: appConfig.baseUrl, + tokenProvider: tokenProvider, + isWeb: kIsWeb, + ); + + final authClient = HtAuthApi(httpClient: httpClient); + authenticationRepository = HtAuthRepository( + authClient: authClient, + storageService: kvStorage, + ); + + // Conditional data client instantiation based on environment + HtDataClient headlinesClient; + HtDataClient categoriesClient; + HtDataClient countriesClient; + HtDataClient sourcesClient; + HtDataClient userContentPreferencesClient; + HtDataClient userAppSettingsClient; + HtDataClient + appConfigClient; // This AppConfig refers to the shared model + + if (appConfig.environment == app_config.AppEnvironment.developmentInMemory) { + // Use prefixed AppEnvironment + headlinesClient = HtDataInMemoryClient( + toJson: (i) => i.toJson(), + getId: (i) => i.id, + initialData: headlinesFixturesData.map(Headline.fromJson).toList(), + ); + categoriesClient = HtDataInMemoryClient( + toJson: (i) => i.toJson(), + getId: (i) => i.id, + initialData: categoriesFixturesData.map(Category.fromJson).toList(), + ); + countriesClient = HtDataInMemoryClient( + toJson: (i) => i.toJson(), + getId: (i) => i.id, + initialData: countriesFixturesData.map(Country.fromJson).toList(), + ); + sourcesClient = HtDataInMemoryClient( + toJson: (i) => i.toJson(), + getId: (i) => i.id, + initialData: sourcesFixturesData.map(Source.fromJson).toList(), + ); + userContentPreferencesClient = HtDataInMemoryClient( + toJson: (i) => i.toJson(), + getId: (i) => i.id, + ); + userAppSettingsClient = HtDataInMemoryClient( + toJson: (i) => i.toJson(), + getId: (i) => i.id, + ); + appConfigClient = HtDataInMemoryClient( + // This AppConfig refers to the shared model + toJson: (i) => i.toJson(), + getId: (i) => i.id, + initialData: [ + AppConfig.fromJson(appConfigFixtureData), + ], // This AppConfig refers to the shared model + ); + } else if (appConfig.environment == + app_config.AppEnvironment.developmentApi) { + // Use prefixed AppEnvironment + headlinesClient = HtDataApi( + httpClient: httpClient, + modelName: 'headline', + fromJson: Headline.fromJson, + toJson: (headline) => headline.toJson(), + ); + categoriesClient = HtDataApi( + httpClient: httpClient, + modelName: 'category', + fromJson: Category.fromJson, + toJson: (category) => category.toJson(), + ); + countriesClient = HtDataApi( + httpClient: httpClient, + modelName: 'country', + fromJson: Country.fromJson, + toJson: (country) => country.toJson(), + ); + sourcesClient = HtDataApi( + httpClient: httpClient, + modelName: 'source', + fromJson: Source.fromJson, + toJson: (source) => source.toJson(), + ); + userContentPreferencesClient = HtDataApi( + httpClient: httpClient, + modelName: 'user_content_preferences', + fromJson: UserContentPreferences.fromJson, + toJson: (prefs) => prefs.toJson(), + ); + userAppSettingsClient = HtDataApi( + httpClient: httpClient, + modelName: 'user_app_settings', + fromJson: UserAppSettings.fromJson, + toJson: (settings) => settings.toJson(), + ); + appConfigClient = HtDataApi( + // This AppConfig refers to the shared model + httpClient: httpClient, + modelName: 'app_config', + fromJson: AppConfig.fromJson, // This AppConfig refers to the shared model + toJson: (config) => config.toJson(), + ); + } else { + // Default to API clients for production + headlinesClient = HtDataApi( + httpClient: httpClient, + modelName: 'headline', + fromJson: Headline.fromJson, + toJson: (headline) => headline.toJson(), + ); + categoriesClient = HtDataApi( + httpClient: httpClient, + modelName: 'category', + fromJson: Category.fromJson, + toJson: (category) => category.toJson(), + ); + countriesClient = HtDataApi( + httpClient: httpClient, + modelName: 'country', + fromJson: Country.fromJson, + toJson: (country) => country.toJson(), + ); + sourcesClient = HtDataApi( + httpClient: httpClient, + modelName: 'source', + fromJson: Source.fromJson, + toJson: (source) => source.toJson(), + ); + userContentPreferencesClient = HtDataApi( + httpClient: httpClient, + modelName: 'user_content_preferences', + fromJson: UserContentPreferences.fromJson, + toJson: (prefs) => prefs.toJson(), + ); + userAppSettingsClient = HtDataApi( + httpClient: httpClient, + modelName: 'user_app_settings', + fromJson: UserAppSettings.fromJson, + toJson: (settings) => settings.toJson(), + ); + appConfigClient = HtDataApi( + // This AppConfig refers to the shared model + httpClient: httpClient, + modelName: 'app_config', + fromJson: AppConfig.fromJson, // This AppConfig refers to the shared model + toJson: (config) => config.toJson(), + ); + } + + final headlinesRepository = HtDataRepository( + dataClient: headlinesClient, + ); + final categoriesRepository = HtDataRepository( + dataClient: categoriesClient, + ); + final countriesRepository = HtDataRepository( + dataClient: countriesClient, + ); + final sourcesRepository = HtDataRepository(dataClient: sourcesClient); + final userContentPreferencesRepository = + HtDataRepository( + dataClient: userContentPreferencesClient, + ); + final userAppSettingsRepository = HtDataRepository( + dataClient: userAppSettingsClient, + ); + final appConfigRepository = HtDataRepository( + // This AppConfig refers to the shared model + dataClient: appConfigClient, + ); + + runApp( + App( + htAuthenticationRepository: authenticationRepository, + htHeadlinesRepository: headlinesRepository, + htCategoriesRepository: categoriesRepository, + htCountriesRepository: countriesRepository, + htSourcesRepository: sourcesRepository, + htUserAppSettingsRepository: userAppSettingsRepository, + htUserContentPreferencesRepository: userContentPreferencesRepository, + htAppConfigRepository: appConfigRepository, + kvStorageService: kvStorage, + ), + ); +} diff --git a/lib/entity_details/bloc/entity_details_bloc.dart b/lib/entity_details/bloc/entity_details_bloc.dart index ff3ca37d..e9ea1461 100644 --- a/lib/entity_details/bloc/entity_details_bloc.dart +++ b/lib/entity_details/bloc/entity_details_bloc.dart @@ -20,13 +20,13 @@ class EntityDetailsBloc extends Bloc { required AccountBloc accountBloc, required AppBloc appBloc, // Added required FeedInjectorService feedInjectorService, // Added - }) : _headlinesRepository = headlinesRepository, - _categoryRepository = categoryRepository, - _sourceRepository = sourceRepository, - _accountBloc = accountBloc, - _appBloc = appBloc, // Added - _feedInjectorService = feedInjectorService, // Added - super(const EntityDetailsState()) { + }) : _headlinesRepository = headlinesRepository, + _categoryRepository = categoryRepository, + _sourceRepository = sourceRepository, + _accountBloc = accountBloc, + _appBloc = appBloc, // Added + _feedInjectorService = feedInjectorService, // Added + super(const EntityDetailsState()) { on(_onEntityDetailsLoadRequested); on( _onEntityDetailsToggleFollowRequested, @@ -163,7 +163,8 @@ class EntityDetailsBloc extends Bloc { isFollowing: isCurrentlyFollowing, feedItems: processedFeedItems, // Changed headlinesStatus: EntityHeadlinesStatus.success, - hasMoreHeadlines: headlineResponse.hasMore, // Based on original headlines + hasMoreHeadlines: + headlineResponse.hasMore, // Based on original headlines headlinesCursor: headlineResponse.cursor, clearErrorMessage: true, ), @@ -172,7 +173,9 @@ class EntityDetailsBloc extends Bloc { // Dispatch event if AccountAction was injected in the initial load if (processedFeedItems.any((item) => item is AccountAction) && _appBloc.state.user?.id != null) { - _appBloc.add(AppUserAccountActionShown(userId: _appBloc.state.user!.id)); + _appBloc.add( + AppUserAccountActionShown(userId: _appBloc.state.user!.id), + ); } } on HtHttpException catch (e) { emit( @@ -239,7 +242,8 @@ class EntityDetailsBloc extends Bloc { EntityDetailsLoadMoreHeadlinesRequested event, Emitter emit, ) async { - if (!state.hasMoreHeadlines || // Still refers to original headlines pagination + if (!state + .hasMoreHeadlines || // Still refers to original headlines pagination state.headlinesStatus == EntityHeadlinesStatus.loadingMore) { return; } @@ -281,7 +285,7 @@ class EntityDetailsBloc extends Bloc { ); return; } - + final newProcessedFeedItems = _feedInjectorService.injectItems( headlines: headlineResponse.items, user: currentUser, @@ -291,9 +295,11 @@ class EntityDetailsBloc extends Bloc { emit( state.copyWith( - feedItems: List.of(state.feedItems)..addAll(newProcessedFeedItems), // Changed + feedItems: List.of(state.feedItems) + ..addAll(newProcessedFeedItems), // Changed headlinesStatus: EntityHeadlinesStatus.success, - hasMoreHeadlines: headlineResponse.hasMore, // Based on original headlines + hasMoreHeadlines: + headlineResponse.hasMore, // Based on original headlines headlinesCursor: headlineResponse.cursor, clearHeadlinesCursor: !headlineResponse.hasMore, ), @@ -302,7 +308,9 @@ class EntityDetailsBloc extends Bloc { // Dispatch event if AccountAction was injected in the newly loaded items if (newProcessedFeedItems.any((item) => item is AccountAction) && _appBloc.state.user?.id != null) { - _appBloc.add(AppUserAccountActionShown(userId: _appBloc.state.user!.id)); + _appBloc.add( + AppUserAccountActionShown(userId: _appBloc.state.user!.id), + ); } } on HtHttpException catch (e) { emit( diff --git a/lib/entity_details/view/entity_details_page.dart b/lib/entity_details/view/entity_details_page.dart index fa7fd0e3..53b69938 100644 --- a/lib/entity_details/view/entity_details_page.dart +++ b/lib/entity_details/view/entity_details_page.dart @@ -41,7 +41,8 @@ class EntityDetailsPage extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocProvider( // Explicitly type BlocProvider + return BlocProvider( + // Explicitly type BlocProvider create: (context) { final feedInjectorService = FeedInjectorService(); final entityDetailsBloc = EntityDetailsBloc( @@ -121,36 +122,45 @@ class _EntityDetailsViewState extends State { name = l10n.detailsPageTitle; // Fallback } // Manual capitalization - return name.isNotEmpty ? '${name[0].toUpperCase()}${name.substring(1)}' : name; + return name.isNotEmpty + ? '${name[0].toUpperCase()}${name.substring(1)}' + : name; } @override Widget build(BuildContext context) { final l10n = context.l10n; final theme = Theme.of(context); - final textTheme = theme.textTheme; + final textTheme = theme.textTheme; final colorScheme = theme.colorScheme; return Scaffold( body: BlocBuilder( builder: (context, state) { - final entityTypeDisplayNameForTitle = _getEntityTypeDisplayName(widget.args.entityType, l10n); + final entityTypeDisplayNameForTitle = _getEntityTypeDisplayName( + widget.args.entityType, + l10n, + ); if (state.status == EntityDetailsStatus.initial || - (state.status == EntityDetailsStatus.loading && state.entity == null)) { + (state.status == EntityDetailsStatus.loading && + state.entity == null)) { return LoadingStateWidget( icon: Icons.info_outline, - headline: entityTypeDisplayNameForTitle, // Use the display name directly + headline: + entityTypeDisplayNameForTitle, // Use the display name directly subheadline: l10n.pleaseWait, ); } - if (state.status == EntityDetailsStatus.failure && state.entity == null) { + if (state.status == EntityDetailsStatus.failure && + state.entity == null) { return FailureStateWidget( //TODO(fulleni): add entityDetailsErrorLoadingto l10n // message: state.errorMessage ?? l10n.entityDetailsErrorLoading(entityType: entityTypeDisplayNameForTitle), message: state.errorMessage ?? '...', - onRetry: () => context.read().add( + onRetry: + () => context.read().add( EntityDetailsLoadRequested( entityId: widget.args.entityId, entityType: widget.args.entityType, @@ -177,29 +187,32 @@ class _EntityDetailsViewState extends State { appBarTitleText = l10n.detailsPageTitle; // Fallback } - final description = state.entity is Category - ? (state.entity as Category).description - : state.entity is Source + final description = + state.entity is Category + ? (state.entity as Category).description + : state.entity is Source ? (state.entity as Source).description : null; - final entityIconUrl = (state.entity is Category && - (state.entity as Category).iconUrl != null) - ? (state.entity as Category).iconUrl - : null; + final 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 : Icons.add_circle_outline, color: colorScheme.primary, ), - 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(), + ); }, ); @@ -216,11 +229,12 @@ class _EntityDetailsViewState extends State { width: kToolbarHeight - AppSpacing.lg, height: kToolbarHeight - AppSpacing.lg, fit: BoxFit.contain, - errorBuilder: (context, error, stackTrace) => Icon( - appBarIconData ?? Icons.info_outline, - size: kToolbarHeight - AppSpacing.xl, - color: colorScheme.onSurfaceVariant, - ), + errorBuilder: + (context, error, stackTrace) => Icon( + appBarIconData ?? Icons.info_outline, + size: kToolbarHeight - AppSpacing.xl, + color: colorScheme.onSurfaceVariant, + ), ), ), ) @@ -251,10 +265,7 @@ class _EntityDetailsViewState extends State { pinned: true, floating: false, snap: false, - actions: [ - followButton, - const SizedBox(width: AppSpacing.sm), - ], + actions: [followButton, const SizedBox(width: AppSpacing.sm)], ), SliverPadding( padding: const EdgeInsets.all(AppSpacing.paddingMedium), @@ -271,15 +282,15 @@ class _EntityDetailsViewState extends State { const SizedBox(height: AppSpacing.lg), ], if (state.feedItems.isNotEmpty || - state.headlinesStatus == EntityHeadlinesStatus.loadingMore) ...[ + state.headlinesStatus == + EntityHeadlinesStatus.loadingMore) ...[ Text( l10n.headlinesSectionTitle, - style: textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - ), - const Divider( - height: AppSpacing.lg, - thickness: 1, + style: textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), ), + const Divider(height: AppSpacing.lg, thickness: 1), ], ]), ), @@ -305,19 +316,27 @@ class _EntityDetailsViewState extends State { ) else SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.paddingMedium), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.paddingMedium, + ), sliver: SliverList.separated( - itemCount: state.feedItems.length + + itemCount: + state.feedItems.length + (state.hasMoreHeadlines && - state.headlinesStatus == EntityHeadlinesStatus.loadingMore + state.headlinesStatus == + EntityHeadlinesStatus.loadingMore ? 1 : 0), - separatorBuilder: (context, index) => const SizedBox(height: AppSpacing.sm), + separatorBuilder: + (context, index) => + const SizedBox(height: AppSpacing.sm), itemBuilder: (context, index) { if (index >= state.feedItems.length) { return const Center( child: Padding( - padding: EdgeInsets.symmetric(vertical: AppSpacing.lg), + padding: EdgeInsets.symmetric( + vertical: AppSpacing.lg, + ), child: CircularProgressIndicator(), ), ); @@ -325,56 +344,63 @@ class _EntityDetailsViewState extends State { final item = state.feedItems[index]; if (item is Headline) { - 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: item, - onHeadlineTap: () => context.pushNamed( - Routes.globalArticleDetailsName, - pathParameters: {'id': item.id}, - extra: item, - ), + onHeadlineTap: + () => context.pushNamed( + Routes.globalArticleDetailsName, + pathParameters: {'id': item.id}, + extra: item, + ), 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, ); case HeadlineImageStyle.smallThumbnail: tile = HeadlineTileImageStart( headline: item, - onHeadlineTap: () => context.pushNamed( - Routes.globalArticleDetailsName, - pathParameters: {'id': item.id}, - extra: item, - ), + onHeadlineTap: + () => context.pushNamed( + Routes.globalArticleDetailsName, + pathParameters: {'id': item.id}, + extra: item, + ), 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, ); case HeadlineImageStyle.largeThumbnail: tile = HeadlineTileImageTop( headline: item, - onHeadlineTap: () => context.pushNamed( - Routes.globalArticleDetailsName, - pathParameters: {'id': item.id}, - extra: item, - ), + onHeadlineTap: + () => context.pushNamed( + Routes.globalArticleDetailsName, + pathParameters: {'id': item.id}, + extra: item, + ), 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, ); @@ -392,12 +418,14 @@ class _EntityDetailsViewState extends State { padding: const EdgeInsets.all(AppSpacing.paddingMedium), child: Text( state.errorMessage ?? l10n.failedToLoadMoreHeadlines, - style: textTheme.bodyMedium?.copyWith(color: colorScheme.error), + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.error, + ), textAlign: TextAlign.center, ), ), ), - const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.xxl)), + const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.xxl)), ], ); }, diff --git a/lib/headline-details/view/headline_details_page.dart b/lib/headline-details/view/headline_details_page.dart index 7b8f0c71..784e5327 100644 --- a/lib/headline-details/view/headline_details_page.dart +++ b/lib/headline-details/view/headline_details_page.dart @@ -154,11 +154,11 @@ class _HeadlineDetailsPageState extends State { final HeadlineDetailsLoaded loadedState => _buildLoadedContent(context, loadedState.headline), _ => Center( - child: Text( - l10n.unknownError, - style: Theme.of(context).textTheme.bodyLarge, - ), + child: Text( + l10n.unknownError, + style: Theme.of(context).textTheme.bodyLarge, ), + ), }; }, ), @@ -190,9 +190,10 @@ class _HeadlineDetailsPageState extends State { isSaved ? Icons.bookmark : Icons.bookmark_border_outlined, color: colorScheme.primary, // Ensure icon color from theme ), - tooltip: isSaved - ? l10n.headlineDetailsRemoveFromSavedTooltip - : l10n.headlineDetailsSaveTooltip, + tooltip: + isSaved + ? l10n.headlineDetailsRemoveFromSavedTooltip + : l10n.headlineDetailsSaveTooltip, onPressed: () { context.read().add( AccountSaveHeadlineToggled(headline: headline), @@ -273,8 +274,9 @@ class _HeadlineDetailsPageState extends State { sliver: SliverToBoxAdapter( child: Text( headline.title, - style: textTheme.headlineSmall - ?.copyWith(fontWeight: FontWeight.bold), // Adjusted style + style: textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), // Adjusted style ), ), ), @@ -287,7 +289,9 @@ class _HeadlineDetailsPageState extends State { ), sliver: SliverToBoxAdapter( child: ClipRRect( - borderRadius: BorderRadius.circular(AppSpacing.md), // Consistent radius + borderRadius: BorderRadius.circular( + AppSpacing.md, + ), // Consistent radius child: AspectRatio( aspectRatio: 16 / 9, child: Image.network( @@ -302,14 +306,15 @@ class _HeadlineDetailsPageState extends State { ), ); }, - errorBuilder: (context, error, stackTrace) => ColoredBox( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.broken_image_outlined, - color: colorScheme.onSurfaceVariant, - size: AppSpacing.xxl * 1.5, // Larger placeholder - ), - ), + errorBuilder: + (context, error, stackTrace) => ColoredBox( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.broken_image_outlined, + color: colorScheme.onSurfaceVariant, + size: AppSpacing.xxl * 1.5, // Larger placeholder + ), + ), ), ), ), @@ -340,7 +345,9 @@ class _HeadlineDetailsPageState extends State { ), ), SliverPadding( - padding: horizontalPadding.copyWith(top: AppSpacing.lg), // Increased spacing + padding: horizontalPadding.copyWith( + top: AppSpacing.lg, + ), // Increased spacing sliver: SliverToBoxAdapter( child: Wrap( spacing: AppSpacing.md, // Increased spacing @@ -351,7 +358,9 @@ class _HeadlineDetailsPageState extends State { ), if (headline.description != null && headline.description!.isNotEmpty) SliverPadding( - padding: horizontalPadding.copyWith(top: AppSpacing.lg), // Increased + padding: horizontalPadding.copyWith( + top: AppSpacing.lg, + ), // Increased sliver: SliverToBoxAdapter( child: Text( headline.description!, @@ -380,12 +389,15 @@ class _HeadlineDetailsPageState extends State { horizontal: AppSpacing.lg, vertical: AppSpacing.md, ), - textStyle: textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold), + textStyle: textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.bold, + ), ), ), ), ), - if (headline.url == null || headline.url!.isEmpty) // Ensure bottom padding + if (headline.url == null || + headline.url!.isEmpty) // Ensure bottom padding const SliverPadding( padding: EdgeInsets.only(bottom: AppSpacing.xl), sliver: SliverToBoxAdapter(child: SizedBox.shrink()), @@ -395,13 +407,17 @@ class _HeadlineDetailsPageState extends State { sliver: SliverToBoxAdapter( child: Padding( padding: EdgeInsets.only( - top: (headline.url != null && headline.url!.isNotEmpty) ? AppSpacing.sm : AppSpacing.xl, + top: + (headline.url != null && headline.url!.isNotEmpty) + ? AppSpacing.sm + : AppSpacing.xl, bottom: AppSpacing.md, ), child: Text( l10n.similarHeadlinesSectionTitle, - style: textTheme.titleLarge - ?.copyWith(fontWeight: FontWeight.bold), + style: textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), ), ), ), @@ -433,8 +449,9 @@ class _HeadlineDetailsPageState extends State { final chips = []; if (headline.publishedAt != null) { - final formattedDate = - DateFormat('MMM d, yyyy').format(headline.publishedAt!); + final formattedDate = DateFormat( + 'MMM d, yyyy', + ).format(headline.publishedAt!); chips.add( Chip( avatar: Icon( @@ -455,14 +472,17 @@ class _HeadlineDetailsPageState extends State { if (headline.source != null) { chips.add( - InkWell( // Make chip tappable + InkWell( + // Make chip tappable onTap: () { context.push( Routes.sourceDetails, extra: EntityDetailsPageArguments(entity: headline.source), ); }, - borderRadius: BorderRadius.circular(AppSpacing.sm), // Match chip shape + borderRadius: BorderRadius.circular( + AppSpacing.sm, + ), // Match chip shape child: Chip( avatar: Icon( Icons.source_outlined, @@ -483,14 +503,17 @@ class _HeadlineDetailsPageState extends State { if (headline.category != null) { chips.add( - InkWell( // Make chip tappable + InkWell( + // Make chip tappable onTap: () { context.push( Routes.categoryDetails, extra: EntityDetailsPageArguments(entity: headline.category), ); }, - borderRadius: BorderRadius.circular(AppSpacing.sm), // Match chip shape + borderRadius: BorderRadius.circular( + AppSpacing.sm, + ), // Match chip shape child: Chip( avatar: Icon( Icons.category_outlined, @@ -512,7 +535,9 @@ class _HeadlineDetailsPageState extends State { } Widget _buildSimilarHeadlinesSection( - BuildContext context, EdgeInsets hPadding,) { + BuildContext context, + EdgeInsets hPadding, + ) { final l10n = context.l10n; final theme = Theme.of(context); final textTheme = theme.textTheme; @@ -531,71 +556,82 @@ class _HeadlineDetailsPageState extends State { final SimilarHeadlinesError errorState => SliverToBoxAdapter( child: Padding( padding: hPadding.copyWith( - top: AppSpacing.md, bottom: AppSpacing.xl,), + top: AppSpacing.md, + bottom: AppSpacing.xl, + ), child: Text( errorState.message, textAlign: TextAlign.center, - style: textTheme.bodyMedium - ?.copyWith(color: colorScheme.error), + style: textTheme.bodyMedium?.copyWith(color: colorScheme.error), ), ), ), SimilarHeadlinesEmpty() => SliverToBoxAdapter( child: Padding( padding: hPadding.copyWith( - top: AppSpacing.md, bottom: AppSpacing.xl,), + top: AppSpacing.md, + bottom: AppSpacing.xl, + ), child: Text( l10n.similarHeadlinesEmpty, textAlign: TextAlign.center, - style: textTheme.bodyLarge - ?.copyWith(color: colorScheme.onSurfaceVariant), + style: textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), ), ), final SimilarHeadlinesLoaded loadedState => SliverPadding( padding: hPadding.copyWith(bottom: AppSpacing.xxl), sliver: SliverList.separated( - separatorBuilder: (context, index) => - const SizedBox(height: AppSpacing.sm), // Spacing between items + separatorBuilder: + (context, index) => const SizedBox( + height: AppSpacing.sm, + ), // Spacing between items itemCount: loadedState.similarHeadlines.length, - itemBuilder: (context, index) { // Corrected: SliverList.separated uses itemBuilder + itemBuilder: (context, index) { + // Corrected: SliverList.separated uses itemBuilder final similarHeadline = loadedState.similarHeadlines[index]; return Builder( builder: (context) { - 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: similarHeadline, - onHeadlineTap: () => context.pushNamed( - Routes.globalArticleDetailsName, - pathParameters: {'id': similarHeadline.id}, - extra: similarHeadline, - ), + onHeadlineTap: + () => context.pushNamed( + Routes.globalArticleDetailsName, + pathParameters: {'id': similarHeadline.id}, + extra: similarHeadline, + ), ); case HeadlineImageStyle.smallThumbnail: tile = HeadlineTileImageStart( headline: similarHeadline, - onHeadlineTap: () => context.pushNamed( - Routes.globalArticleDetailsName, - pathParameters: {'id': similarHeadline.id}, - extra: similarHeadline, - ), + onHeadlineTap: + () => context.pushNamed( + Routes.globalArticleDetailsName, + pathParameters: {'id': similarHeadline.id}, + extra: similarHeadline, + ), ); case HeadlineImageStyle.largeThumbnail: tile = HeadlineTileImageTop( headline: similarHeadline, - onHeadlineTap: () => context.pushNamed( - Routes.globalArticleDetailsName, - pathParameters: {'id': similarHeadline.id}, - extra: similarHeadline, - ), + onHeadlineTap: + () => context.pushNamed( + Routes.globalArticleDetailsName, + pathParameters: {'id': similarHeadline.id}, + extra: similarHeadline, + ), ); } return tile; diff --git a/lib/headlines-feed/bloc/headlines_feed_bloc.dart b/lib/headlines-feed/bloc/headlines_feed_bloc.dart index e6e5e3e4..89234957 100644 --- a/lib/headlines-feed/bloc/headlines_feed_bloc.dart +++ b/lib/headlines-feed/bloc/headlines_feed_bloc.dart @@ -27,10 +27,10 @@ class HeadlinesFeedBloc extends Bloc { required HtDataRepository headlinesRepository, required FeedInjectorService feedInjectorService, // Added required AppBloc appBloc, // Added - }) : _headlinesRepository = headlinesRepository, - _feedInjectorService = feedInjectorService, // Added - _appBloc = appBloc, // Added - super(HeadlinesFeedInitial()) { + }) : _headlinesRepository = headlinesRepository, + _feedInjectorService = feedInjectorService, // Added + _appBloc = appBloc, // Added + super(HeadlinesFeedInitial()) { on( _onHeadlinesFeedFetchRequested, transformer: @@ -86,7 +86,9 @@ class HeadlinesFeedBloc extends Bloc { if (appConfig == null) { // AppConfig is crucial for injection rules. - emit(const HeadlinesFeedError(message: 'App configuration not available.')); + emit( + const HeadlinesFeedError(message: 'App configuration not available.'), + ); return; } @@ -109,7 +111,9 @@ class HeadlinesFeedBloc extends Bloc { // Dispatch event if AccountAction was injected if (processedFeedItems.any((item) => item is AccountAction) && _appBloc.state.user?.id != null) { - _appBloc.add(AppUserAccountActionShown(userId: _appBloc.state.user!.id)); + _appBloc.add( + AppUserAccountActionShown(userId: _appBloc.state.user!.id), + ); } } on HtHttpException catch (e) { emit(HeadlinesFeedError(message: e.message)); @@ -139,7 +143,9 @@ class HeadlinesFeedBloc extends Bloc { final appConfig = _appBloc.state.appConfig; if (appConfig == null) { - emit(const HeadlinesFeedError(message: 'App configuration not available.')); + emit( + const HeadlinesFeedError(message: 'App configuration not available.'), + ); return; } @@ -158,11 +164,13 @@ class HeadlinesFeedBloc extends Bloc { filter: const HeadlineFilter(), // Ensure filter is reset ), ); - + // Dispatch event if AccountAction was injected if (processedFeedItems.any((item) => item is AccountAction) && _appBloc.state.user?.id != null) { - _appBloc.add(AppUserAccountActionShown(userId: _appBloc.state.user!.id)); + _appBloc.add( + AppUserAccountActionShown(userId: _appBloc.state.user!.id), + ); } } on HtHttpException catch (e) { emit(HeadlinesFeedError(message: e.message)); @@ -197,27 +205,35 @@ class HeadlinesFeedBloc extends Bloc { currentFeedItems = loadedState.feedItems; currentFeedItemCountForInjector = loadedState.feedItems.length; - if (event.cursor != null) { // Explicit pagination request + if (event.cursor != null) { + // Explicit pagination request if (!loadedState.hasMore) return; // No more items to fetch isPaginating = true; - currentCursorForFetch = loadedState.cursor; // Use BLoC's cursor for safety - } else { // Initial fetch or refresh (event.cursor is null) + currentCursorForFetch = + loadedState.cursor; // Use BLoC's cursor for safety + } else { + // Initial fetch or refresh (event.cursor is null) currentFeedItems = []; // Reset for non-pagination currentFeedItemCountForInjector = 0; } - } else if (state is HeadlinesFeedLoading || state is HeadlinesFeedLoadingSilently) { + } else if (state is HeadlinesFeedLoading || + state is HeadlinesFeedLoadingSilently) { if (event.cursor == null) return; // Avoid concurrent initial fetches } // For initial load or if event.cursor is null, currentCursorForFetch remains null. - emit(isPaginating ? HeadlinesFeedLoadingSilently() : HeadlinesFeedLoading()); + emit( + isPaginating ? HeadlinesFeedLoadingSilently() : HeadlinesFeedLoading(), + ); try { final currentUser = _appBloc.state.user; final appConfig = _appBloc.state.appConfig; if (appConfig == null) { - emit(const HeadlinesFeedError(message: 'App configuration not available.')); + emit( + const HeadlinesFeedError(message: 'App configuration not available.'), + ); return; } @@ -246,9 +262,10 @@ class HeadlinesFeedBloc extends Bloc { currentFeedItemCount: currentFeedItemCountForInjector, ); - final resultingFeedItems = isPaginating - ? (List.of(currentFeedItems)..addAll(newProcessedFeedItems)) - : newProcessedFeedItems; + final resultingFeedItems = + isPaginating + ? (List.of(currentFeedItems)..addAll(newProcessedFeedItems)) + : newProcessedFeedItems; emit( HeadlinesFeedLoaded( @@ -262,7 +279,9 @@ class HeadlinesFeedBloc extends Bloc { // Dispatch event if AccountAction was injected if (newProcessedFeedItems.any((item) => item is AccountAction) && _appBloc.state.user?.id != null) { - _appBloc.add(AppUserAccountActionShown(userId: _appBloc.state.user!.id)); + _appBloc.add( + AppUserAccountActionShown(userId: _appBloc.state.user!.id), + ); } } on HtHttpException catch (e) { emit(HeadlinesFeedError(message: e.message)); @@ -292,18 +311,22 @@ class HeadlinesFeedBloc extends Bloc { final appConfig = _appBloc.state.appConfig; if (appConfig == null) { - emit(const HeadlinesFeedError(message: 'App configuration not available.')); + emit( + const HeadlinesFeedError(message: 'App configuration not available.'), + ); return; } final queryParams = {}; if (currentFilter.categories?.isNotEmpty ?? false) { - queryParams['categories'] = - currentFilter.categories!.map((c) => c.id).join(','); + queryParams['categories'] = currentFilter.categories! + .map((c) => c.id) + .join(','); } if (currentFilter.sources?.isNotEmpty ?? false) { - queryParams['sources'] = - currentFilter.sources!.map((s) => s.id).join(','); + queryParams['sources'] = currentFilter.sources! + .map((s) => s.id) + .join(','); } final headlineResponse = await _headlinesRepository.readAllByQuery( @@ -335,7 +358,9 @@ class HeadlinesFeedBloc extends Bloc { // Dispatch event if AccountAction was injected if (processedFeedItems.any((item) => item is AccountAction) && _appBloc.state.user?.id != null) { - _appBloc.add(AppUserAccountActionShown(userId: _appBloc.state.user!.id)); + _appBloc.add( + AppUserAccountActionShown(userId: _appBloc.state.user!.id), + ); } } on HtHttpException catch (e) { emit(HeadlinesFeedError(message: e.message)); diff --git a/lib/headlines-feed/models/headline_filter.dart b/lib/headlines-feed/models/headline_filter.dart index ee286d1e..87f898e9 100644 --- a/lib/headlines-feed/models/headline_filter.dart +++ b/lib/headlines-feed/models/headline_filter.dart @@ -33,12 +33,12 @@ class HeadlineFilter extends Equatable { @override List get props => [ - categories, - sources, - selectedSourceCountryIsoCodes, - selectedSourceSourceTypes, - isFromFollowedItems, // Added to props - ]; + categories, + sources, + selectedSourceCountryIsoCodes, + selectedSourceSourceTypes, + isFromFollowedItems, // Added to props + ]; /// Creates a copy of this [HeadlineFilter] with the given fields /// replaced with the new values. @@ -56,7 +56,8 @@ class HeadlineFilter extends Equatable { selectedSourceCountryIsoCodes ?? this.selectedSourceCountryIsoCodes, selectedSourceSourceTypes: selectedSourceSourceTypes ?? this.selectedSourceSourceTypes, - isFromFollowedItems: isFromFollowedItems ?? this.isFromFollowedItems, // Added + isFromFollowedItems: + isFromFollowedItems ?? this.isFromFollowedItems, // Added ); } } diff --git a/lib/headlines-feed/view/category_filter_page.dart b/lib/headlines-feed/view/category_filter_page.dart index 5169f072..b949c808 100644 --- a/lib/headlines-feed/view/category_filter_page.dart +++ b/lib/headlines-feed/view/category_filter_page.dart @@ -152,8 +152,10 @@ class _CategoryFilterPageState extends State { state.categories.isEmpty) { return FailureStateWidget( message: state.error?.toString() ?? l10n.unknownError, - onRetry: () => - context.read().add(CategoriesFilterRequested()), + onRetry: + () => context.read().add( + CategoriesFilterRequested(), + ), ); } @@ -170,9 +172,11 @@ class _CategoryFilterPageState extends State { // Handle loaded state (success or loading more) return ListView.builder( controller: _scrollController, - padding: const EdgeInsets.symmetric(vertical: AppSpacing.paddingSmall) - .copyWith(bottom: AppSpacing.xxl), // Consistent vertical padding - itemCount: state.categories.length + + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.paddingSmall, + ).copyWith(bottom: AppSpacing.xxl), // Consistent vertical padding + itemCount: + state.categories.length + ((state.status == CategoriesFilterStatus.loadingMore || (state.status == CategoriesFilterStatus.failure && state.categories.isNotEmpty)) @@ -194,8 +198,9 @@ class _CategoryFilterPageState extends State { child: Center( child: Text( l10n.loadMoreError, - style: textTheme.bodySmall - ?.copyWith(color: colorScheme.error), + style: textTheme.bodySmall?.copyWith( + color: colorScheme.error, + ), ), ), ); @@ -208,36 +213,40 @@ class _CategoryFilterPageState extends State { return CheckboxListTile( title: Text(category.name, style: textTheme.titleMedium), - secondary: category.iconUrl != null - ? SizedBox( - width: AppSpacing.xl + AppSpacing.sm, // 40 -> 32 - height: AppSpacing.xl + AppSpacing.sm, - child: ClipRRect( - borderRadius: BorderRadius.circular(AppSpacing.xs), - child: Image.network( - category.iconUrl!, - fit: BoxFit.contain, - errorBuilder: (context, error, stackTrace) => Icon( - Icons.category_outlined, // Use outlined - color: colorScheme.onSurfaceVariant, // Theme color - size: AppSpacing.xl, + secondary: + category.iconUrl != null + ? SizedBox( + width: AppSpacing.xl + AppSpacing.sm, // 40 -> 32 + height: AppSpacing.xl + AppSpacing.sm, + child: ClipRRect( + borderRadius: BorderRadius.circular(AppSpacing.xs), + child: Image.network( + category.iconUrl!, + fit: BoxFit.contain, + errorBuilder: + (context, error, stackTrace) => Icon( + Icons.category_outlined, // Use outlined + color: + colorScheme.onSurfaceVariant, // Theme color + size: AppSpacing.xl, + ), + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Center( + child: CircularProgressIndicator( + strokeWidth: 2, + value: + loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, + ), + ); + }, ), - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; - return Center( - child: CircularProgressIndicator( - strokeWidth: 2, - value: loadingProgress.expectedTotalBytes != null - ? loadingProgress.cumulativeBytesLoaded / - loadingProgress.expectedTotalBytes! - : null, - ), - ); - }, ), - ), - ) - : null, + ) + : null, value: isSelected, onChanged: (bool? value) { setState(() { diff --git a/lib/headlines-feed/view/country_filter_page.dart b/lib/headlines-feed/view/country_filter_page.dart index a7f643c8..3917945e 100644 --- a/lib/headlines-feed/view/country_filter_page.dart +++ b/lib/headlines-feed/view/country_filter_page.dart @@ -149,8 +149,10 @@ class _CountryFilterPageState extends State { state.countries.isEmpty) { return FailureStateWidget( message: state.error?.toString() ?? l10n.unknownError, - onRetry: () => - context.read().add(CountriesFilterRequested()), + onRetry: + () => context.read().add( + CountriesFilterRequested(), + ), ); } @@ -167,9 +169,11 @@ class _CountryFilterPageState extends State { // Handle loaded state (success or loading more) return ListView.builder( controller: _scrollController, - padding: const EdgeInsets.symmetric(vertical: AppSpacing.paddingSmall) - .copyWith(bottom: AppSpacing.xxl), // Consistent vertical padding - itemCount: state.countries.length + + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.paddingSmall, + ).copyWith(bottom: AppSpacing.xxl), // Consistent vertical padding + itemCount: + state.countries.length + ((state.status == CountriesFilterStatus.loadingMore || (state.status == CountriesFilterStatus.failure && state.countries.isNotEmpty)) @@ -191,8 +195,9 @@ class _CountryFilterPageState extends State { child: Center( child: Text( l10n.loadMoreError, - style: textTheme.bodySmall - ?.copyWith(color: colorScheme.error), + style: textTheme.bodySmall?.copyWith( + color: colorScheme.error, + ), ), ), ); @@ -208,25 +213,28 @@ class _CountryFilterPageState extends State { secondary: SizedBox( width: AppSpacing.xl + AppSpacing.xs, // Standardized width (36) height: AppSpacing.lg + AppSpacing.sm, // Standardized height (24) - child: ClipRRect( // Clip the image for rounded corners if desired + child: ClipRRect( + // Clip the image for rounded corners if desired borderRadius: BorderRadius.circular(AppSpacing.xs / 2), child: Image.network( country.flagUrl, fit: BoxFit.cover, // Use cover for better filling - errorBuilder: (context, error, stackTrace) => Icon( - Icons.flag_outlined, - color: colorScheme.onSurfaceVariant, - size: AppSpacing.lg, // Adjust size as needed - ), + errorBuilder: + (context, error, stackTrace) => Icon( + Icons.flag_outlined, + color: colorScheme.onSurfaceVariant, + size: AppSpacing.lg, // Adjust size as needed + ), loadingBuilder: (context, child, loadingProgress) { if (loadingProgress == null) return child; return Center( child: CircularProgressIndicator( strokeWidth: 2, - value: loadingProgress.expectedTotalBytes != null - ? loadingProgress.cumulativeBytesLoaded / - loadingProgress.expectedTotalBytes! - : null, + value: + loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, ), ); }, diff --git a/lib/headlines-feed/view/headlines_feed_page.dart b/lib/headlines-feed/view/headlines_feed_page.dart index 87615df6..e838e266 100644 --- a/lib/headlines-feed/view/headlines_feed_page.dart +++ b/lib/headlines-feed/view/headlines_feed_page.dart @@ -163,7 +163,8 @@ class _HeadlinesFeedPageState extends State { return const SizedBox.shrink(); case HeadlinesFeedLoaded(): - if (state.feedItems.isEmpty) { // Changed from state.headlines + if (state.feedItems.isEmpty) { + // Changed from state.headlines return FailureStateWidget( message: '${l10n.headlinesFeedEmptyFilteredHeadline}\n${l10n.headlinesFeedEmptyFilteredSubheadline}', @@ -172,8 +173,7 @@ class _HeadlinesFeedPageState extends State { HeadlinesFeedFiltersCleared(), ); }, - retryButtonText: - l10n.headlinesFeedClearFiltersButton, + retryButtonText: l10n.headlinesFeedClearFiltersButton, ); } return RefreshIndicator( @@ -188,23 +188,29 @@ class _HeadlinesFeedPageState extends State { top: AppSpacing.md, bottom: AppSpacing.xxl, ), - itemCount: state.hasMore - ? state.feedItems.length + 1 // Changed - : state.feedItems.length, // Changed + itemCount: + state.hasMore + ? state.feedItems.length + + 1 // Changed + : state.feedItems.length, // Changed separatorBuilder: (context, index) { // Add a bit more space if the next item is an Ad or AccountAction - if (index < state.feedItems.length -1) { + if (index < state.feedItems.length - 1) { final currentItem = state.feedItems[index]; - final nextItem = state.feedItems[index+1]; - if ((currentItem is Headline && (nextItem is Ad || nextItem is AccountAction)) || - ((currentItem is Ad || currentItem is AccountAction) && nextItem is Headline)) { + final nextItem = state.feedItems[index + 1]; + if ((currentItem is Headline && + (nextItem is Ad || nextItem is AccountAction)) || + ((currentItem is Ad || + currentItem is AccountAction) && + nextItem is Headline)) { return const SizedBox(height: AppSpacing.md); } } return const SizedBox(height: AppSpacing.lg); }, itemBuilder: (context, index) { - if (index >= state.feedItems.length) { // Changed + if (index >= state.feedItems.length) { + // Changed return const Padding( padding: EdgeInsets.symmetric(vertical: AppSpacing.lg), child: Center(child: CircularProgressIndicator()), @@ -213,40 +219,44 @@ class _HeadlinesFeedPageState extends State { final item = state.feedItems[index]; // Changed if (item is Headline) { - 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: item, - onHeadlineTap: () => context.goNamed( - Routes.articleDetailsName, - pathParameters: {'id': item.id}, - extra: item, - ), + onHeadlineTap: + () => context.goNamed( + Routes.articleDetailsName, + pathParameters: {'id': item.id}, + extra: item, + ), ); case HeadlineImageStyle.smallThumbnail: tile = HeadlineTileImageStart( headline: item, - onHeadlineTap: () => context.goNamed( - Routes.articleDetailsName, - pathParameters: {'id': item.id}, - extra: item, - ), + onHeadlineTap: + () => context.goNamed( + Routes.articleDetailsName, + pathParameters: {'id': item.id}, + extra: item, + ), ); case HeadlineImageStyle.largeThumbnail: tile = HeadlineTileImageTop( headline: item, - onHeadlineTap: () => context.goNamed( - Routes.articleDetailsName, - pathParameters: {'id': item.id}, - extra: item, - ), + onHeadlineTap: + () => context.goNamed( + Routes.articleDetailsName, + pathParameters: {'id': item.id}, + extra: item, + ), ); } return tile; @@ -266,8 +276,11 @@ class _HeadlinesFeedPageState extends State { Image.network( item.imageUrl, height: 100, - errorBuilder: (ctx, err, st) => - const Icon(Icons.broken_image, size: 50), + errorBuilder: + (ctx, err, st) => const Icon( + Icons.broken_image, + size: 50, + ), ), const SizedBox(height: AppSpacing.sm), Text( @@ -299,7 +312,8 @@ class _HeadlinesFeedPageState extends State { color: colorScheme.secondaryContainer, child: ListTile( leading: Icon( - item.accountActionType == AccountActionType.linkAccount + item.accountActionType == + AccountActionType.linkAccount ? Icons.link : Icons.upgrade, color: colorScheme.onSecondaryContainer, @@ -311,29 +325,34 @@ class _HeadlinesFeedPageState extends State { fontWeight: FontWeight.bold, ), ), - subtitle: item.description != null - ? Text( - item.description!, - style: textTheme.bodySmall?.copyWith( - color: colorScheme.onSecondaryContainer.withOpacity(0.8), - ), - ) - : null, - trailing: item.callToActionText != null - ? ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: colorScheme.secondary, - foregroundColor: colorScheme.onSecondary, - ), - onPressed: () { - if (item.callToActionUrl != null) { - context.push(item.callToActionUrl!); - } - }, - child: Text(item.callToActionText!), - ) - : null, - isThreeLine: item.description != null && item.description!.length > 50, + subtitle: + item.description != null + ? Text( + item.description!, + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSecondaryContainer + .withOpacity(0.8), + ), + ) + : null, + trailing: + item.callToActionText != null + ? ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: colorScheme.secondary, + foregroundColor: colorScheme.onSecondary, + ), + onPressed: () { + if (item.callToActionUrl != null) { + context.push(item.callToActionUrl!); + } + }, + child: Text(item.callToActionText!), + ) + : null, + isThreeLine: + item.description != null && + item.description!.length > 50, ), ); } diff --git a/lib/headlines-feed/view/source_filter_page.dart b/lib/headlines-feed/view/source_filter_page.dart index 522708d7..6a8fa602 100644 --- a/lib/headlines-feed/view/source_filter_page.dart +++ b/lib/headlines-feed/view/source_filter_page.dart @@ -107,7 +107,8 @@ class _SourceFilterView extends StatelessWidget { final textTheme = theme.textTheme; if (state.dataLoadingStatus == SourceFilterDataLoadingStatus.loading && - state.allAvailableSources.isEmpty) { // Check allAvailableSources + state.allAvailableSources.isEmpty) { + // Check allAvailableSources return LoadingStateWidget( icon: Icons.source_outlined, // More relevant icon headline: l10n.sourceFilterLoadingHeadline, // Specific l10n @@ -115,18 +116,18 @@ class _SourceFilterView extends StatelessWidget { ); } if (state.dataLoadingStatus == SourceFilterDataLoadingStatus.failure && - state.allAvailableSources.isEmpty) { // Check allAvailableSources + state.allAvailableSources.isEmpty) { + // Check allAvailableSources return FailureStateWidget( message: state.errorMessage ?? l10n.headlinesFeedFilterErrorCriteria, onRetry: () { - context - .read() - .add(const LoadSourceFilterData()); + context.read().add(const LoadSourceFilterData()); }, ); } - return Column( // Removed Padding, handled by children + return Column( + // Removed Padding, handled by children crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildCountryCapsules(context, state, l10n, textTheme), @@ -155,9 +156,11 @@ class _SourceFilterView extends StatelessWidget { TextTheme textTheme, // Added textTheme ) { return Padding( - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.paddingMedium) - .copyWith(top: AppSpacing.md), // Add top padding - child: Column( // Use Column for label and then list + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.paddingMedium, + ).copyWith(top: AppSpacing.md), // Add top padding + child: Column( + // Use Column for label and then list crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( @@ -170,8 +173,8 @@ class _SourceFilterView extends StatelessWidget { child: ListView.separated( scrollDirection: Axis.horizontal, itemCount: state.availableCountries.length + 1, - separatorBuilder: (context, index) => - const SizedBox(width: AppSpacing.sm), + separatorBuilder: + (context, index) => const SizedBox(width: AppSpacing.sm), itemBuilder: (context, index) { if (index == 0) { return ChoiceChip( @@ -179,28 +182,30 @@ class _SourceFilterView extends StatelessWidget { labelStyle: textTheme.labelLarge, selected: state.selectedCountryIsoCodes.isEmpty, onSelected: (_) { - context - .read() - .add(const CountryCapsuleToggled('')); + context.read().add( + const CountryCapsuleToggled(''), + ); }, ); } final country = state.availableCountries[index - 1]; return ChoiceChip( - avatar: country.flagUrl.isNotEmpty - ? CircleAvatar( - backgroundImage: NetworkImage(country.flagUrl), - radius: AppSpacing.sm + AppSpacing.xs, - ) - : null, + avatar: + country.flagUrl.isNotEmpty + ? CircleAvatar( + backgroundImage: NetworkImage(country.flagUrl), + radius: AppSpacing.sm + AppSpacing.xs, + ) + : null, label: Text(country.name), labelStyle: textTheme.labelLarge, - selected: - state.selectedCountryIsoCodes.contains(country.isoCode), + selected: state.selectedCountryIsoCodes.contains( + country.isoCode, + ), onSelected: (_) { - context - .read() - .add(CountryCapsuleToggled(country.isoCode)); + context.read().add( + CountryCapsuleToggled(country.isoCode), + ); }, ); }, @@ -219,7 +224,8 @@ class _SourceFilterView extends StatelessWidget { ) { return Padding( padding: const EdgeInsets.symmetric(horizontal: AppSpacing.paddingMedium), - child: Column( // Use Column for label and then list + child: Column( + // Use Column for label and then list crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( @@ -232,8 +238,8 @@ class _SourceFilterView extends StatelessWidget { child: ListView.separated( scrollDirection: Axis.horizontal, itemCount: state.availableSourceTypes.length + 1, - separatorBuilder: (context, index) => - const SizedBox(width: AppSpacing.sm), + separatorBuilder: + (context, index) => const SizedBox(width: AppSpacing.sm), itemBuilder: (context, index) { if (index == 0) { return ChoiceChip( @@ -241,21 +247,23 @@ class _SourceFilterView extends StatelessWidget { labelStyle: textTheme.labelLarge, selected: state.selectedSourceTypes.isEmpty, onSelected: (_) { - context - .read() - .add(const AllSourceTypesCapsuleToggled()); + context.read().add( + const AllSourceTypesCapsuleToggled(), + ); }, ); } final sourceType = state.availableSourceTypes[index - 1]; return ChoiceChip( - label: Text(sourceType.name), // Assuming SourceType.name is user-friendly + label: Text( + sourceType.name, + ), // Assuming SourceType.name is user-friendly labelStyle: textTheme.labelLarge, selected: state.selectedSourceTypes.contains(sourceType), onSelected: (_) { - context - .read() - .add(SourceTypeCapsuleToggled(sourceType)); + context.read().add( + SourceTypeCapsuleToggled(sourceType), + ); }, ); }, @@ -273,7 +281,8 @@ class _SourceFilterView extends StatelessWidget { TextTheme textTheme, // Added textTheme ) { if (state.dataLoadingStatus == SourceFilterDataLoadingStatus.loading && - state.displayableSources.isEmpty) { // Added check for displayableSources + state.displayableSources.isEmpty) { + // Added check for displayableSources return const Center(child: CircularProgressIndicator()); } if (state.dataLoadingStatus == SourceFilterDataLoadingStatus.failure && @@ -281,14 +290,13 @@ class _SourceFilterView extends StatelessWidget { return FailureStateWidget( message: state.errorMessage ?? l10n.headlinesFeedFilterErrorSources, onRetry: () { - context - .read() - .add(const LoadSourceFilterData()); + context.read().add(const LoadSourceFilterData()); }, ); } if (state.displayableSources.isEmpty && - state.dataLoadingStatus != SourceFilterDataLoadingStatus.loading) { // Avoid showing if still loading + state.dataLoadingStatus != SourceFilterDataLoadingStatus.loading) { + // Avoid showing if still loading return Center( child: Padding( padding: const EdgeInsets.all(AppSpacing.paddingLarge), @@ -302,8 +310,9 @@ class _SourceFilterView extends StatelessWidget { } return ListView.builder( - padding: const EdgeInsets.symmetric(vertical: AppSpacing.paddingSmall) - .copyWith(bottom: AppSpacing.xxl), + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.paddingSmall, + ).copyWith(bottom: AppSpacing.xxl), itemCount: state.displayableSources.length, itemBuilder: (context, index) { final source = state.displayableSources[index]; @@ -312,9 +321,9 @@ class _SourceFilterView extends StatelessWidget { value: state.finallySelectedSourceIds.contains(source.id), onChanged: (bool? value) { if (value != null) { - context - .read() - .add(SourceCheckboxToggled(source.id, value)); + context.read().add( + SourceCheckboxToggled(source.id, value), + ); } }, controlAffinity: ListTileControlAffinity.leading, diff --git a/lib/headlines-search/bloc/headlines_search_bloc.dart b/lib/headlines-search/bloc/headlines_search_bloc.dart index 3645459a..25550f2a 100644 --- a/lib/headlines-search/bloc/headlines_search_bloc.dart +++ b/lib/headlines-search/bloc/headlines_search_bloc.dart @@ -18,12 +18,12 @@ class HeadlinesSearchBloc required HtDataRepository sourceRepository, required AppBloc appBloc, // Added required FeedInjectorService feedInjectorService, // Added - }) : _headlinesRepository = headlinesRepository, - _categoryRepository = categoryRepository, - _sourceRepository = sourceRepository, - _appBloc = appBloc, // Added - _feedInjectorService = feedInjectorService, // Added - super(const HeadlinesSearchInitial()) { + }) : _headlinesRepository = headlinesRepository, + _categoryRepository = categoryRepository, + _sourceRepository = sourceRepository, + _appBloc = appBloc, // Added + _feedInjectorService = feedInjectorService, // Added + super(const HeadlinesSearchInitial()) { on(_onHeadlinesSearchModelTypeChanged); on( _onSearchFetchRequested, @@ -96,7 +96,12 @@ class HeadlinesSearchBloc final currentUser = _appBloc.state.user; final appConfig = _appBloc.state.appConfig; if (appConfig == null) { - emit(successState.copyWith(errorMessage: 'App configuration not available for pagination.')); + emit( + successState.copyWith( + errorMessage: + 'App configuration not available for pagination.', + ), + ); return; } final injectedItems = _feedInjectorService.injectItems( @@ -108,14 +113,17 @@ class HeadlinesSearchBloc emit( successState.copyWith( items: List.of(successState.items)..addAll(injectedItems), - hasMore: response.hasMore, // hasMore from original headline fetch + hasMore: + response.hasMore, // hasMore from original headline fetch cursor: response.cursor, ), ); // Dispatch event if AccountAction was injected during pagination if (injectedItems.any((item) => item is AccountAction) && _appBloc.state.user?.id != null) { - _appBloc.add(AppUserAccountActionShown(userId: _appBloc.state.user!.id)); + _appBloc.add( + AppUserAccountActionShown(userId: _appBloc.state.user!.id), + ); } case SearchModelType.category: response = await _categoryRepository.readAllByQuery( @@ -125,12 +133,13 @@ class HeadlinesSearchBloc ); emit( successState.copyWith( - items: List.of(successState.items)..addAll(response.items.cast()), + items: List.of(successState.items) + ..addAll(response.items.cast()), hasMore: response.hasMore, cursor: response.cursor, ), ); -// Added break + // Added break case SearchModelType.source: response = await _sourceRepository.readAllByQuery( {'q': searchTerm, 'model': modelType.toJson()}, @@ -139,12 +148,13 @@ class HeadlinesSearchBloc ); emit( successState.copyWith( - items: List.of(successState.items)..addAll(response.items.cast()), + items: List.of(successState.items) + ..addAll(response.items.cast()), hasMore: response.hasMore, cursor: response.cursor, ), ); -// Added break + // Added break } } on HtHttpException catch (e) { emit(successState.copyWith(errorMessage: e.message)); @@ -171,10 +181,10 @@ class HeadlinesSearchBloc switch (modelType) { case SearchModelType.headline: - rawResponse = await _headlinesRepository.readAllByQuery( - {'q': searchTerm, 'model': modelType.toJson()}, - limit: _limit, - ); + rawResponse = await _headlinesRepository.readAllByQuery({ + 'q': searchTerm, + 'model': modelType.toJson(), + }, limit: _limit); final headlines = rawResponse.items.cast(); final currentUser = _appBloc.state.user; final appConfig = _appBloc.state.appConfig; @@ -195,21 +205,21 @@ class HeadlinesSearchBloc currentFeedItemCount: 0, ); case SearchModelType.category: - rawResponse = await _categoryRepository.readAllByQuery( - {'q': searchTerm, 'model': modelType.toJson()}, - limit: _limit, - ); + rawResponse = await _categoryRepository.readAllByQuery({ + 'q': searchTerm, + 'model': modelType.toJson(), + }, limit: _limit); processedItems = rawResponse.items.cast(); case SearchModelType.source: - rawResponse = await _sourceRepository.readAllByQuery( - {'q': searchTerm, 'model': modelType.toJson()}, - limit: _limit, - ); + rawResponse = await _sourceRepository.readAllByQuery({ + 'q': searchTerm, + 'model': modelType.toJson(), + }, limit: _limit); processedItems = rawResponse.items.cast(); } emit( HeadlinesSearchSuccess( - items: processedItems, + items: processedItems, hasMore: rawResponse.hasMore, cursor: rawResponse.cursor, lastSearchTerm: searchTerm, @@ -217,10 +227,12 @@ class HeadlinesSearchBloc ), ); // Dispatch event if AccountAction was injected in new search - if (modelType == SearchModelType.headline && + if (modelType == SearchModelType.headline && processedItems.any((item) => item is AccountAction) && _appBloc.state.user?.id != null) { - _appBloc.add(AppUserAccountActionShown(userId: _appBloc.state.user!.id)); + _appBloc.add( + AppUserAccountActionShown(userId: _appBloc.state.user!.id), + ); } } on HtHttpException catch (e) { emit( diff --git a/lib/headlines-search/view/headlines_search_page.dart b/lib/headlines-search/view/headlines_search_page.dart index 7b624710..6732696b 100644 --- a/lib/headlines-search/view/headlines_search_page.dart +++ b/lib/headlines-search/view/headlines_search_page.dart @@ -59,7 +59,7 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { }); // Ensure _selectedModelType is valid (it should be, as .country is removed from enum) if (!SearchModelType.values.contains(_selectedModelType)) { - _selectedModelType = SearchModelType.headline; + _selectedModelType = SearchModelType.headline; } context.read().add( HeadlinesSearchModelTypeChanged(_selectedModelType), @@ -110,9 +110,9 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { _selectedModelType = SearchModelType.headline; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { - context - .read() - .add(HeadlinesSearchModelTypeChanged(_selectedModelType)); + context.read().add( + HeadlinesSearchModelTypeChanged(_selectedModelType), + ); } }); } @@ -137,38 +137,43 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { isDense: true, // Make it more compact ), style: textTheme.titleMedium?.copyWith( - color: appBarTheme.titleTextStyle?.color ?? + color: + appBarTheme.titleTextStyle?.color ?? colorScheme.onSurface, ), dropdownColor: colorScheme.surfaceContainerHighest, icon: Icon( Icons.arrow_drop_down_rounded, // Rounded icon color: - appBarTheme.iconTheme?.color ?? colorScheme.onSurfaceVariant, + appBarTheme.iconTheme?.color ?? + colorScheme.onSurfaceVariant, ), - items: availableSearchModelTypes.map((SearchModelType type) { - String displayLocalizedName; - switch (type) { - case SearchModelType.headline: - displayLocalizedName = l10n.searchModelTypeHeadline; - case SearchModelType.category: - displayLocalizedName = l10n.searchModelTypeCategory; - case SearchModelType.source: - displayLocalizedName = l10n.searchModelTypeSource; - } - return DropdownMenuItem( - value: type, - child: Text(displayLocalizedName), // Style applied by DropdownButtonFormField - ); - }).toList(), + items: + availableSearchModelTypes.map((SearchModelType type) { + String displayLocalizedName; + switch (type) { + case SearchModelType.headline: + displayLocalizedName = l10n.searchModelTypeHeadline; + case SearchModelType.category: + displayLocalizedName = l10n.searchModelTypeCategory; + case SearchModelType.source: + displayLocalizedName = l10n.searchModelTypeSource; + } + return DropdownMenuItem( + value: type, + child: Text( + displayLocalizedName, + ), // Style applied by DropdownButtonFormField + ); + }).toList(), onChanged: (SearchModelType? newValue) { if (newValue != null) { setState(() { _selectedModelType = newValue; }); - context - .read() - .add(HeadlinesSearchModelTypeChanged(newValue)); + context.read().add( + HeadlinesSearchModelTypeChanged(newValue), + ); // Optionally trigger search or clear text when type changes // _textController.clear(); // _performSearch(); // Or wait for user to tap search @@ -180,7 +185,9 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { Expanded( child: TextField( controller: _textController, - style: appBarTheme.titleTextStyle ?? textTheme.titleMedium, // Consistent style + style: + appBarTheme.titleTextStyle ?? + textTheme.titleMedium, // Consistent style decoration: InputDecoration( hintText: _getHintTextForModelType(_selectedModelType, l10n), hintStyle: textTheme.bodyMedium?.copyWith( @@ -195,16 +202,18 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { horizontal: AppSpacing.md, // Standard padding vertical: AppSpacing.sm, // Adjusted ), - suffixIcon: _showClearButton - ? IconButton( - icon: Icon( - Icons.clear_rounded, // Rounded icon - color: appBarTheme.iconTheme?.color ?? - colorScheme.onSurfaceVariant, - ), - onPressed: _textController.clear, - ) - : null, + suffixIcon: + _showClearButton + ? IconButton( + icon: Icon( + Icons.clear_rounded, // Rounded icon + color: + appBarTheme.iconTheme?.color ?? + colorScheme.onSurfaceVariant, + ), + onPressed: _textController.clear, + ) + : null, ), onSubmitted: (_) => _performSearch(), ), @@ -229,16 +238,17 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { return switch (state) { HeadlinesSearchInitial() => InitialStateWidget( - icon: Icons.search_outlined, // Themed icon - headline: l10n.searchPageInitialHeadline, - subheadline: l10n.searchPageInitialSubheadline, - ), - HeadlinesSearchLoading() => LoadingStateWidget( // Use LoadingStateWidget - icon: Icons.search_outlined, // Themed icon - headline: l10n.headlinesFeedLoadingHeadline, // Re-use existing - subheadline: - 'Searching ${state.selectedModelType.displayName.toLowerCase()}...', - ), + icon: Icons.search_outlined, // Themed icon + headline: l10n.searchPageInitialHeadline, + subheadline: l10n.searchPageInitialSubheadline, + ), + HeadlinesSearchLoading() => LoadingStateWidget( + // Use LoadingStateWidget + icon: Icons.search_outlined, // Themed icon + headline: l10n.headlinesFeedLoadingHeadline, // Re-use existing + subheadline: + 'Searching ${state.selectedModelType.displayName.toLowerCase()}...', + ), HeadlinesSearchSuccess( items: final items, hasMore: final hasMore, @@ -248,188 +258,208 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { ) => errorMessage != null ? FailureStateWidget( - message: errorMessage, - onRetry: () => context.read().add( - HeadlinesSearchFetchRequested( - searchTerm: lastSearchTerm, - ), + message: errorMessage, + onRetry: + () => context.read().add( + HeadlinesSearchFetchRequested( + searchTerm: lastSearchTerm, ), - ) + ), + ) : items.isEmpty - ? FailureStateWidget( // Use FailureStateWidget for no results - message: - '${l10n.headlinesSearchNoResultsHeadline} for "$lastSearchTerm" in ${resultsModelType.displayName.toLowerCase()}.\n${l10n.headlinesSearchNoResultsSubheadline}', - // No retry button for "no results" - ) - : ListView.separated( - controller: _scrollController, - padding: const EdgeInsets.symmetric( // Consistent padding - horizontal: AppSpacing.paddingMedium, - vertical: AppSpacing.paddingSmall, - ).copyWith(bottom: AppSpacing.xxl), // Ensure bottom space - itemCount: - hasMore ? items.length + 1 : items.length, - separatorBuilder: (context, index) => - const SizedBox(height: AppSpacing.sm), // Consistent spacing - itemBuilder: (context, index) { - if (index >= items.length) { - return const Padding( - padding: EdgeInsets.symmetric( - vertical: AppSpacing.lg,), - child: - Center(child: CircularProgressIndicator()), - ); - } - final feedItem = items[index]; + ? FailureStateWidget( + // Use FailureStateWidget for no results + message: + '${l10n.headlinesSearchNoResultsHeadline} for "$lastSearchTerm" in ${resultsModelType.displayName.toLowerCase()}.\n${l10n.headlinesSearchNoResultsSubheadline}', + // No retry button for "no results" + ) + : ListView.separated( + controller: _scrollController, + padding: const EdgeInsets.symmetric( + // Consistent padding + horizontal: AppSpacing.paddingMedium, + vertical: AppSpacing.paddingSmall, + ).copyWith(bottom: AppSpacing.xxl), // Ensure bottom space + itemCount: hasMore ? items.length + 1 : items.length, + separatorBuilder: + (context, index) => const SizedBox( + height: AppSpacing.sm, + ), // Consistent spacing + itemBuilder: (context, index) { + if (index >= items.length) { + return const Padding( + padding: EdgeInsets.symmetric( + vertical: AppSpacing.lg, + ), + child: Center(child: CircularProgressIndicator()), + ); + } + final feedItem = items[index]; - if (feedItem is Headline) { - final imageStyle = context - .watch() - .state - .settings - .feedPreferences - .headlineImageStyle; - Widget tile; - switch (imageStyle) { - case HeadlineImageStyle.hidden: - tile = HeadlineTileTextOnly( - headline: feedItem, - onHeadlineTap: () => context.goNamed( - Routes.searchArticleDetailsName, - pathParameters: {'id': feedItem.id}, - extra: feedItem, - ), - ); - case HeadlineImageStyle.smallThumbnail: - tile = HeadlineTileImageStart( - headline: feedItem, - onHeadlineTap: () => context.goNamed( - Routes.searchArticleDetailsName, - pathParameters: {'id': feedItem.id}, - extra: feedItem, - ), - ); - case HeadlineImageStyle.largeThumbnail: - tile = HeadlineTileImageTop( - headline: feedItem, - onHeadlineTap: () => context.goNamed( - Routes.searchArticleDetailsName, - pathParameters: {'id': feedItem.id}, - extra: feedItem, - ), - ); - } - return tile; - } else if (feedItem is Category) { - return CategoryItemWidget(category: feedItem); - } else if (feedItem is Source) { - return SourceItemWidget(source: feedItem); - } else if (feedItem is Ad) { - return Card( - margin: const EdgeInsets.symmetric( - vertical: AppSpacing.xs,), - color: currentColorScheme.surfaceContainerHighest, - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - children: [ - if (feedItem.imageUrl.isNotEmpty) - ClipRRect( // Add ClipRRect for consistency - borderRadius: BorderRadius.circular(AppSpacing.xs), - child: Image.network( - feedItem.imageUrl, - height: 100, // Consistent height - fit: BoxFit.cover, - errorBuilder: (ctx, err, st) => - Icon( - Icons.broken_image_outlined, - size: AppSpacing.xxl, - color: currentColorScheme.onSurfaceVariant, - ), - ), - ), - const SizedBox(height: AppSpacing.sm), - Text( - 'Placeholder Ad: ${feedItem.adType?.name ?? 'Generic'}', - style: currentTextTheme.titleSmall, - ), - Text( - 'Placement: ${feedItem.placement?.name ?? 'Default'}', - style: currentTextTheme.bodySmall, - ), - ], + if (feedItem is Headline) { + final imageStyle = + context + .watch() + .state + .settings + .feedPreferences + .headlineImageStyle; + Widget tile; + switch (imageStyle) { + case HeadlineImageStyle.hidden: + tile = HeadlineTileTextOnly( + headline: feedItem, + onHeadlineTap: + () => context.goNamed( + Routes.searchArticleDetailsName, + pathParameters: {'id': feedItem.id}, + extra: feedItem, ), - ), - ); - } else if (feedItem is AccountAction) { - return Card( - margin: const EdgeInsets.symmetric( - vertical: AppSpacing.xs,), - color: currentColorScheme.secondaryContainer, - child: ListTile( - leading: Icon( - feedItem.accountActionType == - AccountActionType.linkAccount - ? Icons.link_outlined // Outlined - : Icons.upgrade_outlined, // Outlined - color: currentColorScheme.onSecondaryContainer, + ); + case HeadlineImageStyle.smallThumbnail: + tile = HeadlineTileImageStart( + headline: feedItem, + onHeadlineTap: + () => context.goNamed( + Routes.searchArticleDetailsName, + pathParameters: {'id': feedItem.id}, + extra: feedItem, ), - title: Text( - feedItem.title, - style: currentTextTheme.titleMedium - ?.copyWith( - color: currentColorScheme - .onSecondaryContainer, - fontWeight: FontWeight.bold, + ); + case HeadlineImageStyle.largeThumbnail: + tile = HeadlineTileImageTop( + headline: feedItem, + onHeadlineTap: + () => context.goNamed( + Routes.searchArticleDetailsName, + pathParameters: {'id': feedItem.id}, + extra: feedItem, + ), + ); + } + return tile; + } else if (feedItem is Category) { + return CategoryItemWidget(category: feedItem); + } else if (feedItem is Source) { + return SourceItemWidget(source: feedItem); + } else if (feedItem is Ad) { + return Card( + margin: const EdgeInsets.symmetric( + vertical: AppSpacing.xs, + ), + color: currentColorScheme.surfaceContainerHighest, + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + children: [ + if (feedItem.imageUrl.isNotEmpty) + ClipRRect( + // Add ClipRRect for consistency + borderRadius: BorderRadius.circular( + AppSpacing.xs, + ), + child: Image.network( + feedItem.imageUrl, + height: 100, // Consistent height + fit: BoxFit.cover, + errorBuilder: + (ctx, err, st) => Icon( + Icons.broken_image_outlined, + size: AppSpacing.xxl, + color: + currentColorScheme + .onSurfaceVariant, + ), ), ), - subtitle: feedItem.description != null - ? Text( - feedItem.description!, - style: currentTextTheme.bodySmall - ?.copyWith( + const SizedBox(height: AppSpacing.sm), + Text( + 'Placeholder Ad: ${feedItem.adType?.name ?? 'Generic'}', + style: currentTextTheme.titleSmall, + ), + Text( + 'Placement: ${feedItem.placement?.name ?? 'Default'}', + style: currentTextTheme.bodySmall, + ), + ], + ), + ), + ); + } else if (feedItem is AccountAction) { + return Card( + margin: const EdgeInsets.symmetric( + vertical: AppSpacing.xs, + ), + color: currentColorScheme.secondaryContainer, + child: ListTile( + leading: Icon( + feedItem.accountActionType == + AccountActionType.linkAccount + ? Icons + .link_outlined // Outlined + : Icons.upgrade_outlined, // Outlined + color: currentColorScheme.onSecondaryContainer, + ), + title: Text( + feedItem.title, + style: currentTextTheme.titleMedium?.copyWith( + color: currentColorScheme.onSecondaryContainer, + fontWeight: FontWeight.bold, + ), + ), + subtitle: + feedItem.description != null + ? Text( + feedItem.description!, + style: currentTextTheme.bodySmall + ?.copyWith( color: currentColorScheme .onSecondaryContainer - .withOpacity(0.85), // Adjusted opacity - ), - ) - : null, - trailing: feedItem.callToActionText != null - ? ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: - currentColorScheme.secondary, - foregroundColor: - currentColorScheme.onSecondary, - padding: const EdgeInsets.symmetric( // Consistent padding - horizontal: AppSpacing.md, - vertical: AppSpacing.sm, - ), - textStyle: currentTextTheme.labelLarge, + .withOpacity( + 0.85, + ), // Adjusted opacity ), - onPressed: () { - if (feedItem.callToActionUrl != - null) { - context.push( - feedItem.callToActionUrl!,); - } - }, - child: - Text(feedItem.callToActionText!), - ) - : null, - isThreeLine: feedItem.description != null && - feedItem.description!.length > 50, - contentPadding: const EdgeInsets.symmetric( // Consistent padding - horizontal: AppSpacing.paddingMedium, - vertical: AppSpacing.paddingSmall, - ), - ), - ); - } - return const SizedBox.shrink(); - }, - ), + ) + : null, + trailing: + feedItem.callToActionText != null + ? ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: + currentColorScheme.secondary, + foregroundColor: + currentColorScheme.onSecondary, + padding: const EdgeInsets.symmetric( + // Consistent padding + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + textStyle: currentTextTheme.labelLarge, + ), + onPressed: () { + if (feedItem.callToActionUrl != null) { + context.push( + feedItem.callToActionUrl!, + ); + } + }, + child: Text(feedItem.callToActionText!), + ) + : null, + isThreeLine: + feedItem.description != null && + feedItem.description!.length > 50, + contentPadding: const EdgeInsets.symmetric( + // Consistent padding + horizontal: AppSpacing.paddingMedium, + vertical: AppSpacing.paddingSmall, + ), + ), + ); + } + return const SizedBox.shrink(); + }, + ), HeadlinesSearchFailure( errorMessage: final errorMessage, lastSearchTerm: final lastSearchTerm, @@ -438,9 +468,9 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { FailureStateWidget( message: 'Failed to search "$lastSearchTerm" in ${failedModelType.displayName.toLowerCase()}:\n$errorMessage', // Improved message - onRetry: () => context.read().add( - HeadlinesSearchFetchRequested( - searchTerm: lastSearchTerm,), + onRetry: + () => context.read().add( + HeadlinesSearchFetchRequested(searchTerm: lastSearchTerm), ), ), _ => const SizedBox.shrink(), // Fallback for any other state diff --git a/lib/main.dart b/lib/main.dart index 60cb4770..70cd8b43 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,141 +1,20 @@ -import 'package:flutter/foundation.dart' show kIsWeb; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:ht_auth_api/ht_auth_api.dart'; -import 'package:ht_auth_repository/ht_auth_repository.dart'; -import 'package:ht_data_api/ht_data_api.dart'; -import 'package:ht_data_repository/ht_data_repository.dart'; -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; +import 'package:ht_main/app/config/config.dart' as app_config; +import 'package:ht_main/bootstrap.dart'; -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(); - - // 2. Declare Auth Repository (will be initialized after TokenProvider) - // This is necessary because the TokenProvider needs a reference to the - // authenticationRepository instance before it's fully initialized. - late final HtAuthRepository authenticationRepository; - - // 3. Define Token Provider - Future tokenProvider() async { - return authenticationRepository.getAuthToken(); - } - - // 4. Instantiate HTTP Client - final httpClient = HtHttpClient( - baseUrl: 'http://localhost:8080', - tokenProvider: tokenProvider, - isWeb: kIsWeb, - ); - - // 5. Instantiate Auth Client and Repository - // Concrete client implementation is HtAuthApi from ht_auth_api - final authClient = HtAuthApi(httpClient: httpClient); - // Initialize the authenticationRepository instance - authenticationRepository = HtAuthRepository( - authClient: authClient, - storageService: kvStorage, - ); - - // 6. Instantiate Data Clients and Repositories for each model type - // Concrete client implementation is HtDataApi from ht_data_api - // Each client needs the httpClient, a modelName string, and fromJson/toJson functions. +// Define the current application environment here. +// Change this value to switch between environments for local development. +const app_config.AppEnvironment currentEnvironment = + app_config + .AppEnvironment + .developmentInMemory; // Or .developmentApi, or .production - final headlinesClient = HtDataApi( - httpClient: httpClient, - modelName: 'headline', - fromJson: Headline.fromJson, - toJson: (headline) => headline.toJson(), - ); - final headlinesRepository = HtDataRepository( - dataClient: headlinesClient, - ); - - final categoriesClient = HtDataApi( - httpClient: httpClient, - modelName: 'category', - fromJson: Category.fromJson, - toJson: (category) => category.toJson(), - ); - final categoriesRepository = HtDataRepository( - dataClient: categoriesClient, - ); - - final countriesClient = HtDataApi( - httpClient: httpClient, - modelName: 'country', - fromJson: Country.fromJson, - toJson: (country) => country.toJson(), - ); - final countriesRepository = HtDataRepository( - dataClient: countriesClient, - ); - - final sourcesClient = HtDataApi( - httpClient: httpClient, - modelName: 'source', - fromJson: Source.fromJson, - toJson: (source) => source.toJson(), - ); - final sourcesRepository = HtDataRepository(dataClient: sourcesClient); - - final userContentPreferencesClient = HtDataApi( - httpClient: httpClient, - modelName: 'user_content_preferences', - fromJson: UserContentPreferences.fromJson, - toJson: (prefs) => prefs.toJson(), - ); - final userContentPreferencesRepository = - HtDataRepository( - dataClient: userContentPreferencesClient, - ); - - final userAppSettingsClient = HtDataApi( - httpClient: httpClient, - modelName: 'user_app_settings', - fromJson: UserAppSettings.fromJson, - toJson: (settings) => settings.toJson(), - ); - final userAppSettingsRepository = HtDataRepository( - dataClient: userAppSettingsClient, - ); - - final appConfigClient = HtDataApi( - httpClient: httpClient, - modelName: 'app_config', - fromJson: AppConfig.fromJson, - toJson: (config) => config.toJson(), - ); - final appConfigRepository = HtDataRepository( - dataClient: appConfigClient, - ); - - // 7. Run the App, injecting repositories - runApp( - App( - htAuthenticationRepository: authenticationRepository, - htHeadlinesRepository: headlinesRepository, - htCategoriesRepository: categoriesRepository, - htCountriesRepository: countriesRepository, - htSourcesRepository: sourcesRepository, - htUserAppSettingsRepository: userAppSettingsRepository, - htUserContentPreferencesRepository: userContentPreferencesRepository, - htAppConfigRepository: appConfigRepository, - kvStorageService: kvStorage, - ), - ); +void main() async { + final appConfig = switch (currentEnvironment) { + app_config.AppEnvironment.production => app_config.AppConfig.production(), + app_config.AppEnvironment.developmentInMemory => + app_config.AppConfig.developmentInMemory(), + app_config.AppEnvironment.developmentApi => + app_config.AppConfig.developmentApi(), + }; + await bootstrap(appConfig); } diff --git a/lib/router/router.dart b/lib/router/router.dart index d5f89e2f..cbe82da3 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -76,7 +76,8 @@ GoRouter createRouter({ // --- Redirect Logic --- redirect: (BuildContext context, GoRouterState state) { final appStatus = context.read().state.status; - final appConfig = context.read().state.appConfig; // Get appConfig + final appConfig = + context.read().state.appConfig; // Get appConfig final currentLocation = state.matchedLocation; final currentUri = state.uri; @@ -97,7 +98,6 @@ GoRouter createRouter({ if (appStatus == AppStatus.initial || appStatus == AppStatus.configFetching || appStatus == AppStatus.configFetchFailed) { - // If AppStatus is initial and trying to go to a non-auth page (e.g. initial /feed) // redirect to auth immediately to settle auth status first. if (appStatus == AppStatus.initial && !isGoingToAuth) { @@ -111,14 +111,16 @@ GoRouter createRouter({ print( ' Redirect Decision: AppStatus is $appStatus. Allowing App widget to handle display or navigation to auth.', ); - return null; + return null; } // --- Case 1: Unauthenticated User (after initial phase, config not relevant yet for this decision) --- if (appStatus == AppStatus.unauthenticated) { print(' Redirect Decision: User is UNauthenticated.'); if (!isGoingToAuth) { - print(' Action: Not going to auth. Redirecting to $authenticationPath'); + print( + ' Action: Not going to auth. Redirecting to $authenticationPath', + ); return authenticationPath; } print(' Action: Already going to auth. Allowing navigation.'); @@ -129,10 +131,12 @@ GoRouter createRouter({ // (Covers AppStatus.anonymous and AppStatus.authenticated) // At this point, AppConfig should be loaded or its loading/error state is handled by App widget. // The main concern here is preventing authenticated users from re-entering basic auth flows. - if (appStatus == AppStatus.anonymous || appStatus == AppStatus.authenticated) { + if (appStatus == AppStatus.anonymous || + appStatus == AppStatus.authenticated) { print(' Redirect Decision: User is $appStatus.'); - - final isLinkingContext = currentUri.queryParameters['context'] == 'linking'; + + final isLinkingContext = + currentUri.queryParameters['context'] == 'linking'; // If an authenticated/anonymous user tries to access the BASE /authentication path // AND it's NOT for account linking, redirect them to the feed. @@ -142,14 +146,18 @@ GoRouter createRouter({ ); return feedPath; } - + // Allow access to other routes (including auth sub-routes if linking, or any other app route) - print(' Action: Allowing navigation to $currentLocation for $appStatus user.'); + print( + ' Action: Allowing navigation to $currentLocation for $appStatus user.', + ); return null; } // Fallback (should ideally not be reached if all statuses are handled) - print(' Redirect Decision: Fallback, no specific condition met for $appStatus. Allowing navigation.'); + print( + ' Redirect Decision: Fallback, no specific condition met for $appStatus. Allowing navigation.', + ); return null; }, // --- Authentication Routes --- diff --git a/lib/shared/services/feed_injector_service.dart b/lib/shared/services/feed_injector_service.dart index 465c9285..4c98ca2d 100644 --- a/lib/shared/services/feed_injector_service.dart +++ b/lib/shared/services/feed_injector_service.dart @@ -64,7 +64,7 @@ class FeedInjectorService { final headline = headlines[i]; finalFeed.add(headline); headlinesInThisBatchCount++; - + final totalItemsSoFar = currentFeedItemCount + finalFeed.length; // 1. Inject AccountAction (if due and not already injected in this batch) @@ -96,7 +96,8 @@ class FeedInjectorService { required User? user, required AppConfig appConfig, }) { - final userRole = user?.role ?? UserRole.guestUser; // Default to guest if user is null + final userRole = + user?.role ?? UserRole.guestUser; // Default to guest if user is null final now = DateTime.now(); final lastActionShown = user?.lastAccountActionShownAt; final daysBetweenActionsConfig = appConfig.accountActionConfig; @@ -109,7 +110,8 @@ class FeedInjectorService { actionType = AccountActionType.linkAccount; } else if (userRole == UserRole.standardUser) { // Assuming standardUser is the target for upgrade prompts - daysThreshold = daysBetweenActionsConfig.standardUserDaysBetweenAccountActions; + daysThreshold = + daysBetweenActionsConfig.standardUserDaysBetweenAccountActions; actionType = AccountActionType.upgrade; } else { // No account actions for premium users or other roles for now @@ -135,7 +137,7 @@ class FeedInjectorService { String title; String description; - var ctaText = 'Learn More'; + var ctaText = 'Learn More'; switch (variant) { case 0: @@ -174,7 +176,7 @@ class FeedInjectorService { String title; String description; - var ctaText = 'Explore Premium'; + var ctaText = 'Explore Premium'; switch (variant) { case 0: @@ -211,11 +213,12 @@ class FeedInjectorService { return Ad( // id is generated by model if not provided - imageUrl: 'https://via.placeholder.com/300x100.png/000000/FFFFFF?Text=Native+Placeholder+Ad', // Adjusted placeholder + imageUrl: + 'https://via.placeholder.com/300x100.png/000000/FFFFFF?Text=Native+Placeholder+Ad', // Adjusted placeholder targetUrl: 'https://example.com/adtarget', adType: AdType.native, // Always native // Default placement or random from native-compatible placements - placement: AdPlacement.feedInlineNativeBanner, + placement: AdPlacement.feedInlineNativeBanner, ); } } diff --git a/lib/shared/widgets/headline_tile_image_start.dart b/lib/shared/widgets/headline_tile_image_start.dart index 8b728795..ae86507d 100644 --- a/lib/shared/widgets/headline_tile_image_start.dart +++ b/lib/shared/widgets/headline_tile_image_start.dart @@ -203,10 +203,7 @@ class _HeadlineMetadataRow extends StatelessWidget { ); } }, - child: Text( - headline.category!.name, - style: metadataTextStyle, - ), + child: Text(headline.category!.name, style: metadataTextStyle), ), ], // Conditionally render Source as Text @@ -230,10 +227,7 @@ class _HeadlineMetadataRow extends StatelessWidget { ); } }, - child: Text( - headline.source!.name, - style: metadataTextStyle, - ), + child: Text(headline.source!.name, style: metadataTextStyle), ), ], ], diff --git a/lib/shared/widgets/headline_tile_image_top.dart b/lib/shared/widgets/headline_tile_image_top.dart index 76b4aadf..52ac814c 100644 --- a/lib/shared/widgets/headline_tile_image_top.dart +++ b/lib/shared/widgets/headline_tile_image_top.dart @@ -214,10 +214,7 @@ class _HeadlineMetadataRow extends StatelessWidget { ); } }, - child: Text( - headline.category!.name, - style: metadataTextStyle, - ), + child: Text(headline.category!.name, style: metadataTextStyle), ), ], // Conditionally render Source as Text @@ -241,10 +238,7 @@ class _HeadlineMetadataRow extends StatelessWidget { ); } }, - child: Text( - headline.source!.name, - style: metadataTextStyle, - ), + child: Text(headline.source!.name, style: metadataTextStyle), ), ], ], diff --git a/lib/shared/widgets/headline_tile_text_only.dart b/lib/shared/widgets/headline_tile_text_only.dart index c5572186..cc12a322 100644 --- a/lib/shared/widgets/headline_tile_text_only.dart +++ b/lib/shared/widgets/headline_tile_text_only.dart @@ -163,10 +163,7 @@ class _HeadlineMetadataRow extends StatelessWidget { ); } }, - child: Text( - headline.category!.name, - style: metadataTextStyle, - ), + child: Text(headline.category!.name, style: metadataTextStyle), ), ], // Conditionally render Source as Text @@ -190,10 +187,7 @@ class _HeadlineMetadataRow extends StatelessWidget { ); } }, - child: Text( - headline.source!.name, - style: metadataTextStyle, - ), + child: Text(headline.source!.name, style: metadataTextStyle), ), ], ], diff --git a/pubspec.lock b/pubspec.lock index a01d5e4d..f4030119 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -362,6 +362,15 @@ packages: url: "https://github.com/headlines-toolkit/ht-data-client.git" source: git version: "0.0.0" + ht_data_inmemory: + dependency: "direct main" + description: + path: "." + ref: HEAD + resolved-ref: "6310e0561f9c27af5719aa7a2b95225f38776595" + url: "https://github.com/headlines-toolkit/ht-data-inmemory.git" + source: git + version: "0.0.0" ht_data_repository: dependency: "direct main" description: @@ -403,7 +412,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: d309fa8da138a706d22fe78c73046fb2cede06e5 + resolved-ref: "2caadf1dc7cfa476cc062f20247986c420116b62" url: "https://github.com/headlines-toolkit/ht-shared.git" source: git version: "0.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 68381f13..ff62b8fe 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,6 +35,9 @@ dependencies: ht_data_client: git: url: https://github.com/headlines-toolkit/ht-data-client.git + ht_data_inmemory: + git: + url: https://github.com/headlines-toolkit/ht-data-inmemory.git ht_data_repository: git: url: https://github.com/headlines-toolkit/ht-data-repository.git