diff --git a/l10n.yaml b/l10n.yaml index f88aa438..f45d6674 100644 --- a/l10n.yaml +++ b/l10n.yaml @@ -1,4 +1,5 @@ arb-dir: lib/l10n/arb template-arb-file: app_en.arb output-localization-file: app_localizations.dart -nullable-getter: false \ No newline at end of file +output-dir: lib/l10n +nullable-getter: false diff --git a/lib/account/view/account_page.dart b/lib/account/view/account_page.dart index 2b35771d..27b5752e 100644 --- a/lib/account/view/account_page.dart +++ b/lib/account/view/account_page.dart @@ -22,105 +22,122 @@ class AccountPage extends StatelessWidget { // Watch AppBloc for user details and authentication status final appState = context.watch().state; final user = appState.user; - final status = appState.status; // Use AppStatus from AppBloc state - - // Determine if the user is anonymous - final isAnonymous = - status == AppStatus.anonymous; // Use AppStatus.anonymous + final status = appState.status; + final isAnonymous = status == AppStatus.anonymous; + final theme = Theme.of(context); // Get theme for AppBar + final textTheme = theme.textTheme; // Get textTheme for AppBar return Scaffold( - appBar: AppBar(title: Text(l10n.accountPageTitle)), + appBar: AppBar( + title: Text( + l10n.accountPageTitle, + style: textTheme.titleLarge, // Consistent AppBar title style + ), + ), body: ListView( - // Use ListView for potential scrolling if content grows - padding: const EdgeInsets.all(AppSpacing.lg), // Use AppSpacing + padding: const EdgeInsets.all(AppSpacing.paddingMedium), // Adjusted padding children: [ - // --- User Header --- _buildUserHeader(context, user, isAnonymous), - const SizedBox(height: AppSpacing.xl), // Use AppSpacing - // --- Action Tiles --- - // Content Preferences Tile + const SizedBox(height: AppSpacing.lg), // Adjusted spacing ListTile( - leading: const Icon(Icons.tune_outlined), - title: Text(l10n.accountContentPreferencesTile), + leading: Icon(Icons.tune_outlined, color: theme.colorScheme.primary), + title: Text( + l10n.accountContentPreferencesTile, + style: textTheme.titleMedium, + ), trailing: const Icon(Icons.chevron_right), onTap: () { - context.goNamed(Routes.manageFollowedItemsName); // Updated route + context.goNamed(Routes.manageFollowedItemsName); }, ), - const Divider(), // Divider after Content Preferences - // Saved Headlines Tile + const Divider(indent: AppSpacing.paddingMedium, endIndent: AppSpacing.paddingMedium), ListTile( - leading: const Icon(Icons.bookmark_outline), - title: Text(l10n.accountSavedHeadlinesTile), + leading: Icon(Icons.bookmark_outline, color: theme.colorScheme.primary), + title: Text( + l10n.accountSavedHeadlinesTile, + style: textTheme.titleMedium, + ), trailing: const Icon(Icons.chevron_right), onTap: () { context.goNamed(Routes.accountSavedHeadlinesName); }, ), - const Divider(), // Divider after Saved Headlines - // Settings Tile + const Divider(indent: AppSpacing.paddingMedium, endIndent: AppSpacing.paddingMedium), _buildSettingsTile(context), - const Divider(), // Divider after settings + const Divider(indent: AppSpacing.paddingMedium, endIndent: AppSpacing.paddingMedium), ], ), ); } - /// Builds the header section displaying user avatar, name, and status. Widget _buildUserHeader(BuildContext context, User? user, bool isAnonymous) { final l10n = context.l10n; final theme = Theme.of(context); final textTheme = theme.textTheme; + final colorScheme = theme.colorScheme; // Get colorScheme - // Use a generic icon for the avatar - const avatarIcon = Icon(Icons.person, size: 40); + final avatarIcon = Icon( + Icons.person_outline, // Use outlined version + size: AppSpacing.xxl, // Standardized size + color: colorScheme.onPrimaryContainer, + ); - // Determine display name and status text final String displayName; final Widget statusWidget; if (isAnonymous) { displayName = l10n.accountAnonymousUser; statusWidget = Padding( - padding: const EdgeInsets.only(top: AppSpacing.sm), - child: TextButton( + padding: const EdgeInsets.only(top: AppSpacing.md), // Increased padding + 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, + ), + textStyle: textTheme.labelLarge, + ), onPressed: () { - // Navigate to the authentication page in linking mode context.goNamed( Routes.authenticationName, queryParameters: {'context': 'linking'}, ); }, - child: Text(l10n.accountSignInPromptButton), ), ); } else { - // For authenticated users, display email and role displayName = user?.email ?? l10n.accountNoNameUser; statusWidget = Column( + mainAxisSize: MainAxisSize.min, // To keep column tight children: [ - const SizedBox(height: AppSpacing.sm), - Text( - l10n.accountRoleLabel(user?.role.name ?? 'unknown'), // Display role - style: textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + if (user?.role != null) ...[ // Show role only if available + const SizedBox(height: AppSpacing.xs), + Text( + l10n.accountRoleLabel(user!.role.name), + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, - ), - const SizedBox(height: AppSpacing.sm), - OutlinedButton( + ], + const SizedBox(height: AppSpacing.md), // Consistent spacing + OutlinedButton.icon( // Changed to OutlinedButton.icon + icon: Icon(Icons.logout, color: colorScheme.error), + label: Text(l10n.accountSignOutTile), style: OutlinedButton.styleFrom( - foregroundColor: theme.colorScheme.error, - side: BorderSide(color: theme.colorScheme.error), + foregroundColor: colorScheme.error, + side: BorderSide(color: colorScheme.error.withOpacity(0.5)), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, vertical: AppSpacing.sm, + ), + textStyle: textTheme.labelLarge, ), onPressed: () { - // Dispatch AuthenticationSignOutRequested from Auth Bloc - context.read().add( - const AuthenticationSignOutRequested(), - ); - // Global redirect will be handled by AppBloc/GoRouter + context + .read() + .add(const AuthenticationSignOutRequested()); }, - child: Text(l10n.accountSignOutTile), ), ], ); @@ -129,30 +146,31 @@ class AccountPage extends StatelessWidget { return Column( children: [ CircleAvatar( - radius: 40, - backgroundColor: theme.colorScheme.primaryContainer, + radius: AppSpacing.xxl - AppSpacing.sm, // Standardized radius (40) + backgroundColor: colorScheme.primaryContainer, child: avatarIcon, ), - const SizedBox(height: AppSpacing.lg), // Use AppSpacing + const SizedBox(height: AppSpacing.md), // Adjusted spacing Text( displayName, - style: textTheme.titleLarge, + style: textTheme.headlineSmall, // More prominent style textAlign: TextAlign.center, ), - statusWidget, // Display sign-in button or role/logout button + statusWidget, ], ); } - /// Builds the ListTile for navigating to Settings. Widget _buildSettingsTile(BuildContext context) { final l10n = context.l10n; + final theme = Theme.of(context); + final textTheme = theme.textTheme; + return ListTile( - leading: const Icon(Icons.settings_outlined), - title: Text(l10n.accountSettingsTile), + leading: Icon(Icons.settings_outlined, color: theme.colorScheme.primary), + title: Text(l10n.accountSettingsTile, style: textTheme.titleMedium), trailing: const Icon(Icons.chevron_right), onTap: () { - // Navigate to the existing settings route context.goNamed(Routes.settingsName); }, ); 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 c159e0fb..44251617 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 @@ -18,19 +18,32 @@ class AddCategoryToFollowPage extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = context.l10n; + final theme = Theme.of(context); // Get theme + 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(l10n.addCategoriesPageTitle)), + appBar: AppBar( + title: Text( + l10n.addCategoriesPageTitle, + style: textTheme.titleLarge, // Consistent AppBar title + ), + ), body: BlocBuilder( builder: (context, categoriesState) { - if (categoriesState.status == CategoriesFilterStatus.loading) { - return const Center(child: CircularProgressIndicator()); + if (categoriesState.status == CategoriesFilterStatus.loading && + categoriesState.categories.isEmpty) { // Show full loading only if list is empty + return LoadingStateWidget( + icon: Icons.category_outlined, + headline: l10n.categoryFilterLoadingHeadline, + subheadline: l10n.categoryFilterLoadingSubheadline, + ); } - if (categoriesState.status == CategoriesFilterStatus.failure) { + if (categoriesState.status == CategoriesFilterStatus.failure && + categoriesState.categories.isEmpty) { // Show full error only if list is empty var errorMessage = l10n.categoryFilterError; if (categoriesState.error is HtHttpException) { errorMessage = @@ -40,78 +53,115 @@ class AddCategoryToFollowPage extends StatelessWidget { } return FailureStateWidget( message: errorMessage, - onRetry: - () => context.read().add( - CategoriesFilterRequested(), - ), + onRetry: () => context + .read() + .add(CategoriesFilterRequested()), ); } - if (categoriesState.categories.isEmpty) { - return FailureStateWidget( - message: l10n.categoryFilterEmptyHeadline, + if (categoriesState.categories.isEmpty && + 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, ); } + // Handle loading more at the bottom or list display + final categories = categoriesState.categories; + 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.all(AppSpacing.md), - itemCount: categoriesState.categories.length, + padding: const EdgeInsets.symmetric( // Consistent padding + horizontal: AppSpacing.paddingMedium, + vertical: AppSpacing.paddingSmall, + ).copyWith(bottom: AppSpacing.xxl), // Ensure bottom space for loader + itemCount: categories.length + (isLoadingMore ? 1 : 0), itemBuilder: (context, index) { - final category = categoriesState.categories[index]; - final isFollowed = followedCategories.any( - (fc) => fc.id == category.id, - ); + if (index == categories.length && isLoadingMore) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: AppSpacing.lg), + child: Center(child: CircularProgressIndicator()), + ); + } + if (index >= categories.length) return const SizedBox.shrink(); + + final category = categories[index]; + final isFollowed = + followedCategories.any((fc) => fc.id == category.id); + final colorScheme = Theme.of(context).colorScheme; return Card( margin: const EdgeInsets.only(bottom: AppSpacing.sm), + elevation: 0.5, // Subtle elevation + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppSpacing.sm), + side: BorderSide(color: colorScheme.outlineVariant.withOpacity(0.3)), + ), child: ListTile( - leading: - category.iconUrl != null && - Uri.tryParse( - category.iconUrl!, - )?.isAbsolute == - true - ? SizedBox( - width: 36, - height: 36, + 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) => - const Icon(Icons.category_outlined), + 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, + ), + ); + }, ), ) - : const Icon(Icons.category_outlined), - title: Text(category.name), + : Icon( + Icons.category_outlined, + color: colorScheme.onSurfaceVariant, + size: AppSpacing.lg, + ), + ), + title: Text(category.name, style: textTheme.titleMedium), trailing: IconButton( - icon: - isFollowed - ? Icon( - Icons.check_circle, - color: - Theme.of(context).colorScheme.primary, - ) - : const Icon(Icons.add_circle_outline), - 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 + horizontal: AppSpacing.paddingMedium, + vertical: AppSpacing.xs, + ), ), ); }, 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 d77075db..57e6305c 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 @@ -15,34 +15,50 @@ class ManageFollowedItemsPage extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = context.l10n; + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final colorScheme = theme.colorScheme; return Scaffold( appBar: AppBar( title: Text( l10n.accountContentPreferencesTile, - ), // "Content Preferences" + style: textTheme.titleLarge, // Consistent AppBar title style + ), ), body: ListView( - padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), + padding: const EdgeInsets.symmetric(vertical: AppSpacing.paddingSmall), // Adjusted padding children: [ ListTile( - leading: const Icon(Icons.category_outlined), - title: Text(l10n.headlinesFeedFilterCategoryLabel), // "Categories" + leading: Icon(Icons.category_outlined, color: colorScheme.primary), + title: Text( + l10n.headlinesFeedFilterCategoryLabel, // "Categories" + style: textTheme.titleMedium, + ), trailing: const Icon(Icons.chevron_right), onTap: () { context.goNamed(Routes.followedCategoriesListName); }, ), - const Divider(indent: AppSpacing.lg, endIndent: AppSpacing.lg), + const Divider( + indent: AppSpacing.paddingMedium, // Consistent indent + endIndent: AppSpacing.paddingMedium, + ), ListTile( - leading: const Icon(Icons.source_outlined), - title: Text(l10n.headlinesFeedFilterSourceLabel), // "Sources" + leading: Icon(Icons.source_outlined, color: colorScheme.primary), + title: Text( + l10n.headlinesFeedFilterSourceLabel, // "Sources" + style: textTheme.titleMedium, + ), trailing: const Icon(Icons.chevron_right), onTap: () { context.goNamed(Routes.followedSourcesListName); }, ), - const Divider(indent: AppSpacing.lg, endIndent: AppSpacing.lg), + const Divider( + indent: AppSpacing.paddingMedium, // Consistent indent + endIndent: AppSpacing.paddingMedium, + ), // ListTile for Followed Countries removed ], ), diff --git a/lib/account/view/saved_headlines_page.dart b/lib/account/view/saved_headlines_page.dart index 91a954c0..369c4223 100644 --- a/lib/account/view/saved_headlines_page.dart +++ b/lib/account/view/saved_headlines_page.dart @@ -23,31 +23,34 @@ class SavedHeadlinesPage extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = context.l10n; + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final colorScheme = theme.colorScheme; return Scaffold( - appBar: AppBar(title: Text(l10n.accountSavedHeadlinesTile)), + appBar: AppBar( + title: Text( + l10n.accountSavedHeadlinesTile, + style: textTheme.titleLarge, // Consistent AppBar title + ), + ), body: BlocBuilder( builder: (context, state) { - // Initial load or loading state for preferences if (state.status == AccountStatus.loading && state.preferences == null) { - return const LoadingStateWidget( + return LoadingStateWidget( icon: Icons.bookmarks_outlined, - headline: 'Loading Saved Headlines...', // Placeholder - subheadline: - 'Please wait while we fetch your saved articles.', // Placeholder + headline: l10n.savedHeadlinesLoadingHeadline, // Use l10n + subheadline: l10n.savedHeadlinesLoadingSubheadline, // Use l10n ); } - // Failure to load preferences if (state.status == AccountStatus.failure && state.preferences == null) { return FailureStateWidget( - message: - state.errorMessage ?? - 'Could not load saved headlines.', // Placeholder + message: state.errorMessage ?? l10n.savedHeadlinesErrorHeadline, // Use l10n onRetry: () { if (state.user?.id != null) { context.read().add( - AccountLoadUserPreferences( // Corrected event name + AccountLoadUserPreferences( userId: state.user!.id, ), ); @@ -59,34 +62,37 @@ class SavedHeadlinesPage extends StatelessWidget { final savedHeadlines = state.preferences?.savedHeadlines ?? []; if (savedHeadlines.isEmpty) { - return const InitialStateWidget( + return InitialStateWidget( icon: Icons.bookmark_add_outlined, - headline: 'No Saved Headlines', // Placeholder - Reverted - subheadline: - "You haven't saved any articles yet. Start exploring!", // Placeholder - Reverted + headline: l10n.savedHeadlinesEmptyHeadline, // Use l10n + subheadline: l10n.savedHeadlinesEmptySubheadline, // Use l10n ); } return ListView.separated( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.paddingSmall), // Add padding itemCount: savedHeadlines.length, - separatorBuilder: (context, index) => const Divider(height: 1), + separatorBuilder: (context, index) => 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: const Icon(Icons.delete_outline), - tooltip: l10n.headlineDetailsRemoveFromSavedTooltip, // Use l10n + 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/authentication/view/authentication_page.dart b/lib/authentication/view/authentication_page.dart index 878d7718..e9ec5ad4 100644 --- a/lib/authentication/view/authentication_page.dart +++ b/lib/authentication/view/authentication_page.dart @@ -97,76 +97,80 @@ class AuthenticationPage extends StatelessWidget { MainAxisAlignment.center, // Center vertically crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // --- Hardcoded Icon --- + // --- Icon --- Padding( - padding: const EdgeInsets.only( - bottom: AppSpacing.xl, - ), // Spacing below icon + padding: const EdgeInsets.only(bottom: AppSpacing.xl), child: Icon( - Icons.security, // Hardcode the icon - size: - (Theme.of(context).iconTheme.size ?? - AppSpacing.xl) * - 3.0, - color: Theme.of(context).colorScheme.primary, + Icons.security, + size: AppSpacing.xxl * 2, // Standardized large icon + color: colorScheme.primary, ), ), - const SizedBox( - height: AppSpacing.lg, - ), // Space between icon and headline + // const SizedBox(height: AppSpacing.lg), // Removed, padding above handles it // --- Headline and Subheadline --- Text( headline, - style: textTheme.headlineMedium, + style: textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, // Ensure prominence + ), textAlign: TextAlign.center, ), - const SizedBox(height: AppSpacing.sm), + const SizedBox(height: AppSpacing.md), // Increased spacing Text( subHeadline, - style: textTheme.bodyLarge, + style: textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, // Softer color + ), textAlign: TextAlign.center, ), const SizedBox(height: AppSpacing.xxl), + // --- Email Sign-In Button --- - ElevatedButton( - // Consider an email icon - // icon: const Icon(Icons.email_outlined), - onPressed: - isLoading - ? null - : () { - // Navigate to the dedicated email sign-in page, - // passing the linking context via 'extra'. - context.goNamed( - Routes.requestCodeName, - extra: isLinkingContext, - ); - }, - // Consider an email icon - // icon: const Icon(Icons.email_outlined), - child: Text(l10n.authenticationEmailSignInButton), + ElevatedButton.icon( + icon: const Icon(Icons.email_outlined), + onPressed: isLoading + ? null + : () { + context.goNamed( + Routes.requestCodeName, + extra: isLinkingContext, + ); + }, + label: Text(l10n.authenticationEmailSignInButton), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.md, + ), + textStyle: textTheme.labelLarge, + ), ), + const SizedBox(height: AppSpacing.lg), // --- Anonymous Sign-In Button (Conditional) --- if (showAnonymousButton) ...[ - const SizedBox(height: AppSpacing.lg), - OutlinedButton( - onPressed: - isLoading - ? null - : () => context.read().add( + OutlinedButton.icon( + icon: const Icon(Icons.person_outline), + onPressed: isLoading + ? null + : () => context.read().add( const AuthenticationAnonymousSignInRequested(), ), - child: Text(l10n.authenticationAnonymousSignInButton), + label: Text(l10n.authenticationAnonymousSignInButton), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.md, + ), + textStyle: textTheme.labelLarge, + ), ), ], - // --- Loading Indicator (Optional, for general loading state) --- - // If needed, show a general loading indicator when state is AuthenticationLoading - if (isLoading && - state is! AuthenticationRequestCodeLoading) ...[ - const SizedBox(height: AppSpacing.xl), - const Center(child: CircularProgressIndicator()), + // --- Loading Indicator --- + 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 9c356a49..734f2da3 100644 --- a/lib/authentication/view/email_code_verification_page.dart +++ b/lib/authentication/view/email_code_verification_page.dart @@ -48,21 +48,30 @@ class EmailCodeVerificationPage extends StatelessWidget { child: SingleChildScrollView( child: Column( mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, // Stretch buttons children: [ - const Icon(Icons.mark_email_read_outlined, size: 80), + Icon( + Icons.mark_email_read_outlined, + size: AppSpacing.xxl * 2, // Standardized large icon + color: colorScheme.primary, + ), const SizedBox(height: AppSpacing.xl), Text( l10n.emailCodeSentConfirmation(email), - style: textTheme.titleLarge, + style: textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, // Ensure prominence + ), textAlign: TextAlign.center, ), - const SizedBox(height: AppSpacing.xxl), + const SizedBox(height: AppSpacing.lg), // Adjusted spacing Text( l10n.emailCodeSentInstructions, - style: textTheme.bodyMedium, + style: textTheme.bodyLarge?.copyWith( + color: colorScheme + .onSurfaceVariant), // Softer color textAlign: TextAlign.center, ), - const SizedBox(height: AppSpacing.lg), + const SizedBox(height: AppSpacing.xl), // Increased spacing _EmailCodeVerificationForm( email: email, isLoading: isLoading, @@ -118,6 +127,7 @@ class _EmailCodeVerificationFormState @override Widget build(BuildContext context) { final l10n = context.l10n; + final textTheme = Theme.of(context).textTheme; // Added missing textTheme return Form( key: _formKey, @@ -125,18 +135,21 @@ class _EmailCodeVerificationFormState mainAxisSize: MainAxisSize.min, children: [ Padding( - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + // No horizontal padding needed if column is stretched + // padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + padding: EdgeInsets.zero, child: TextFormField( controller: _codeController, decoration: InputDecoration( - hintText: l10n.emailCodeVerificationHint, - border: const OutlineInputBorder(), + labelText: l10n.emailCodeVerificationHint, // Use labelText + // border: const OutlineInputBorder(), // Uses theme default counterText: '', // Hide the counter ), keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], maxLength: 6, textAlign: TextAlign.center, + style: textTheme.headlineSmall, // Make input text larger enabled: !widget.isLoading, validator: (value) { if (value == null || value.isEmpty) { @@ -150,17 +163,23 @@ class _EmailCodeVerificationFormState onFieldSubmitted: widget.isLoading ? null : (_) => _submitForm(), ), ), - const SizedBox(height: AppSpacing.xl), + const SizedBox(height: AppSpacing.xxl), // Increased spacing ElevatedButton( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), + textStyle: textTheme.labelLarge, + ), onPressed: widget.isLoading ? null : _submitForm, - child: - widget.isLoading - ? const SizedBox( - height: 24, - width: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : 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 88e3b216..b35d1072 100644 --- a/lib/authentication/view/request_code_page.dart +++ b/lib/authentication/view/request_code_page.dart @@ -42,6 +42,7 @@ class _RequestCodeView extends StatelessWidget { Widget build(BuildContext context) { final l10n = context.l10n; final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; // Added textTheme return Scaffold( appBar: AppBar( @@ -106,27 +107,29 @@ class _RequestCodeView extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // --- Hardcoded Icon --- + // --- Icon --- Padding( - padding: const EdgeInsets.only( - bottom: AppSpacing.xl, - ), // Spacing below icon + padding: const EdgeInsets.only(bottom: AppSpacing.xl), child: Icon( - Icons.email_outlined, // Hardcoded icon - size: - (Theme.of(context).iconTheme.size ?? - AppSpacing.xl) * - 3.0, - color: Theme.of(context).colorScheme.primary, + Icons.email_outlined, + size: AppSpacing.xxl * 2, // Standardized large icon + color: colorScheme.primary, ), ), - const SizedBox( - height: AppSpacing.lg, - ), // Space between icon and text + // const SizedBox(height: AppSpacing.lg), // Removed // --- Explanation Text --- Text( - l10n.emailSignInExplanation, - style: Theme.of(context).textTheme.bodyLarge, + l10n.requestCodePageHeadline, // Using a more title-like l10n key + style: textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.md), + Text( + l10n.requestCodePageSubheadline, // Using a more descriptive subheadline + style: textTheme.bodyLarge + ?.copyWith(color: colorScheme.onSurfaceVariant), textAlign: TextAlign.center, ), const SizedBox(height: AppSpacing.xxl), @@ -177,6 +180,8 @@ class _EmailLinkFormState extends State<_EmailLinkForm> { @override Widget build(BuildContext context) { final l10n = context.l10n; + final textTheme = Theme.of(context).textTheme; // Added textTheme + final colorScheme = Theme.of(context).colorScheme; // Added colorScheme return Form( key: _formKey, @@ -186,9 +191,9 @@ class _EmailLinkFormState extends State<_EmailLinkForm> { TextFormField( controller: _emailController, decoration: InputDecoration( - labelText: l10n.authenticationEmailLabel, - border: const OutlineInputBorder(), - // Consider adding hint text if needed + labelText: l10n.requestCodeEmailLabel, // More specific label + hintText: l10n.requestCodeEmailHint, // Added hint text + // border: const OutlineInputBorder(), // Uses theme default ), keyboardType: TextInputType.emailAddress, autocorrect: false, @@ -206,14 +211,20 @@ class _EmailLinkFormState extends State<_EmailLinkForm> { const SizedBox(height: AppSpacing.lg), ElevatedButton( onPressed: widget.isLoading ? null : _submitForm, - child: - widget.isLoading - ? const SizedBox( - height: 24, - width: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Text(l10n.authenticationSendLinkButton), + style: ElevatedButton.styleFrom( + 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 ), ], ), diff --git a/lib/entity_details/view/entity_details_page.dart b/lib/entity_details/view/entity_details_page.dart index 7772d5e0..168629b8 100644 --- a/lib/entity_details/view/entity_details_page.dart +++ b/lib/entity_details/view/entity_details_page.dart @@ -108,30 +108,52 @@ class _EntityDetailsViewState extends State { return currentScroll >= (maxScroll * 0.9); } + String _getEntityTypeDisplayName(EntityType? type, AppLocalizations l10n) { + if (type == null) return l10n.detailsPageTitle; // Fallback + String name; + switch (type) { + case EntityType.category: + name = l10n.entityDetailsCategoryTitle; // Use direct l10n string + break; + case EntityType.source: + name = l10n.entityDetailsSourceTitle; // Use direct l10n string + break; + // EntityType.country does not exist, remove or map if added later + default: + name = l10n.detailsPageTitle; // Fallback + break; + } + // Manual capitalization + 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 colorScheme = theme.colorScheme; return Scaffold( body: BlocBuilder( builder: (context, state) { + 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: l10n.headlineDetailsLoadingHeadline, // Used generic loading - subheadline: l10n.pleaseWait, // Used generic please wait + icon: Icons.info_outline, + 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( - message: state.errorMessage ?? l10n.unknownError, // Used generic error - onRetry: - () => context.read().add( + //TODO(fulleni): add entityDetailsErrorLoadingto l10n + // message: state.errorMessage ?? l10n.entityDetailsErrorLoading(entityType: entityTypeDisplayNameForTitle), + message: state.errorMessage ?? '...', + onRetry: () => context.read().add( EntityDetailsLoadRequested( entityId: widget.args.entityId, entityType: widget.args.entityType, @@ -141,46 +163,46 @@ class _EntityDetailsViewState extends State { ); } - // At this point, state.entity should not be null if success or loading more - final appBarTitle = - state.entity is Category - ? (state.entity as Category).name - : state.entity is Source - ? (state.entity as Source).name - : l10n.detailsPageTitle; + final String appBarTitleText; + IconData? appBarIconData; + // String? entityImageHeroTag; // Not currently used - final description = - state.entity is Category - ? (state.entity as Category).description - : state.entity is Source + if (state.entity is Category) { + final cat = state.entity as Category; + appBarTitleText = cat.name; + appBarIconData = Icons.category_outlined; + // entityImageHeroTag = 'category-image-${cat.id}'; + } else if (state.entity is Source) { + final src = state.entity as Source; + appBarTitleText = src.name; + appBarIconData = Icons.source_outlined; + } else { + appBarTitleText = l10n.detailsPageTitle; // Fallback + } + + 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 // Filled when following - : Icons.add_circle_outline, - color: - theme - .colorScheme - .primary, // Use primary for both states for accent + 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()); }, ); @@ -194,39 +216,33 @@ class _EntityDetailsViewState extends State { borderRadius: BorderRadius.circular(AppSpacing.xs), child: Image.network( entityIconUrl, - width: kToolbarHeight - 16, // AppBar height minus padding - height: kToolbarHeight - 16, - fit: BoxFit.cover, - errorBuilder: - (context, error, stackTrace) => const Icon( - Icons.category_outlined, - size: kToolbarHeight - 20, - ), + 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, + ), ), ), ) - else if (state.entityType == EntityType.category) - Padding( - padding: const EdgeInsets.only(right: AppSpacing.sm), - child: Icon( - Icons.category_outlined, - size: kToolbarHeight - 20, - color: theme.colorScheme.onSurface, - ), - ) - else if (state.entityType == EntityType.source) + else if (appBarIconData != null) Padding( padding: const EdgeInsets.only(right: AppSpacing.sm), child: Icon( - Icons.source_outlined, - size: kToolbarHeight - 20, - color: theme.colorScheme.onSurface, + appBarIconData, + size: kToolbarHeight - AppSpacing.xl, + color: colorScheme.onSurface, ), ), - Flexible( - child: Text(appBarTitle, overflow: TextOverflow.ellipsis), + Expanded( + child: Text( + appBarTitleText, + style: textTheme.titleLarge, + overflow: TextOverflow.ellipsis, + ), ), - // Info icon removed from here ], ); @@ -236,67 +252,80 @@ class _EntityDetailsViewState extends State { SliverAppBar( title: appBarTitleWidget, pinned: true, - actions: [followButton], + floating: false, + snap: false, + actions: [ + followButton, + const SizedBox(width: AppSpacing.sm), + ], ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all( - AppSpacing.paddingMedium, - ), // Consistent padding - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (description != null && description.isNotEmpty) ...[ - Text( - description, - style: theme.textTheme.bodyLarge?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: AppSpacing.lg), - ], - if (state.feedItems.isNotEmpty || // Changed - state.headlinesStatus == - EntityHeadlinesStatus.loadingMore) ...[ - Text( - l10n.headlinesSectionTitle, - style: theme.textTheme.titleLarge, + SliverPadding( + padding: const EdgeInsets.all(AppSpacing.paddingMedium), + sliver: SliverList( + delegate: SliverChildListDelegate([ + if (description != null && description.isNotEmpty) ...[ + Text( + description, + style: textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + height: 1.5, ), - const Divider(height: AppSpacing.md), - ], + ), + const SizedBox(height: AppSpacing.lg), ], - ), + if (state.feedItems.isNotEmpty || + state.headlinesStatus == EntityHeadlinesStatus.loadingMore) ...[ + Text( + l10n.headlinesSectionTitle, + style: textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + const Divider( + height: AppSpacing.lg, + thickness: 1, + ), + ], + ]), ), ), - if (state.feedItems.isEmpty && // Changed + if (state.feedItems.isEmpty && state.headlinesStatus != EntityHeadlinesStatus.initial && state.headlinesStatus != EntityHeadlinesStatus.loadingMore && state.status == EntityDetailsStatus.success) SliverFillRemaining( + hasScrollBody: false, child: Center( - child: Text( - l10n.noHeadlinesFoundMessage, - style: theme.textTheme.titleMedium, + child: Padding( + padding: const EdgeInsets.all(AppSpacing.paddingLarge), + child: Text( + l10n.noHeadlinesFoundMessage, + style: textTheme.titleMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), ), ), ) else - SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - if (index >= state.feedItems.length) { // Changed - return state.hasMoreHeadlines && // hasMoreHeadlines still refers to original headlines - state.headlinesStatus == - EntityHeadlinesStatus.loadingMore - ? const Center( - child: Padding( - padding: EdgeInsets.all(AppSpacing.md), - child: CircularProgressIndicator(), - ), - ) - : const SizedBox.shrink(); + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.paddingMedium), + sliver: SliverList.separated( + itemCount: state.feedItems.length + + (state.hasMoreHeadlines && + state.headlinesStatus == EntityHeadlinesStatus.loadingMore + ? 1 + : 0), + 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), + child: CircularProgressIndicator(), + ), + ); } - final item = state.feedItems[index]; // Changed + final item = state.feedItems[index]; if (item is Headline) { final imageStyle = context @@ -354,107 +383,24 @@ class _EntityDetailsViewState extends State { ); } return tile; - } else if (item is Ad) { - return Card( - margin: const EdgeInsets.symmetric( - horizontal: AppSpacing.paddingMedium, - vertical: AppSpacing.xs, - ), - color: theme.colorScheme.surfaceContainerHighest, - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - children: [ - if (item.imageUrl.isNotEmpty) - Image.network( - item.imageUrl, - height: 100, - errorBuilder: (ctx, err, st) => - const Icon(Icons.broken_image, size: 50), - ), - const SizedBox(height: AppSpacing.sm), - Text( - 'Placeholder Ad: ${item.adType?.name ?? 'Generic'}', - style: theme.textTheme.titleSmall, - ), - Text( - 'Placement: ${item.placement?.name ?? 'Default'}', - style: theme.textTheme.bodySmall, - ), - ], - ), - ), - ); - } else if (item is AccountAction) { - return Card( - margin: const EdgeInsets.symmetric( - horizontal: AppSpacing.paddingMedium, - vertical: AppSpacing.xs, - ), - color: theme.colorScheme.secondaryContainer, - child: ListTile( - leading: Icon( - item.accountActionType == AccountActionType.linkAccount - ? Icons.link - : Icons.upgrade, - color: theme.colorScheme.onSecondaryContainer, - ), - title: Text( - item.title, - style: theme.textTheme.titleMedium?.copyWith( - color: theme.colorScheme.onSecondaryContainer, - fontWeight: FontWeight.bold, - ), - ), - subtitle: item.description != null - ? Text( - item.description!, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSecondaryContainer.withOpacity(0.8), - ), - ) - : null, - trailing: item.callToActionText != null - ? ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: theme.colorScheme.secondary, - foregroundColor: theme.colorScheme.onSecondary, - ), - onPressed: () { - if (item.callToActionUrl != null) { - context.push(item.callToActionUrl!); - } - }, - child: Text(item.callToActionText!), - ) - : null, - isThreeLine: item.description != null && item.description!.length > 50, - ), - ); } - return const SizedBox.shrink(); // Should not happen + return const SizedBox.shrink(); }, - childCount: state.feedItems.length + // Changed - (state.hasMoreHeadlines && // hasMoreHeadlines still refers to original headlines - state.headlinesStatus == - EntityHeadlinesStatus.loadingMore - ? 1 - : 0), ), ), - // Error display for headline loading specifically if (state.headlinesStatus == EntityHeadlinesStatus.failure && - state.feedItems.isNotEmpty) // Changed + state.feedItems.isNotEmpty) SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), + padding: const EdgeInsets.all(AppSpacing.paddingMedium), child: Text( state.errorMessage ?? l10n.failedToLoadMoreHeadlines, - style: TextStyle(color: theme.colorScheme.error), + style: textTheme.bodyMedium?.copyWith(color: colorScheme.error), textAlign: TextAlign.center, ), ), ), + 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 287f8d9f..52a06b9c 100644 --- a/lib/headline-details/view/headline_details_page.dart +++ b/lib/headline-details/view/headline_details_page.dart @@ -10,10 +10,9 @@ import 'package:ht_main/app/bloc/app_bloc.dart'; // Added AppBloc import 'package:ht_main/entity_details/view/entity_details_page.dart'; // Added for Page Arguments import 'package:ht_main/headline-details/bloc/headline_details_bloc.dart'; import 'package:ht_main/headline-details/bloc/similar_headlines_bloc.dart'; -// HeadlineItemWidget import removed import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/router/routes.dart'; -import 'package:ht_main/shared/shared.dart'; +import 'package:ht_main/shared/shared.dart'; // Imports AppSpacing import 'package:ht_shared/ht_shared.dart' show Category, @@ -43,7 +42,6 @@ class _HeadlineDetailsPageState extends State { context.read().add( HeadlineProvided(widget.initialHeadline!), ); - // Also trigger fetching similar headlines if the main one is already provided context.read().add( FetchSimilarHeadlines(currentHeadline: widget.initialHeadline!), ); @@ -61,9 +59,6 @@ class _HeadlineDetailsPageState extends State { return BlocListener( listener: (context, headlineState) { if (headlineState is HeadlineDetailsLoaded) { - // Once the main headline is loaded (if fetched by ID), - // fetch similar ones. - // This check ensures it's not re-triggered if already loaded via initialHeadline. if (widget.initialHeadline == null) { context.read().add( FetchSimilarHeadlines(currentHeadline: headlineState.headline), @@ -88,17 +83,10 @@ class _HeadlineDetailsPageState extends State { (h) => h.id == currentHeadlineId, ) ?? false; - - // Condition 1: Actual change in saved status for this headline if (wasPreviouslySaved != isCurrentlySaved) { - // Only trigger if the status is success (to show confirmation) - // or failure (to show error). Avoid triggering if status is just loading. return current.status == AccountStatus.success || current.status == AccountStatus.failure; } - - // Condition 2: A specific save/unsave operation just failed - // This triggers if an operation was attempted (loading) and then failed. if (current.status == AccountStatus.failure && previous.status == AccountStatus.loading) { return true; @@ -114,7 +102,6 @@ class _HeadlineDetailsPageState extends State { (h) => h.id == detailsState.headline.id, ) ?? false; - if (accountState.status == AccountStatus.failure && accountState.errorMessage != null) { ScaffoldMessenger.of(context) @@ -149,7 +136,7 @@ class _HeadlineDetailsPageState extends State { return switch (state) { HeadlineDetailsInitial() || HeadlineDetailsLoading() => LoadingStateWidget( - icon: Icons.downloading, + icon: Icons.article_outlined, // Themed icon headline: l10n.headlineDetailsLoadingHeadline, subheadline: l10n.headlineDetailsLoadingSubheadline, ), @@ -166,7 +153,12 @@ class _HeadlineDetailsPageState extends State { ), final HeadlineDetailsLoaded loadedState => _buildLoadedContent(context, loadedState.headline), - _ => const Center(child: Text('Unknown state')), + _ => Center( + child: Text( + l10n.unknownError, + style: Theme.of(context).textTheme.bodyLarge, + ), + ), }; }, ), @@ -182,7 +174,7 @@ class _HeadlineDetailsPageState extends State { final textTheme = theme.textTheme; final colorScheme = theme.colorScheme; - const horizontalPadding = EdgeInsets.symmetric( + final horizontalPadding = EdgeInsets.symmetric( horizontal: AppSpacing.paddingLarge, ); @@ -194,11 +186,13 @@ class _HeadlineDetailsPageState extends State { false; final bookmarkButton = IconButton( - icon: Icon(isSaved ? Icons.bookmark : Icons.bookmark_border), - tooltip: - isSaved - ? l10n.headlineDetailsRemoveFromSavedTooltip - : l10n.headlineDetailsSaveTooltip, + icon: Icon( + isSaved ? Icons.bookmark : Icons.bookmark_border_outlined, + color: colorScheme.primary, // Ensure icon color from theme + ), + tooltip: isSaved + ? l10n.headlineDetailsRemoveFromSavedTooltip + : l10n.headlineDetailsSaveTooltip, onPressed: () { context.read().add( AccountSaveHeadlineToggled(headline: headline), @@ -209,7 +203,10 @@ class _HeadlineDetailsPageState extends State { final Widget shareButtonWidget = Builder( builder: (BuildContext buttonContext) { return IconButton( - icon: const Icon(Icons.share), + icon: Icon( + Icons.share_outlined, + color: colorScheme.primary, // Ensure icon color from theme + ), tooltip: l10n.shareActionTooltip, onPressed: () async { final box = buttonContext.findRenderObject() as RenderBox?; @@ -217,7 +214,6 @@ class _HeadlineDetailsPageState extends State { if (box != null) { sharePositionOrigin = box.localToGlobal(Offset.zero) & box.size; } - ShareParams params; if (kIsWeb && headline.url != null && headline.url!.isNotEmpty) { params = ShareParams( @@ -238,9 +234,7 @@ class _HeadlineDetailsPageState extends State { sharePositionOrigin: sharePositionOrigin, ); } - final shareResult = await SharePlus.instance.share(params); - if (buttonContext.mounted) { if (shareResult.status == ShareResultStatus.unavailable) { ScaffoldMessenger.of(buttonContext).showSnackBar( @@ -257,126 +251,162 @@ class _HeadlineDetailsPageState extends State { slivers: [ SliverAppBar( leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.arrow_back_ios_new), + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + onPressed: () => context.pop(), + color: colorScheme.onSurface, // Ensure icon color from theme ), - actions: [bookmarkButton, shareButtonWidget], + actions: [ + bookmarkButton, + shareButtonWidget, + const SizedBox(width: AppSpacing.sm), + ], pinned: false, floating: true, snap: true, backgroundColor: Colors.transparent, elevation: 0, - foregroundColor: theme.colorScheme.onSurface, + foregroundColor: colorScheme.onSurface, ), SliverPadding( - padding: horizontalPadding.copyWith(top: AppSpacing.lg), + padding: horizontalPadding.copyWith(top: AppSpacing.sm), // Adjusted sliver: SliverToBoxAdapter( - child: Text(headline.title, style: textTheme.headlineMedium), + child: Text( + headline.title, + style: textTheme.headlineSmall + ?.copyWith(fontWeight: FontWeight.bold), // Adjusted style + ), ), ), - // Image or Placeholder Section - SliverPadding( - padding: const EdgeInsets.only( - top: AppSpacing.lg, - left: AppSpacing.paddingLarge, - right: AppSpacing.paddingLarge, - ), - sliver: SliverToBoxAdapter( - child: ClipRRect( - borderRadius: BorderRadius.circular(AppSpacing.md), - child: - headline.imageUrl != null - ? Image.network( - headline.imageUrl!, - width: double.infinity, - height: 200, - fit: BoxFit.cover, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; - return Container( - width: double.infinity, - height: 200, - color: colorScheme.surfaceContainerHighest, - child: const Center( - child: CircularProgressIndicator(), - ), - ); - }, - errorBuilder: - (context, error, stackTrace) => Container( - width: double.infinity, - height: 200, - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.broken_image_outlined, - color: colorScheme.onSurfaceVariant, - size: AppSpacing.xxl, - ), - ), - ) - : Container( - width: double.infinity, - height: 200, + if (headline.imageUrl != null) + SliverPadding( + padding: EdgeInsets.only( + top: AppSpacing.md, + left: horizontalPadding.left, + right: horizontalPadding.right, + ), + sliver: SliverToBoxAdapter( + child: ClipRRect( + borderRadius: BorderRadius.circular(AppSpacing.md), // Consistent radius + child: AspectRatio( + aspectRatio: 16 / 9, + child: Image.network( + headline.imageUrl!, + fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Container( color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.image_not_supported_outlined, - color: colorScheme.onSurfaceVariant, - size: AppSpacing.xxl, + child: const Center( + child: CircularProgressIndicator(strokeWidth: 2), ), + ); + }, + errorBuilder: (context, error, stackTrace) => Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.broken_image_outlined, + color: colorScheme.onSurfaceVariant, + size: AppSpacing.xxl * 1.5, // Larger placeholder ), + ), + ), + ), + ), + ), + ) + else // Placeholder if no image + SliverPadding( + padding: EdgeInsets.only( + top: AppSpacing.md, + left: horizontalPadding.left, + right: horizontalPadding.right, + ), + sliver: SliverToBoxAdapter( + child: AspectRatio( + aspectRatio: 16 / 9, + child: Container( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(AppSpacing.md), + ), + child: Icon( + Icons.image_not_supported_outlined, + color: colorScheme.onSurfaceVariant, + size: AppSpacing.xxl * 1.5, // Larger placeholder + ), + ), + ), ), ), - ), SliverPadding( - padding: horizontalPadding.copyWith(top: AppSpacing.lg), + padding: horizontalPadding.copyWith(top: AppSpacing.lg), // Increased spacing sliver: SliverToBoxAdapter( child: Wrap( - spacing: AppSpacing.sm, - runSpacing: AppSpacing.sm, + spacing: AppSpacing.md, // Increased spacing + runSpacing: AppSpacing.sm, // Adjusted runSpacing children: _buildMetadataChips(context, headline), ), ), ), - if (headline.description != null) + if (headline.description != null && headline.description!.isNotEmpty) SliverPadding( - padding: horizontalPadding.copyWith(top: AppSpacing.lg), + padding: horizontalPadding.copyWith(top: AppSpacing.lg), // Increased sliver: SliverToBoxAdapter( - child: Text(headline.description!, style: textTheme.bodyLarge), + child: Text( + headline.description!, + style: textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + height: 1.6, // Improved line height + ), + ), ), ), - if (headline.url != null) + if (headline.url != null && headline.url!.isNotEmpty) SliverPadding( padding: horizontalPadding.copyWith( top: AppSpacing.xl, - bottom: AppSpacing.paddingLarge, + bottom: AppSpacing.xl, // Consistent padding ), sliver: SliverToBoxAdapter( - child: SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () async { - await launchUrlString(headline.url!); - }, - child: Text(l10n.headlineDetailsContinueReadingButton), + child: ElevatedButton.icon( + icon: const Icon(Icons.open_in_new_outlined), + onPressed: () async { + await launchUrlString(headline.url!); + }, + label: Text(l10n.headlineDetailsContinueReadingButton), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.md, + ), + textStyle: textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold), ), ), ), ), - if (headline.url == null) - const SliverPadding( - padding: EdgeInsets.only(bottom: AppSpacing.paddingLarge), - sliver: SliverToBoxAdapter(child: SizedBox.shrink()), + if (headline.url == null || headline.url!.isEmpty) // Ensure bottom padding + SliverPadding( + padding: EdgeInsets.only(bottom: AppSpacing.xl), + sliver: const SliverToBoxAdapter(child: SizedBox.shrink()), ), - SliverToBoxAdapter( - child: Padding( - padding: horizontalPadding.copyWith(top: AppSpacing.xl), - child: Text( - l10n.similarHeadlinesSectionTitle, - style: textTheme.titleLarge, + SliverPadding( + padding: horizontalPadding, + sliver: SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.only( + 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), + ), ), ), ), - _buildSimilarHeadlinesSection(context), + _buildSimilarHeadlinesSection(context, horizontalPadding), ], ); } @@ -384,28 +414,31 @@ class _HeadlineDetailsPageState extends State { List _buildMetadataChips(BuildContext context, Headline headline) { final theme = Theme.of(context); final textTheme = theme.textTheme; - final chipLabelStyle = textTheme.labelSmall; - final chipBackgroundColor = theme.colorScheme.surfaceContainerHighest; - final chipAvatarColor = theme.colorScheme.onSurfaceVariant; - const chipAvatarSize = 14.0; - const chipPadding = EdgeInsets.symmetric( - horizontal: AppSpacing.xs, - vertical: AppSpacing.xs / 2, + final colorScheme = theme.colorScheme; + final chipLabelStyle = textTheme.labelMedium?.copyWith( + color: colorScheme.onSecondaryContainer, // Ensure text is visible + ); + final chipBackgroundColor = colorScheme.secondaryContainer; + final chipAvatarColor = colorScheme.onSecondaryContainer; + const chipAvatarSize = AppSpacing.md; + final chipPadding = EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xs, + ); + final chipShape = RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppSpacing.sm), + side: BorderSide(color: colorScheme.outlineVariant.withOpacity(0.3)), ); - const chipVisualDensity = VisualDensity.compact; - const chipMaterialTapTargetSize = MaterialTapTargetSize.shrinkWrap; final chips = []; - // 1. Add Date Chip First 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( - Icons.date_range, + Icons.calendar_today_outlined, size: chipAvatarSize, color: chipAvatarColor, ), @@ -413,25 +446,26 @@ class _HeadlineDetailsPageState extends State { labelStyle: chipLabelStyle, backgroundColor: chipBackgroundColor, padding: chipPadding, - visualDensity: chipVisualDensity, - materialTapTargetSize: chipMaterialTapTargetSize, + shape: chipShape, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ), ); } - // 2. Add Source Chip Second if (headline.source != null) { chips.add( - GestureDetector( + InkWell( // Make chip tappable onTap: () { context.push( Routes.sourceDetails, extra: EntityDetailsPageArguments(entity: headline.source), ); }, + borderRadius: BorderRadius.circular(AppSpacing.sm), // Match chip shape child: Chip( avatar: Icon( - Icons.source, + Icons.source_outlined, size: chipAvatarSize, color: chipAvatarColor, ), @@ -439,32 +473,37 @@ class _HeadlineDetailsPageState extends State { labelStyle: chipLabelStyle, backgroundColor: chipBackgroundColor, padding: chipPadding, - visualDensity: chipVisualDensity, - materialTapTargetSize: chipMaterialTapTargetSize, + shape: chipShape, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ), ), ); } - // Country chip for headline.source.headquarters removed. - - // 3. Add Category Chip Third if (headline.category != null) { chips.add( - GestureDetector( + InkWell( // Make chip tappable onTap: () { context.push( Routes.categoryDetails, extra: EntityDetailsPageArguments(entity: headline.category), ); }, + borderRadius: BorderRadius.circular(AppSpacing.sm), // Match chip shape child: Chip( + avatar: Icon( + Icons.category_outlined, + size: chipAvatarSize, + color: chipAvatarColor, + ), label: Text(headline.category!.name), labelStyle: chipLabelStyle, backgroundColor: chipBackgroundColor, padding: chipPadding, - visualDensity: chipVisualDensity, - materialTapTargetSize: chipMaterialTapTargetSize, + shape: chipShape, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ), ), ); @@ -472,93 +511,98 @@ class _HeadlineDetailsPageState extends State { return chips; } - Widget _buildSimilarHeadlinesSection(BuildContext context) { + Widget _buildSimilarHeadlinesSection( + BuildContext context, EdgeInsets hPadding) { final l10n = context.l10n; + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final colorScheme = theme.colorScheme; + return BlocBuilder( builder: (context, state) { return switch (state) { SimilarHeadlinesInitial() || - SimilarHeadlinesLoading() => const SliverToBoxAdapter( + SimilarHeadlinesLoading() => SliverToBoxAdapter( child: Padding( - padding: EdgeInsets.all(AppSpacing.lg), - child: Center(child: CircularProgressIndicator()), + padding: EdgeInsets.symmetric(vertical: AppSpacing.xl), + child: const Center(child: CircularProgressIndicator()), ), ), final SimilarHeadlinesError errorState => SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.all(AppSpacing.lg), + padding: hPadding.copyWith( + top: AppSpacing.md, bottom: AppSpacing.xl), child: Text( errorState.message, textAlign: TextAlign.center, - style: TextStyle(color: Theme.of(context).colorScheme.error), + style: textTheme.bodyMedium + ?.copyWith(color: colorScheme.error), ), ), ), SimilarHeadlinesEmpty() => SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.all(AppSpacing.lg), + padding: hPadding.copyWith( + top: AppSpacing.md, bottom: AppSpacing.xl), child: Text( l10n.similarHeadlinesEmpty, textAlign: TextAlign.center, + style: textTheme.bodyLarge + ?.copyWith(color: colorScheme.onSurfaceVariant), ), ), ), - final SimilarHeadlinesLoaded loadedState => SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final similarHeadline = loadedState.similarHeadlines[index]; - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.paddingMedium, - vertical: AppSpacing.sm, - ), - child: Builder( - // Use Builder to get a new context that can watch AppBloc + final SimilarHeadlinesLoaded loadedState => SliverPadding( + padding: hPadding.copyWith(bottom: AppSpacing.xxl), + sliver: SliverList.separated( + separatorBuilder: (context, index) => + const SizedBox(height: AppSpacing.sm), // Spacing between items + itemCount: loadedState.similarHeadlines.length, + 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.articleDetailsName, - 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.articleDetailsName, - 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.articleDetailsName, - pathParameters: {'id': similarHeadline.id}, - extra: similarHeadline, - ), + onHeadlineTap: () => context.pushNamed( + Routes.globalArticleDetailsName, + pathParameters: {'id': similarHeadline.id}, + extra: similarHeadline, + ), ); } return tile; }, - ), - ); - }, childCount: loadedState.similarHeadlines.length,), + ); + }, + ), ), _ => const SliverToBoxAdapter(child: SizedBox.shrink()), }; diff --git a/lib/headlines-feed/view/category_filter_page.dart b/lib/headlines-feed/view/category_filter_page.dart index 9a1e807b..5169f072 100644 --- a/lib/headlines-feed/view/category_filter_page.dart +++ b/lib/headlines-feed/view/category_filter_page.dart @@ -100,12 +100,17 @@ class _CategoryFilterPageState extends State { Widget build(BuildContext context) { final l10n = context.l10n; + final theme = Theme.of(context); // Get theme + final textTheme = theme.textTheme; // Get textTheme + final colorScheme = theme.colorScheme; // Get colorScheme + return Scaffold( appBar: AppBar( - // Default back button will pop without result (cancelling) - title: Text(l10n.headlinesFeedFilterCategoryLabel), + title: Text( + l10n.headlinesFeedFilterCategoryLabel, + style: textTheme.titleLarge, // Apply consistent title style + ), actions: [ - // Apply Button IconButton( icon: const Icon(Icons.check), tooltip: l10n.headlinesFeedFilterApplyButton, @@ -129,6 +134,9 @@ class _CategoryFilterPageState extends State { /// Builds the main content body based on the current [CategoriesFilterState]. Widget _buildBody(BuildContext context, CategoriesFilterState state) { final l10n = context.l10n; + final theme = Theme.of(context); // Get theme + final textTheme = theme.textTheme; // Get textTheme + final colorScheme = theme.colorScheme; // Get colorScheme // Handle initial loading state if (state.status == CategoriesFilterStatus.loading) { @@ -140,15 +148,12 @@ class _CategoryFilterPageState extends State { } // Handle failure state (show error and retry button) - // Only show full error screen if not loading more (i.e., initial load failed) if (state.status == CategoriesFilterStatus.failure && state.categories.isEmpty) { return FailureStateWidget( message: state.error?.toString() ?? l10n.unknownError, - onRetry: - () => context.read().add( - CategoriesFilterRequested(), - ), + onRetry: () => + context.read().add(CategoriesFilterRequested()), ); } @@ -156,29 +161,24 @@ class _CategoryFilterPageState extends State { if (state.status == CategoriesFilterStatus.success && state.categories.isEmpty) { return InitialStateWidget( - icon: Icons.search_off, + icon: Icons.search_off_outlined, // Use outlined version headline: l10n.categoryFilterEmptyHeadline, subheadline: l10n.categoryFilterEmptySubheadline, ); } // Handle loaded state (success or loading more) - // Show the list, potentially with a loading indicator at the bottom return ListView.builder( controller: _scrollController, - padding: const EdgeInsets.only( - bottom: AppSpacing.xxl, // Padding at the bottom for loader/content - ), - // Add 1 to item count if loading more or if failed during load more - 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)) ? 1 : 0), itemBuilder: (context, index) { - // Check if we need to render the loading/error indicator at the end if (index >= state.categories.length) { if (state.status == CategoriesFilterStatus.loadingMore) { return const Padding( @@ -186,7 +186,6 @@ class _CategoryFilterPageState extends State { child: Center(child: CircularProgressIndicator()), ); } else if (state.status == CategoriesFilterStatus.failure) { - // Show a smaller error indicator at the bottom if load more failed return Padding( padding: const EdgeInsets.symmetric( vertical: AppSpacing.md, @@ -195,51 +194,64 @@ class _CategoryFilterPageState extends State { child: Center( child: Text( l10n.loadMoreError, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.error, - ), + style: textTheme.bodySmall + ?.copyWith(color: colorScheme.error), ), ), ); - } else { - return const SizedBox.shrink(); // Should not happen if hasMore is false } + return const SizedBox.shrink(); } - // Render the actual category item final category = state.categories[index]; final isSelected = _pageSelectedCategories.contains(category); return CheckboxListTile( - title: Text(category.name), - secondary: - category.iconUrl != null - ? SizedBox( - width: 40, - height: 40, + 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) => - const Icon(Icons.category), // Placeholder icon + 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, + ), + ); + }, ), - ) - : null, + ), + ) + : null, value: isSelected, onChanged: (bool? value) { - // When a checkbox state changes, update the local selection set - // (`_pageSelectedCategories`) for this page. setState(() { if (value == true) { - // Add the category if checked. _pageSelectedCategories.add(category); } else { - // Remove the category if unchecked. _pageSelectedCategories.remove(category); } }); }, + controlAffinity: ListTileControlAffinity.leading, + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.paddingMedium, // Standard padding + ), ); }, ); diff --git a/lib/headlines-feed/view/country_filter_page.dart b/lib/headlines-feed/view/country_filter_page.dart index de40c847..a7f643c8 100644 --- a/lib/headlines-feed/view/country_filter_page.dart +++ b/lib/headlines-feed/view/country_filter_page.dart @@ -98,10 +98,15 @@ class _CountryFilterPageState extends State { @override Widget build(BuildContext context) { final l10n = context.l10n; + final theme = Theme.of(context); // Get theme + final textTheme = theme.textTheme; // Get textTheme return Scaffold( appBar: AppBar( - title: Text(l10n.headlinesFeedFilterEventCountryLabel), + title: Text( + l10n.headlinesFeedFilterEventCountryLabel, + style: textTheme.titleLarge, // Apply consistent title style + ), actions: [ IconButton( icon: const Icon(Icons.check), @@ -126,14 +131,16 @@ class _CountryFilterPageState extends State { /// Builds the main content body based on the current [CountriesFilterState]. Widget _buildBody(BuildContext context, CountriesFilterState state) { final l10n = context.l10n; + final theme = Theme.of(context); // Get theme + final textTheme = theme.textTheme; // Get textTheme + final colorScheme = theme.colorScheme; // Get colorScheme // Handle initial loading state if (state.status == CountriesFilterStatus.loading) { return LoadingStateWidget( - icon: Icons.public_outlined, // Changed icon - headline: l10n.countryFilterLoadingHeadline, // Assumes this exists - subheadline: - l10n.countryFilterLoadingSubheadline, // Assumes this exists + icon: Icons.public_outlined, + headline: l10n.countryFilterLoadingHeadline, + subheadline: l10n.countryFilterLoadingSubheadline, ); } @@ -141,12 +148,9 @@ class _CountryFilterPageState extends State { if (state.status == CountriesFilterStatus.failure && state.countries.isEmpty) { return FailureStateWidget( - message: - state.error?.toString() ?? l10n.unknownError, // Assumes this exists - onRetry: - () => context.read().add( - CountriesFilterRequested(), - ), + message: state.error?.toString() ?? l10n.unknownError, + onRetry: () => + context.read().add(CountriesFilterRequested()), ); } @@ -154,18 +158,18 @@ class _CountryFilterPageState extends State { if (state.status == CountriesFilterStatus.success && state.countries.isEmpty) { return InitialStateWidget( - icon: Icons.search_off, - headline: l10n.countryFilterEmptyHeadline, // Assumes this exists - subheadline: l10n.countryFilterEmptySubheadline, // Assumes this exists + icon: Icons.flag_circle_outlined, // More relevant icon + headline: l10n.countryFilterEmptyHeadline, + subheadline: l10n.countryFilterEmptySubheadline, ); } // Handle loaded state (success or loading more) return ListView.builder( controller: _scrollController, - padding: const EdgeInsets.only(bottom: AppSpacing.xxl), - 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)) @@ -186,49 +190,63 @@ class _CountryFilterPageState extends State { ), child: Center( child: Text( - l10n.loadMoreError, // Assumes this exists - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.error, - ), + l10n.loadMoreError, + style: textTheme.bodySmall + ?.copyWith(color: colorScheme.error), ), ), ); - } else { - return const SizedBox.shrink(); } + return const SizedBox.shrink(); } final country = state.countries[index]; final isSelected = _pageSelectedCountries.contains(country); return CheckboxListTile( - title: Text(country.name), + title: Text(country.name, style: textTheme.titleMedium), secondary: SizedBox( - // Use SizedBox for consistent flag size - width: 40, - height: 30, // Adjust height for flag aspect ratio if needed - child: Image.network( - country.flagUrl, - fit: BoxFit.contain, - errorBuilder: - (context, error, stackTrace) => - const Icon(Icons.flag_outlined), // Placeholder icon + 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 + 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 + ), + 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: isSelected, onChanged: (bool? value) { - // When a checkbox state changes, update the local selection set - // (`_pageSelectedCountries`) for this page. setState(() { if (value == true) { - // Add the country if checked. _pageSelectedCountries.add(country); } else { - // Remove the country if unchecked. _pageSelectedCountries.remove(country); } }); }, + controlAffinity: ListTileControlAffinity.leading, + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.paddingMedium, + ), ); }, ); diff --git a/lib/headlines-feed/view/headlines_filter_page.dart b/lib/headlines-feed/view/headlines_filter_page.dart index 34008123..04fe43c2 100644 --- a/lib/headlines-feed/view/headlines_filter_page.dart +++ b/lib/headlines-feed/view/headlines_filter_page.dart @@ -70,10 +70,12 @@ class _HeadlinesFilterPageState extends State { final currentFilter = headlinesFeedState.filter; _tempSelectedCategories = List.from(currentFilter.categories ?? []); _tempSelectedSources = List.from(currentFilter.sources ?? []); - _tempSelectedSourceCountryIsoCodes = - Set.from(currentFilter.selectedSourceCountryIsoCodes ?? {}); - _tempSelectedSourceSourceTypes = - Set.from(currentFilter.selectedSourceSourceTypes ?? {}); + _tempSelectedSourceCountryIsoCodes = Set.from( + currentFilter.selectedSourceCountryIsoCodes ?? {}, + ); + _tempSelectedSourceSourceTypes = Set.from( + currentFilter.selectedSourceSourceTypes ?? {}, + ); // Use the new flag from the filter to set the checkbox state initialUseFollowedFilters = currentFilter.isFromFollowedItems; @@ -161,12 +163,14 @@ class _HeadlinesFilterPageState extends State { } } on NotFoundException { setState(() { - _currentUserPreferences = - UserContentPreferences(id: currentUser.id); // Empty prefs + _currentUserPreferences = UserContentPreferences( + id: currentUser.id, + ); // Empty prefs _tempSelectedCategories = []; _tempSelectedSources = []; _isLoadingFollowedFilters = false; - _useFollowedFilters = false; // Uncheck as no prefs found (implies no followed) + _useFollowedFilters = + false; // Uncheck as no prefs found (implies no followed) }); if (mounted) { ScaffoldMessenger.of(context) @@ -182,8 +186,7 @@ class _HeadlinesFilterPageState extends State { setState(() { _isLoadingFollowedFilters = false; _useFollowedFilters = false; // Uncheck the box - _loadFollowedFiltersError = - e.message; // Or a generic "Failed to load" + _loadFollowedFiltersError = e.message; // Or a generic "Failed to load" }); } catch (e) { setState(() { @@ -238,17 +241,18 @@ class _HeadlinesFilterPageState extends State { subtitle: Text(subtitle), trailing: const Icon(Icons.chevron_right), enabled: enabled, // Use the enabled parameter - onTap: enabled // Only allow tap if enabled - ? () async { - final result = await context.pushNamed( - routeName, - extra: currentSelectionData, // Pass the map or list - ); - if (result != null && onResult != null) { - onResult(result); + onTap: + enabled // Only allow tap if enabled + ? () async { + final result = await context.pushNamed( + routeName, + extra: currentSelectionData, // Pass the map or list + ); + if (result != null && onResult != null) { + onResult(result); + } } - } - : null, + : null, ); } @@ -271,8 +275,8 @@ class _HeadlinesFilterPageState extends State { tooltip: l10n.headlinesFeedFilterResetButton, onPressed: () { context.read().add( - HeadlinesFeedFiltersCleared(), - ); + HeadlinesFeedFiltersCleared(), + ); // Also reset local state for the checkbox setState(() { _useFollowedFilters = false; @@ -288,12 +292,14 @@ class _HeadlinesFilterPageState extends State { tooltip: l10n.headlinesFeedFilterApplyButton, onPressed: () { final newFilter = HeadlineFilter( - categories: _tempSelectedCategories.isNotEmpty - ? _tempSelectedCategories - : null, - sources: _tempSelectedSources.isNotEmpty - ? _tempSelectedSources - : null, + categories: + _tempSelectedCategories.isNotEmpty + ? _tempSelectedCategories + : null, + sources: + _tempSelectedSources.isNotEmpty + ? _tempSelectedSources + : null, selectedSourceCountryIsoCodes: _tempSelectedSourceCountryIsoCodes.isNotEmpty ? _tempSelectedSourceCountryIsoCodes @@ -302,12 +308,11 @@ class _HeadlinesFilterPageState extends State { _tempSelectedSourceSourceTypes.isNotEmpty ? _tempSelectedSourceSourceTypes : null, - isFromFollowedItems: - _useFollowedFilters, // Set the new flag + isFromFollowedItems: _useFollowedFilters, // Set the new flag ); context.read().add( - HeadlinesFeedFiltersApplied(filter: newFilter), - ); + HeadlinesFeedFiltersApplied(filter: newFilter), + ); context.pop(); }, ), @@ -335,13 +340,14 @@ class _HeadlinesFilterPageState extends State { } }); }, - secondary: _isLoadingFollowedFilters - ? const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : null, + secondary: + _isLoadingFollowedFilters + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : null, controlAffinity: ListTileControlAffinity.leading, ), ), diff --git a/lib/headlines-feed/view/source_filter_page.dart b/lib/headlines-feed/view/source_filter_page.dart index 7a652f4b..522708d7 100644 --- a/lib/headlines-feed/view/source_filter_page.dart +++ b/lib/headlines-feed/view/source_filter_page.dart @@ -54,14 +54,19 @@ class _SourceFilterView extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = context.l10n; + final theme = Theme.of(context); // Get theme + final textTheme = theme.textTheme; // Get textTheme final state = context.watch().state; return Scaffold( appBar: AppBar( - title: Text(l10n.headlinesFeedFilterSourceLabel), + title: Text( + l10n.headlinesFeedFilterSourceLabel, + style: textTheme.titleLarge, // Apply consistent title style + ), actions: [ IconButton( - icon: const Icon(Icons.clear_all), + icon: const Icon(Icons.clear_all_outlined), // Use outlined tooltip: l10n.headlinesFeedFilterResetButton, onPressed: () { context.read().add( @@ -98,43 +103,48 @@ class _SourceFilterView extends StatelessWidget { SourcesFilterState state, AppLocalizations l10n, ) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + if (state.dataLoadingStatus == SourceFilterDataLoadingStatus.loading && - state.availableCountries.isEmpty) { + state.allAvailableSources.isEmpty) { // Check allAvailableSources return LoadingStateWidget( - icon: Icons.filter_list_alt, // Added generic icon - headline: l10n.headlinesFeedFilterLoadingCriteria, - subheadline: l10n.pleaseWait, // Added generic subheadline (l10n key) + icon: Icons.source_outlined, // More relevant icon + headline: l10n.sourceFilterLoadingHeadline, // Specific l10n + subheadline: l10n.sourceFilterLoadingSubheadline, // Specific l10n ); } if (state.dataLoadingStatus == SourceFilterDataLoadingStatus.failure && - state.availableCountries.isEmpty) { + state.allAvailableSources.isEmpty) { // Check allAvailableSources return FailureStateWidget( message: state.errorMessage ?? l10n.headlinesFeedFilterErrorCriteria, onRetry: () { - context.read().add( - // When retrying, we don't have initial capsule states from arguments - // So, we pass empty sets, BLoC will load all sources and countries. - // User can then re-apply capsule filters if needed. - // Or, we could try to persist/retrieve the last known good capsule state. - // For now, simple retry reloads all. - const LoadSourceFilterData(), - ); + context + .read() + .add(const LoadSourceFilterData()); }, ); } - return Padding( - padding: const EdgeInsets.only(top: AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildCountryCapsules(context, state, l10n), - const SizedBox(height: AppSpacing.lg), - _buildSourceTypeCapsules(context, state, l10n), - const SizedBox(height: AppSpacing.lg), - Expanded(child: _buildSourcesList(context, state, l10n)), - ], - ), + return Column( // Removed Padding, handled by children + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCountryCapsules(context, state, l10n, textTheme), + const SizedBox(height: AppSpacing.md), // Adjusted spacing + _buildSourceTypeCapsules(context, state, l10n, textTheme), + const SizedBox(height: AppSpacing.md), // Adjusted spacing + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.paddingMedium, + ), + child: Text( + l10n.headlinesFeedFilterSourceLabel, // "Sources" title + style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + ), + const SizedBox(height: AppSpacing.sm), + Expanded(child: _buildSourcesList(context, state, l10n, textTheme)), + ], ); } @@ -142,53 +152,58 @@ class _SourceFilterView extends StatelessWidget { BuildContext context, SourcesFilterState state, AppLocalizations l10n, + TextTheme textTheme, // Added textTheme ) { return Padding( - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.paddingMedium), - child: Row( + 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( - '${l10n.headlinesFeedFilterCountryLabel}:', - style: Theme.of(context).textTheme.titleSmall, + l10n.headlinesFeedFilterCountryLabel, // "Countries" label + style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: SizedBox( - height: 40, // Fixed height for the capsule list - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: state.availableCountries.length + 1, // +1 for "All" - separatorBuilder: - (context, index) => const SizedBox(width: AppSpacing.sm), - itemBuilder: (context, index) { - if (index == 0) { - // "All" chip - return ChoiceChip( - label: Text(l10n.headlinesFeedFilterAllLabel), - selected: state.selectedCountryIsoCodes.isEmpty, - onSelected: (_) { - context.read().add( - const CountryCapsuleToggled( - '', - ), // Special value for "All" - ); - }, - ); - } - final country = state.availableCountries[index - 1]; + const SizedBox(height: AppSpacing.sm), + SizedBox( + height: AppSpacing.xl + AppSpacing.md, // Standardized height + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: state.availableCountries.length + 1, + separatorBuilder: (context, index) => + const SizedBox(width: AppSpacing.sm), + itemBuilder: (context, index) { + if (index == 0) { return ChoiceChip( - label: Text(country.name), - selected: state.selectedCountryIsoCodes.contains( - country.isoCode, - ), + label: Text(l10n.headlinesFeedFilterAllLabel), + labelStyle: textTheme.labelLarge, + selected: state.selectedCountryIsoCodes.isEmpty, onSelected: (_) { - context.read().add( - CountryCapsuleToggled(country.isoCode), - ); + 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, + label: Text(country.name), + labelStyle: textTheme.labelLarge, + selected: + state.selectedCountryIsoCodes.contains(country.isoCode), + onSelected: (_) { + context + .read() + .add(CountryCapsuleToggled(country.isoCode)); + }, + ); + }, ), ), ], @@ -200,62 +215,50 @@ class _SourceFilterView extends StatelessWidget { BuildContext context, SourcesFilterState state, AppLocalizations l10n, + TextTheme textTheme, // Added textTheme ) { return Padding( padding: const EdgeInsets.symmetric(horizontal: AppSpacing.paddingMedium), - child: Row( + child: Column( // Use Column for label and then list + crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '${l10n.headlinesFeedFilterSourceTypeLabel}:', // Assuming l10n key exists - style: Theme.of(context).textTheme.titleSmall, + l10n.headlinesFeedFilterSourceTypeLabel, + style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: SizedBox( - height: 40, // Fixed height for the capsule list - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: - state.availableSourceTypes.length + 1, // +1 for "All" - separatorBuilder: - (context, index) => const SizedBox(width: AppSpacing.sm), - itemBuilder: (context, index) { - if (index == 0) { - // "All" chip - return ChoiceChip( - label: Text(l10n.headlinesFeedFilterAllLabel), - selected: state.selectedSourceTypes.isEmpty, - onSelected: (_) { - // For "All", if it's selected, it means no specific types are chosen. - // The BLoC should interpret an empty selectedSourceTypes set as "All". - // Toggling "All" when it's already selected (meaning list is empty) - // doesn't have a clear action here without more complex "select all" logic. - // For now, if "All" is tapped, we ensure the specific selections are cleared. - // This is best handled in the BLoC. - // We can send a specific event or a toggle that the BLoC interprets. - // For simplicity, let's make it so tapping "All" when selected does nothing, - // Tapping "All" for source types should clear specific selections. - // This is now handled by the AllSourceTypesCapsuleToggled event. - context.read().add( - const AllSourceTypesCapsuleToggled(), - ); - }, - ); - } - final sourceType = state.availableSourceTypes[index - 1]; + const SizedBox(height: AppSpacing.sm), + SizedBox( + height: AppSpacing.xl + AppSpacing.md, // Standardized height + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: state.availableSourceTypes.length + 1, + separatorBuilder: (context, index) => + const SizedBox(width: AppSpacing.sm), + itemBuilder: (context, index) { + if (index == 0) { return ChoiceChip( - label: Text( - sourceType.name, - ), // Or a more user-friendly name - selected: state.selectedSourceTypes.contains(sourceType), + label: Text(l10n.headlinesFeedFilterAllLabel), + labelStyle: textTheme.labelLarge, + selected: state.selectedSourceTypes.isEmpty, onSelected: (_) { - context.read().add( - SourceTypeCapsuleToggled(sourceType), - ); + context + .read() + .add(const AllSourceTypesCapsuleToggled()); }, ); - }, - ), + } + final sourceType = state.availableSourceTypes[index - 1]; + return ChoiceChip( + label: Text(sourceType.name), // Assuming SourceType.name is user-friendly + labelStyle: textTheme.labelLarge, + selected: state.selectedSourceTypes.contains(sourceType), + onSelected: (_) { + context + .read() + .add(SourceTypeCapsuleToggled(sourceType)); + }, + ); + }, ), ), ], @@ -267,8 +270,10 @@ class _SourceFilterView extends StatelessWidget { BuildContext context, SourcesFilterState state, AppLocalizations l10n, + TextTheme textTheme, // Added textTheme ) { - if (state.dataLoadingStatus == SourceFilterDataLoadingStatus.loading) { + if (state.dataLoadingStatus == SourceFilterDataLoadingStatus.loading && + state.displayableSources.isEmpty) { // Added check for displayableSources return const Center(child: CircularProgressIndicator()); } if (state.dataLoadingStatus == SourceFilterDataLoadingStatus.failure && @@ -276,38 +281,46 @@ class _SourceFilterView extends StatelessWidget { return FailureStateWidget( message: state.errorMessage ?? l10n.headlinesFeedFilterErrorSources, onRetry: () { - // Dispatch a public event to reload/retry, BLoC will handle internally - context.read().add( - LoadSourceFilterData( - initialSelectedSources: - state.displayableSources - .where( - (s) => state.finallySelectedSourceIds.contains(s.id), - ) - .toList(), // Or pass current selections if needed for retry context - ), - ); + context + .read() + .add(const LoadSourceFilterData()); }, ); } - if (state.displayableSources.isEmpty) { - return Center(child: Text(l10n.headlinesFeedFilterNoSourcesMatch)); + if (state.displayableSources.isEmpty && + state.dataLoadingStatus != SourceFilterDataLoadingStatus.loading) { // Avoid showing if still loading + return Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.paddingLarge), + child: Text( + l10n.headlinesFeedFilterNoSourcesMatch, + style: textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + ), + ); } return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.paddingSmall) + .copyWith(bottom: AppSpacing.xxl), itemCount: state.displayableSources.length, itemBuilder: (context, index) { final source = state.displayableSources[index]; return CheckboxListTile( - title: Text(source.name), + title: Text(source.name, style: textTheme.titleMedium), 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, + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.paddingMedium, + ), ); }, ); diff --git a/lib/headlines-search/view/headlines_search_page.dart b/lib/headlines-search/view/headlines_search_page.dart index 211f89dd..0b122da4 100644 --- a/lib/headlines-search/view/headlines_search_page.dart +++ b/lib/headlines-search/view/headlines_search_page.dart @@ -101,20 +101,18 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { final l10n = context.l10n; final theme = Theme.of(context); final colorScheme = theme.colorScheme; + final textTheme = theme.textTheme; // Defined for use final appBarTheme = theme.appBarTheme; - // Use all values from SearchModelType as .country is already removed from the enum itself final availableSearchModelTypes = SearchModelType.values.toList(); - - // Ensure _selectedModelType is still valid if it somehow was .country - // (though this shouldn't happen if initState logic is correct and enum is updated) + if (!availableSearchModelTypes.contains(_selectedModelType)) { - _selectedModelType = SearchModelType.headline; + _selectedModelType = SearchModelType.headline; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { - context.read().add( - HeadlinesSearchModelTypeChanged(_selectedModelType), - ); + context + .read() + .add(HeadlinesSearchModelTypeChanged(_selectedModelType)); } }); } @@ -122,32 +120,34 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { return Scaffold( appBar: AppBar( titleSpacing: AppSpacing.paddingSmall, + // backgroundColor: appBarTheme.backgroundColor ?? colorScheme.surface, // Use theme + elevation: appBarTheme.elevation ?? 0, // Use theme elevation title: Row( children: [ SizedBox( - width: 140, + width: 150, // Adjusted width for potentially longer translations child: DropdownButtonFormField( value: _selectedModelType, - decoration: const InputDecoration( - border: InputBorder.none, + decoration: InputDecoration( + border: InputBorder.none, // Clean look contentPadding: EdgeInsets.symmetric( - horizontal: AppSpacing.xs, + horizontal: AppSpacing.sm, // Adjusted padding vertical: AppSpacing.xs, ), + isDense: true, // Make it more compact ), - style: theme.textTheme.titleMedium?.copyWith( - color: - appBarTheme.titleTextStyle?.color ?? + style: textTheme.titleMedium?.copyWith( + color: appBarTheme.titleTextStyle?.color ?? colorScheme.onSurface, ), dropdownColor: colorScheme.surfaceContainerHighest, icon: Icon( - Icons.arrow_drop_down, - color: appBarTheme.iconTheme?.color ?? colorScheme.onSurface, + Icons.arrow_drop_down_rounded, // Rounded icon + color: + appBarTheme.iconTheme?.color ?? colorScheme.onSurfaceVariant, ), items: availableSearchModelTypes.map((SearchModelType type) { String displayLocalizedName; - // The switch is now exhaustive as SearchModelType.country is removed from the enum switch (type) { case SearchModelType.headline: displayLocalizedName = l10n.searchModelTypeHeadline; @@ -158,12 +158,7 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { } return DropdownMenuItem( value: type, - child: Text( - displayLocalizedName, - style: theme.textTheme.titleMedium?.copyWith( - color: colorScheme.onSurface, - ), - ), + child: Text(displayLocalizedName), // Style applied by DropdownButtonFormField ); }).toList(), onChanged: (SearchModelType? newValue) { @@ -171,9 +166,12 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { 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 } }, ), @@ -182,33 +180,35 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { Expanded( child: TextField( controller: _textController, - style: appBarTheme.titleTextStyle ?? theme.textTheme.titleLarge, + style: appBarTheme.titleTextStyle ?? textTheme.titleMedium, // Consistent style decoration: InputDecoration( hintText: _getHintTextForModelType(_selectedModelType, l10n), - hintStyle: theme.textTheme.bodyMedium?.copyWith( + hintStyle: textTheme.bodyMedium?.copyWith( color: (appBarTheme.titleTextStyle?.color ?? colorScheme.onSurface) - .withAlpha(153), + .withOpacity(0.6), // Adjusted opacity ), - border: InputBorder.none, - filled: true, - fillColor: colorScheme.surface.withAlpha(26), + border: InputBorder.none, // Clean look + filled: false, // Use theme's inputDecoratorIsFilled + // fillColor: colorScheme.surface.withAlpha(26), // Use theme contentPadding: const EdgeInsets.symmetric( - horizontal: AppSpacing.paddingMedium, - vertical: AppSpacing.paddingSmall + 3, + horizontal: AppSpacing.md, // Standard padding + vertical: AppSpacing.sm, // Adjusted ), - suffixIcon: - _showClearButton - ? IconButton( - icon: Icon( - Icons.clear, - color: - appBarTheme.iconTheme?.color ?? - colorScheme.onSurface, - ), - onPressed: _textController.clear, - ) - : null, + suffixIcon: _showClearButton + ? IconButton( + icon: Icon( + Icons.clear_rounded, // Rounded icon + color: appBarTheme.iconTheme?.color ?? + colorScheme.onSurfaceVariant, + ), + onPressed: () { + _textController.clear(); + // Optionally clear search results when text is cleared + // context.read().add(HeadlinesSearchTermCleared()); + }, + ) + : null, ), onSubmitted: (_) => _performSearch(), ), @@ -217,28 +217,34 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { ), actions: [ IconButton( - icon: const Icon(Icons.search), + icon: const Icon(Icons.search_outlined), // Use outlined tooltip: l10n.headlinesSearchActionTooltip, onPressed: _performSearch, + // color: appBarTheme.actionsIconTheme?.color, // Use theme ), + const SizedBox(width: AppSpacing.xs) // Add a bit of padding ], ), body: BlocBuilder( builder: (context, state) { + // Ensure textTheme and colorScheme are available in this scope + final currentTextTheme = Theme.of(context).textTheme; + final currentColorScheme = Theme.of(context).colorScheme; + return switch (state) { HeadlinesSearchInitial() => InitialStateWidget( - icon: Icons.search, - headline: l10n.searchPageInitialHeadline, - subheadline: l10n.searchPageInitialSubheadline, - ), - HeadlinesSearchLoading() => InitialStateWidget( - icon: Icons.manage_search, - headline: l10n.headlinesFeedLoadingHeadline, - 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, // Changed from results: final results + items: final items, hasMore: final hasMore, errorMessage: final errorMessage, lastSearchTerm: final lastSearchTerm, @@ -246,163 +252,188 @@ 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( - message: - '${l10n.headlinesSearchNoResultsHeadline} for "$lastSearchTerm" in ${resultsModelType.displayName.toLowerCase()}.\n${l10n.headlinesSearchNoResultsSubheadline}', - ) - : ListView.separated( - controller: _scrollController, - padding: const EdgeInsets.all(AppSpacing.paddingMedium), - itemCount: hasMore ? items.length + 1 : items.length, - separatorBuilder: (context, index) { - // Add a bit more space if the next item is an Ad or AccountAction - if (index < items.length -1) { - final currentItem = items[index]; - final nextItem = items[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.md); - }, - itemBuilder: (context, index) { - if (index >= items.length) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: AppSpacing.lg), - child: Center(child: CircularProgressIndicator()), - ); - } - final feedItem = items[index]; + ) + : 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]; - 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) { - // Placeholder UI for Ad - return Card( - margin: const EdgeInsets.symmetric(vertical: AppSpacing.xs), - color: colorScheme.surfaceContainerHighest, - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - children: [ - if (feedItem.imageUrl.isNotEmpty) - Image.network( - feedItem.imageUrl, - height: 100, - errorBuilder: (ctx, err, st) => - const Icon(Icons.broken_image, size: 50), + 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, + ), + ], ), - const SizedBox(height: AppSpacing.sm), - Text( - 'Placeholder Ad: ${feedItem.adType?.name ?? 'Generic'}', - style: theme.textTheme.titleSmall, - ), - Text( - 'Placement: ${feedItem.placement?.name ?? 'Default'}', - style: theme.textTheme.bodySmall, ), - ], - ), - ), - ); - } else if (feedItem is AccountAction) { - // Placeholder UI for AccountAction - return Card( - margin: const EdgeInsets.symmetric(vertical: AppSpacing.xs), - color: colorScheme.secondaryContainer, - child: ListTile( - leading: Icon( - feedItem.accountActionType == AccountActionType.linkAccount - ? Icons.link - : Icons.upgrade, - color: colorScheme.onSecondaryContainer, - ), - title: Text( - feedItem.title, - style: theme.textTheme.titleMedium?.copyWith( - color: colorScheme.onSecondaryContainer, - fontWeight: FontWeight.bold, - ), - ), - subtitle: feedItem.description != null - ? Text( - feedItem.description!, - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSecondaryContainer.withOpacity(0.8), - ), - ) - : null, - trailing: feedItem.callToActionText != null - ? ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: colorScheme.secondary, - foregroundColor: colorScheme.onSecondary, + ); + } 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, ), - onPressed: () { - if (feedItem.callToActionUrl != null) { - context.push(feedItem.callToActionUrl!); - } - }, - child: Text(feedItem.callToActionText!), - ) - : null, - isThreeLine: feedItem.description != null && feedItem.description!.length > 50, - ), - ); - } - return const SizedBox.shrink(); // Should not happen - }, - ), + ), + 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, + ), + 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, @@ -410,13 +441,13 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { ) => FailureStateWidget( message: - 'Failed to search $lastSearchTerm in ${failedModelType.displayName.toLowerCase()}:\n$errorMessage', - onRetry: - () => context.read().add( - HeadlinesSearchFetchRequested(searchTerm: lastSearchTerm), + 'Failed to search "$lastSearchTerm" in ${failedModelType.displayName.toLowerCase()}:\n$errorMessage', // Improved message + onRetry: () => context.read().add( + HeadlinesSearchFetchRequested( + searchTerm: lastSearchTerm), ), ), - _ => const SizedBox.shrink(), + _ => const SizedBox.shrink(), // Fallback for any other state }; }, ), diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index c7b701c5..38630f09 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -843,5 +843,57 @@ "noFollowedItemsForFilterSnackbar": "أنت لا تتابع أي فئات أو مصادر لتطبيقها كفلتر.", "@noFollowedItemsForFilterSnackbar": { "description": "Snackbar message shown when user tries to apply followed filters but has none." + }, + "requestCodePageHeadline": "أدخل بريدك الإلكتروني", + "@requestCodePageHeadline": { + "description": "Headline for the request code page" + }, + "requestCodePageSubheadline": "سنرسل رمزًا آمنًا إلى بريدك الإلكتروني للتحقق من هويتك.", + "@requestCodePageSubheadline": { + "description": "Subheadline for the request code page" + }, + "requestCodeEmailLabel": "عنوان البريد الإلكتروني", + "@requestCodeEmailLabel": { + "description": "Label for the email input on the request code page" + }, + "requestCodeEmailHint": "you@example.com", + "@requestCodeEmailHint": { + "description": "Hint text for the email input on the request code page" + }, + "requestCodeSendCodeButton": "إرسال الرمز", + "@requestCodeSendCodeButton": { + "description": "Button text to send the verification code" + }, + "entityDetailsCategoryTitle": "الفئة", + "@entityDetailsCategoryTitle": { + "description": "Title for category entity type" + }, + "entityDetailsSourceTitle": "المصدر", + "@entityDetailsSourceTitle": { + "description": "Title for source entity type" + }, + "entityDetailsCountryTitle": "الدولة", + "@entityDetailsCountryTitle": { + "description": "Title for country entity type" + }, + "savedHeadlinesLoadingHeadline": "جارٍ تحميل العناوين المحفوظة...", + "@savedHeadlinesLoadingHeadline": { + "description": "Headline for loading state on saved headlines page" + }, + "savedHeadlinesLoadingSubheadline": "يرجى الانتظار بينما نقوم بجلب مقالاتك المحفوظة.", + "@savedHeadlinesLoadingSubheadline": { + "description": "Subheadline for loading state on saved headlines page" + }, + "savedHeadlinesErrorHeadline": "تعذر تحميل العناوين المحفوظة", + "@savedHeadlinesErrorHeadline": { + "description": "Error message when saved headlines fail to load" + }, + "savedHeadlinesEmptyHeadline": "لا توجد عناوين محفوظة", + "@savedHeadlinesEmptyHeadline": { + "description": "Headline for empty state on saved headlines page" + }, + "savedHeadlinesEmptySubheadline": "لم تقم بحفظ أي مقالات بعد. ابدأ الاستكشاف!", + "@savedHeadlinesEmptySubheadline": { + "description": "Subheadline for empty state on saved headlines page" } } diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 8c6e14b1..0e3bf466 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -843,5 +843,73 @@ "noFollowedItemsForFilterSnackbar": "You are not following any categories or sources to apply as a filter.", "@noFollowedItemsForFilterSnackbar": { "description": "Snackbar message shown when user tries to apply followed filters but has none." + }, + "requestCodePageHeadline": "Enter Your Email", + "@requestCodePageHeadline": { + "description": "Headline for the request code page" + }, + "requestCodePageSubheadline": "We'll send a secure code to your email to verify your identity.", + "@requestCodePageSubheadline": { + "description": "Subheadline for the request code page" + }, + "requestCodeEmailLabel": "Email Address", + "@requestCodeEmailLabel": { + "description": "Label for the email input on the request code page" + }, + "requestCodeEmailHint": "you@example.com", + "@requestCodeEmailHint": { + "description": "Hint text for the email input on the request code page" + }, + "requestCodeSendCodeButton": "Send Code", + "@requestCodeSendCodeButton": { + "description": "Button text to send the verification code" + }, + "entityDetailsCategoryTitle": "Category", + "@entityDetailsCategoryTitle": { + "description": "Title for category entity type" + }, + "entityDetailsSourceTitle": "Source", + "@entityDetailsSourceTitle": { + "description": "Title for source entity type" + }, + "entityDetailsCountryTitle": "Country", + "@entityDetailsCountryTitle": { + "description": "Title for country entity type" + }, + "savedHeadlinesLoadingHeadline": "Loading Saved Headlines...", + "@savedHeadlinesLoadingHeadline": { + "description": "Headline for loading state on saved headlines page" + }, + "savedHeadlinesLoadingSubheadline": "Please wait while we fetch your saved articles.", + "@savedHeadlinesLoadingSubheadline": { + "description": "Subheadline for loading state on saved headlines page" + }, + "savedHeadlinesErrorHeadline": "Could Not Load Saved Headlines", + "@savedHeadlinesErrorHeadline": { + "description": "Error message when saved headlines fail to load" + }, + "savedHeadlinesEmptyHeadline": "No Saved Headlines", + "@savedHeadlinesEmptyHeadline": { + "description": "Headline for empty state on saved headlines page" + }, + "savedHeadlinesEmptySubheadline": "You haven't saved any articles yet. Start exploring!", + "@savedHeadlinesEmptySubheadline": { + "description": "Subheadline for empty state on saved headlines page" + }, + "followedCategoriesLoadingHeadline": "Loading Followed Categories...", + "@followedCategoriesLoadingHeadline": { + "description": "Headline for loading state on followed categories page" + }, + "followedCategoriesErrorHeadline": "Could Not Load Followed Categories", + "@followedCategoriesErrorHeadline": { + "description": "Error message when followed categories fail to load" + }, + "followedCategoriesEmptyHeadline": "No Followed Categories", + "@followedCategoriesEmptyHeadline": { + "description": "Headline for empty state on followed categories page" + }, + "followedCategoriesEmptySubheadline": "Start following categories to see them here.", + "@followedCategoriesEmptySubheadline": { + "description": "Subheadline for empty state on followed categories page" } }