diff --git a/README.md b/README.md index 2f84d10a..cdfbe251 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,114 @@ -# 📰 Headlines Toolkit - -## 📖 Overview - -**source-available**, full-stack Flutter application designed as a robust foundation for building modern news applications. This toolkit offers a streamlined, user-friendly experience for browsing news headlines and is built upon a clean, maintainable architecture. -## ✨ Features - -- 🗞️ **Headlines Feed:** Displays a minimalist list of news headlines (title only, with source, category, and country represented as icons). -- 📃 **Headline Details Page:** Provides detailed information about a headline (title, image, source, category, date, and a "Continue Reading" button that opens the original article in the browser). -- 🔎 **Search:** Allows users to search for headlines. -- 🗂️ **Filtering:** Allows users to filter headlines by category, source, and event country. -- 🌗 **Dark Mode:** Supports light and dark themes. -- 📅 **Planned Features:** - - 👥 User accounts/profiles - - 🌟 Personalized recommendations - - 💾 Saving articles - - 📵 Offline Reading - - 🔔 Push notifications - - 🚀 Social sharing - - 💬 Comments/discussion features +# 📱✨ ht_main: Headlines Toolkit Main App + +![coverage: percentage](https://img.shields.io/badge/coverage-XX-green) +[![style: very good analysis](https://img.shields.io/badge/style-very_good_analysis-B22C89.svg)](https://pub.dev/packages/very_good_analysis) +[![License: PolyForm Free Trial](https://img.shields.io/badge/License-PolyForm%20Free%20Trial-blue)](https://polyformproject.org/licenses/free-trial/1.0.0) + +`ht_main`** is a Flutter mobile application serves as both a powerful, fully functional news application ready for deployment, and an exceptionally robust starter kit, architected for easy extension and customization. It is a key component of the [Headlines Toolkit](https://github.com/headlines-toolkit), an ecosystem that also includes a [Dart Frog backend API](https://github.com/headlines-toolkit/ht-api) and a [web-based content dashboard](https://github.com/headlines-toolkit/ht-dashboard). + +## ⭐ Features & Benefits + +`ht_main` comes packed with features to accelerate your development and delight your users: + +#### 📰 **Dynamic & Engaging Headlines Feed** +Experience a beautifully crafted, infinitely scrolling news feed. It's highly performant and ready for your content. +* **Benefit for you:** Save months of UI/UX development and complex state management. Get a production-quality feed system instantly! ⏱️ + +#### 🔍 **Advanced Content Filtering & Search** +Empower users with intuitive filtering for headlines by categories, sources, and countries. A dedicated search page helps users find exactly what they're looking for. +* **Benefit for you:** Offer powerful content discovery tools that significantly enhance user engagement and satisfaction. 🎯 + +#### 🔐 **Robust User Authentication** +Secure and flexible authentication flows are built-in: +* 📧 **Email + Code (Passwordless) Sign-In:** Modern and secure. +* 👤 **Anonymous Sign-In:** Allow users to explore before committing. +* 🔗 **Account Linking:** Seamlessly convert anonymous users to registered accounts, ensuring all their personalized settings (like theme and language), content preferences (followed categories, sources, countries), and saved headlines are preserved and synced. +* **Benefit for you:** Complex security and user management handled, including data migration during account linking, letting you focus on features. ✅ + +#### 🧑‍🎨 **Personalized User Accounts & Preferences** +Users can tailor their experience: +* **Content Preferences:** Follow/unfollow categories, sources, and countries. +* **Saved Headlines:** Bookmark articles for easy access later. +* **Benefit for you:** A strong foundation for personalization, driving user retention and creating a sticky app experience. ❤️ + +#### ⚙️ **Customizable App Settings** +Offer users control over their app experience: +* **Appearance:** Light/Dark/System themes, accent colors (via FlexColorScheme), font choices, and text scaling. +* **Feed Display:** Customize how headlines are presented. +* **Benefit for you:** Provide a premium, adaptable user experience that caters to individual needs. 🔧 + +#### 📱 **Adaptive UI for All Screens** +Built with `flutter_adaptive_scaffold`, `ht_main` offers responsive navigation and layouts that look great on both phones and tablets. +* **Benefit for you:** Deliver a consistent and optimized UX across a wide range of devices effortlessly. ↔️ + +#### 🏗️ **Clean & Modern Architecture** +Developed with best practices for a maintainable and scalable codebase: +* **Flutter & Dart:** Cutting-edge mobile development. +* **BLoC Pattern:** Predictable and robust state management. +* **GoRouter:** Well-structured and powerful navigation. +* **Benefit for you:** An easy-to-understand, extendable, and testable foundation for your project. 📈 + +#### 🌍 **Localization Ready** +Fully internationalized with working English and Arabic localizations (`.arb` files). Adding more languages is straightforward. +* **Benefit for you:** Easily adapt your application for a global audience. 🌐 + +--- ## 🛠️ Technical Overview -- 🎯 **Language:** Dart -- 💙 **Framework:** Flutter -- 🧱 **State Management:** BLoC -- 🔀 **Routing:** go_router -- ⚙️ **Backend:** Firebase (current), Supabase (future) -- 🏛️ **Architecture:** Layered architecture (Data, Repository, Business Logic, Presentation) +* **Framework:** Flutter +* **Language:** Dart +* **State Management:** BLoC / flutter_bloc +* **Navigation:** GoRouter +* **Theming:** FlexColorScheme +* **Core Dependencies:** Integrates seamlessly with Headlines Toolkit shared packages (`ht_shared`, `ht_auth_repository`, `ht_data_repository`, `ht_http_client`, etc.). + +--- ## 🔑 Access and Licensing -`ht_main` is source-available as part of the Headlines Toolkit ecosystem. +`ht-main` is source-available as part of the Headlines Toolkit ecosystem. -The source code for `ht_main` is available for review as part of the Headlines -Toolkit ecosystem. To acquire a commercial license for building unlimited news -applications with the Headlines Toolkit repositories, please visit the -[Headlines Toolkit GitHub organization page](https://github.com/headlines-toolkit) +To acquire a commercial license for building unlimited news applications, please visit +the [Headlines Toolkit GitHub organization page](https://github.com/headlines-toolkit) for more details. + +--- + +## 🚀 Getting Started + +1. **Ensure Flutter is installed.** (See [Flutter documentation](https://flutter.dev/docs/get-started/install)) +2. **Clone the repository:** + ```bash + git clone https://github.com/headlines-toolkit/ht-main.git + cd ht-main + ``` +3. **Get dependencies:** + ```bash + flutter pub get + ``` +4. **Run the app:** + ```bash + flutter run + ``` + *(Note: For full functionality, ensure the `ht-api` backend service is running and accessible.)* + +--- + +## ✅ Testing + +This project aims for high test coverage to ensure quality and reliability. + +* Run tests with: + ```bash + flutter test + ``` + +--- + +## 📜 License + +This package is licensed under the **PolyForm Free Trial**. +Please review the [LICENSE](LICENSE) file for details. + +--- diff --git a/lib/account/bloc/account_bloc.dart b/lib/account/bloc/account_bloc.dart index e123e064..6de21ffb 100644 --- a/lib/account/bloc/account_bloc.dart +++ b/lib/account/bloc/account_bloc.dart @@ -2,10 +2,10 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; +// Hide Category import 'package:ht_auth_repository/ht_auth_repository.dart'; import 'package:ht_data_repository/ht_data_repository.dart'; -import 'package:ht_shared/ht_shared.dart' - show HtHttpException, User, UserContentPreferences; +import 'package:ht_shared/ht_shared.dart'; part 'account_event.dart'; part 'account_state.dart'; @@ -31,6 +31,10 @@ class AccountBloc extends Bloc { on( _onAccountLoadContentPreferencesRequested, ); + on(_onFollowCategoryToggled); + on(_onFollowSourceToggled); + on(_onFollowCountryToggled); + on(_onSaveHeadlineToggled); // Handlers for AccountSettingsNavigationRequested and // AccountBackupNavigationRequested are typically handled in the UI layer // (e.g., BlocListener navigating) or could emit specific states if needed. @@ -90,4 +94,162 @@ class AccountBloc extends Bloc { ); } } + + Future _persistPreferences( + UserContentPreferences preferences, + Emitter emit, + ) async { + if (state.user == null) { + emit( + state.copyWith( + status: AccountStatus.failure, + errorMessage: 'User not authenticated to save preferences.', + ), + ); + return; + } + try { + await _userContentPreferencesRepository.update( + id: state.user!.id, // ID of the preferences object is the user's ID + item: preferences, + userId: state.user!.id, + ); + // Optimistic update already done, emit success if needed for UI feedback + // emit(state.copyWith(status: AccountStatus.success)); + } on HtHttpException catch (e) { + emit( + state.copyWith( + status: AccountStatus.failure, + errorMessage: 'Failed to save preferences: ${e.message}', + ), + ); + } catch (e) { + emit( + state.copyWith( + status: AccountStatus.failure, + errorMessage: 'An unexpected error occurred while saving: $e', + ), + ); + } + } + + Future _onFollowCategoryToggled( + AccountFollowCategoryToggled event, + Emitter emit, + ) async { + if (state.preferences == null || state.user == null) return; + + final currentPrefs = state.preferences!; + final updatedFollowedCategories = List.from( + currentPrefs.followedCategories, + ); + + final isCurrentlyFollowing = updatedFollowedCategories.any( + (category) => category.id == event.category.id, + ); + + if (isCurrentlyFollowing) { + updatedFollowedCategories.removeWhere( + (category) => category.id == event.category.id, + ); + } else { + updatedFollowedCategories.add(event.category); + } + + final newPreferences = currentPrefs.copyWith( + followedCategories: updatedFollowedCategories, + ); + emit(state.copyWith(preferences: newPreferences)); + await _persistPreferences(newPreferences, emit); + } + + Future _onFollowSourceToggled( + AccountFollowSourceToggled event, + Emitter emit, + ) async { + if (state.preferences == null || state.user == null) return; + + final currentPrefs = state.preferences!; + final updatedFollowedSources = List.from( + currentPrefs.followedSources, + ); + + final isCurrentlyFollowing = updatedFollowedSources.any( + (source) => source.id == event.source.id, + ); + + if (isCurrentlyFollowing) { + updatedFollowedSources.removeWhere( + (source) => source.id == event.source.id, + ); + } else { + updatedFollowedSources.add(event.source); + } + + final newPreferences = currentPrefs.copyWith( + followedSources: updatedFollowedSources, + ); + emit(state.copyWith(preferences: newPreferences)); + await _persistPreferences(newPreferences, emit); + } + + Future _onFollowCountryToggled( + AccountFollowCountryToggled event, + Emitter emit, + ) async { + if (state.preferences == null || state.user == null) return; + + final currentPrefs = state.preferences!; + final updatedFollowedCountries = List.from( + currentPrefs.followedCountries, + ); + + final isCurrentlyFollowing = updatedFollowedCountries.any( + (country) => country.id == event.country.id, + ); + + if (isCurrentlyFollowing) { + updatedFollowedCountries.removeWhere( + (country) => country.id == event.country.id, + ); + } else { + updatedFollowedCountries.add(event.country); + } + + final newPreferences = currentPrefs.copyWith( + followedCountries: updatedFollowedCountries, + ); + emit(state.copyWith(preferences: newPreferences)); + await _persistPreferences(newPreferences, emit); + } + + Future _onSaveHeadlineToggled( + AccountSaveHeadlineToggled event, + Emitter emit, + ) async { + if (state.preferences == null || state.user == null) return; + + final currentPrefs = state.preferences!; + final updatedSavedHeadlines = List.from( + currentPrefs.savedHeadlines, + ); + + final isCurrentlySaved = updatedSavedHeadlines.any( + (headline) => headline.id == event.headline.id, + ); + + if (isCurrentlySaved) { + updatedSavedHeadlines.removeWhere( + (headline) => headline.id == event.headline.id, + ); + } else { + updatedSavedHeadlines.add(event.headline); + } + + final newPreferences = currentPrefs.copyWith( + savedHeadlines: updatedSavedHeadlines, + ); + emit(state.copyWith(preferences: newPreferences)); + await _persistPreferences(newPreferences, emit); + } } diff --git a/lib/account/bloc/account_event.dart b/lib/account/bloc/account_event.dart index 16132911..2f8ac8b1 100644 --- a/lib/account/bloc/account_event.dart +++ b/lib/account/bloc/account_event.dart @@ -38,3 +38,55 @@ final class AccountLoadContentPreferencesRequested extends AccountEvent { @override List get props => [userId]; } + +/// {@template account_follow_category_toggled} +/// Event triggered when a user toggles following a category. +/// {@endtemplate} +final class AccountFollowCategoryToggled extends AccountEvent { + /// {@macro account_follow_category_toggled} + const AccountFollowCategoryToggled({required this.category}); + + final Category category; + + @override + List get props => [category]; +} + +/// {@template account_follow_source_toggled} +/// Event triggered when a user toggles following a source. +/// {@endtemplate} +final class AccountFollowSourceToggled extends AccountEvent { + /// {@macro account_follow_source_toggled} + const AccountFollowSourceToggled({required this.source}); + + final Source source; + + @override + List get props => [source]; +} + +/// {@template account_follow_country_toggled} +/// Event triggered when a user toggles following a country. +/// {@endtemplate} +final class AccountFollowCountryToggled extends AccountEvent { + /// {@macro account_follow_country_toggled} + const AccountFollowCountryToggled({required this.country}); + + final Country country; + + @override + List get props => [country]; +} + +/// {@template account_save_headline_toggled} +/// Event triggered when a user toggles saving a headline. +/// {@endtemplate} +final class AccountSaveHeadlineToggled extends AccountEvent { + /// {@macro account_save_headline_toggled} + const AccountSaveHeadlineToggled({required this.headline}); + + final Headline headline; + + @override + List get props => [headline]; +} diff --git a/lib/account/view/content_preferences_page.dart b/lib/account/view/content_preferences_page.dart index 30cceb6e..f752e227 100644 --- a/lib/account/view/content_preferences_page.dart +++ b/lib/account/view/content_preferences_page.dart @@ -1,8 +1,15 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:ht_main/account/bloc/account_bloc.dart'; import 'package:ht_main/l10n/l10n.dart'; +import 'package:ht_main/router/routes.dart'; +import 'package:ht_main/shared/constants/app_spacing.dart'; +import 'package:ht_shared/ht_shared.dart'; /// {@template content_preferences_page} -/// A placeholder page for managing user content preferences. +/// Page for managing user's content preferences, including followed +/// categories, sources, and countries. /// {@endtemplate} class ContentPreferencesPage extends StatelessWidget { /// {@macro content_preferences_page} @@ -11,9 +18,276 @@ class ContentPreferencesPage extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = context.l10n; - return Scaffold( - appBar: AppBar(title: Text(l10n.accountContentPreferencesTile)), - body: const Center(child: Text('CONTENT PREFERENCES PAGE (Placeholder)')), + + return DefaultTabController( + length: 3, // Categories, Sources, Countries + child: Scaffold( + appBar: AppBar( + title: Text( + l10n.accountContentPreferencesTile, + ), // Reusing existing key + bottom: TabBar( + tabs: [ + Tab(text: l10n.headlinesFeedFilterCategoryLabel), // Reusing + Tab(text: l10n.headlinesFeedFilterSourceLabel), // Reusing + Tab(text: l10n.headlinesFeedFilterEventCountryLabel), // Reusing + ], + ), + ), + body: BlocBuilder( + builder: (context, state) { + if (state.status == AccountStatus.loading && + state.preferences == null) { + return const Center(child: CircularProgressIndicator()); + } + + if (state.status == AccountStatus.failure && + state.preferences == null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.paddingLarge), + child: Text( + state.errorMessage ?? l10n.unknownError, + textAlign: TextAlign.center, + ), + ), + ); + } + + // Preferences might be null initially or if loading failed but user existed + final preferences = + state.preferences ?? const UserContentPreferences(id: ''); + + return TabBarView( + children: [ + _buildCategoriesView( + context, + preferences.followedCategories, + l10n, + ), + _buildSourcesView(context, preferences.followedSources, l10n), + _buildCountriesView( + context, + preferences.followedCountries, + l10n, + ), + ], + ); + }, + ), + ), + ); + } + + Widget _buildCategoriesView( + BuildContext context, + List followedCategories, + AppLocalizations l10n, + ) { + if (followedCategories.isEmpty) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(l10n.categoryFilterEmptyHeadline), // Reusing key + const SizedBox(height: AppSpacing.md), + ElevatedButton.icon( + icon: const Icon(Icons.add_circle_outline), + label: Text(l10n.headlinesFeedFilterCategoryLabel), // "Category" + onPressed: () { + context.goNamed(Routes.feedFilterCategoriesName); + }, + ), + ], + ); + } + + return Column( + children: [ + Expanded( + child: ListView.builder( + padding: const EdgeInsets.all(AppSpacing.paddingMedium), + itemCount: followedCategories.length, + itemBuilder: (context, index) { + final category = followedCategories[index]; + return Card( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + child: ListTile( + title: Text(category.name), + trailing: IconButton( + icon: const Icon( + Icons.remove_circle_outline, + color: Colors.red, + ), + tooltip: 'Unfollow ${category.name}', // Consider l10n + onPressed: () { + context.read().add( + AccountFollowCategoryToggled(category: category), + ); + }, + ), + ), + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.all(AppSpacing.paddingMedium), + child: ElevatedButton.icon( + icon: const Icon(Icons.edit_outlined), + label: Text( + 'Manage ${l10n.headlinesFeedFilterCategoryLabel}', + ), // "Manage Category" + onPressed: () { + context.goNamed(Routes.feedFilterCategoriesName); + }, + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 48), + ), + ), + ), + ], + ); + } + + Widget _buildSourcesView( + BuildContext context, + List followedSources, + AppLocalizations l10n, + ) { + if (followedSources.isEmpty) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(l10n.sourceFilterEmptyHeadline), // Reusing key + const SizedBox(height: AppSpacing.md), + ElevatedButton.icon( + icon: const Icon(Icons.add_circle_outline), + label: Text(l10n.headlinesFeedFilterSourceLabel), // "Source" + onPressed: () { + context.goNamed(Routes.feedFilterSourcesName); + }, + ), + ], + ); + } + + return Column( + children: [ + Expanded( + child: ListView.builder( + padding: const EdgeInsets.all(AppSpacing.paddingMedium), + itemCount: followedSources.length, + itemBuilder: (context, index) { + final source = followedSources[index]; + return Card( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + child: ListTile( + title: Text(source.name), + // Consider adding source.iconUrl if available and desired + trailing: IconButton( + icon: const Icon( + Icons.remove_circle_outline, + color: Colors.red, + ), + tooltip: 'Unfollow ${source.name}', // Consider l10n + onPressed: () { + context.read().add( + AccountFollowSourceToggled(source: source), + ); + }, + ), + ), + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.all(AppSpacing.paddingMedium), + child: ElevatedButton.icon( + icon: const Icon(Icons.edit_outlined), + label: Text( + 'Manage ${l10n.headlinesFeedFilterSourceLabel}', + ), // "Manage Source" + onPressed: () { + context.goNamed(Routes.feedFilterSourcesName); + }, + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 48), + ), + ), + ), + ], + ); + } + + Widget _buildCountriesView( + BuildContext context, + List followedCountries, + AppLocalizations l10n, + ) { + if (followedCountries.isEmpty) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(l10n.countryFilterEmptyHeadline), // Reusing key + const SizedBox(height: AppSpacing.md), + ElevatedButton.icon( + icon: const Icon(Icons.add_circle_outline), + label: Text(l10n.headlinesFeedFilterEventCountryLabel), // "Country" + onPressed: () { + context.goNamed(Routes.feedFilterCountriesName); + }, + ), + ], + ); + } + + return Column( + children: [ + Expanded( + child: ListView.builder( + padding: const EdgeInsets.all(AppSpacing.paddingMedium), + itemCount: followedCountries.length, + itemBuilder: (context, index) { + final country = followedCountries[index]; + return Card( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + child: ListTile( + // leading: country.flagUrl != null ? Image.network(country.flagUrl!, width: 36, height: 24, fit: BoxFit.cover) : null, // Optional: Display flag + title: Text(country.name), + trailing: IconButton( + icon: const Icon( + Icons.remove_circle_outline, + color: Colors.red, + ), + tooltip: 'Unfollow ${country.name}', // Consider l10n + onPressed: () { + context.read().add( + AccountFollowCountryToggled(country: country), + ); + }, + ), + ), + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.all(AppSpacing.paddingMedium), + child: ElevatedButton.icon( + icon: const Icon(Icons.edit_outlined), + label: Text( + 'Manage ${l10n.headlinesFeedFilterEventCountryLabel}', + ), // "Manage Country" + onPressed: () { + context.goNamed(Routes.feedFilterCountriesName); + }, + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 48), + ), + ), + ), + ], ); } } diff --git a/lib/authentication/view/email_code_verification_page.dart b/lib/authentication/view/email_code_verification_page.dart index 03e1560c..9c356a49 100644 --- a/lib/authentication/view/email_code_verification_page.dart +++ b/lib/authentication/view/email_code_verification_page.dart @@ -1,4 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:ht_main/authentication/bloc/authentication_bloc.dart'; import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/shared/constants/app_spacing.dart'; @@ -17,75 +20,149 @@ class EmailCodeVerificationPage extends StatelessWidget { Widget build(BuildContext context) { final l10n = context.l10n; final textTheme = Theme.of(context).textTheme; + final colorScheme = Theme.of(context).colorScheme; return Scaffold( - appBar: AppBar( - title: Text(l10n.emailCodeSentPageTitle), // Updated l10n key - ), + appBar: AppBar(title: Text(l10n.emailCodeSentPageTitle)), body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.paddingLarge), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.mark_email_read_outlined, // Suggestive icon - size: 80, - // Consider using theme color - // color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(height: AppSpacing.xl), - Text( - l10n.emailCodeSentConfirmation(email), // Pass email to l10n - style: textTheme.titleLarge, // Prominent text style - textAlign: TextAlign.center, - ), - const SizedBox(height: AppSpacing.xxl), - Text( - l10n.emailCodeSentInstructions, // New l10n key for instructions - style: textTheme.bodyMedium, - textAlign: TextAlign.center, - ), - const SizedBox(height: AppSpacing.lg), - // Input field for the 6-digit code - Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, + child: BlocConsumer( + listener: (context, state) { + if (state is AuthenticationFailure) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(state.errorMessage), + backgroundColor: colorScheme.error, ), - child: TextField( - // TODO(cline): Add controller and validation - keyboardType: TextInputType.number, - maxLength: 6, - textAlign: TextAlign.center, - decoration: InputDecoration( - hintText: l10n.emailCodeVerificationHint, // Add l10n key - border: const OutlineInputBorder(), - counterText: '', // Hide the counter - ), + ); + } + // Successful authentication is handled by AppBloc redirecting. + }, + builder: (context, state) { + final isLoading = state is AuthenticationLoading; + + return Padding( + padding: const EdgeInsets.all(AppSpacing.paddingLarge), + child: Center( + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.mark_email_read_outlined, size: 80), + const SizedBox(height: AppSpacing.xl), + Text( + l10n.emailCodeSentConfirmation(email), + style: textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.xxl), + Text( + l10n.emailCodeSentInstructions, + style: textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.lg), + _EmailCodeVerificationForm( + email: email, + isLoading: isLoading, + ), + ], ), ), - const SizedBox(height: AppSpacing.xl), - // Verify button - ElevatedButton( - // TODO(cline): Add onPressed logic to dispatch event - onPressed: () { - // Dispatch event to AuthenticationBloc - // context.read().add( - // AuthenticationEmailCodeVerificationRequested( - // email: email, - // code: 'entered_code', // Get code from TextField - // ), - // ); - }, - child: Text( - l10n.emailCodeVerificationButtonLabel, - ), // Add l10n key - ), - ], + ), + ); + }, + ), + ), + ); + } +} + +class _EmailCodeVerificationForm extends StatefulWidget { + const _EmailCodeVerificationForm({ + required this.email, + required this.isLoading, + }); + + final String email; + final bool isLoading; + + @override + State<_EmailCodeVerificationForm> createState() => + _EmailCodeVerificationFormState(); +} + +class _EmailCodeVerificationFormState + extends State<_EmailCodeVerificationForm> { + final _formKey = GlobalKey(); + final _codeController = TextEditingController(); + + @override + void dispose() { + _codeController.dispose(); + super.dispose(); + } + + void _submitForm() { + if (_formKey.currentState!.validate()) { + context.read().add( + AuthenticationVerifyCodeRequested( + email: widget.email, + code: _codeController.text.trim(), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + return Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + child: TextFormField( + controller: _codeController, + decoration: InputDecoration( + hintText: l10n.emailCodeVerificationHint, + border: const OutlineInputBorder(), + counterText: '', // Hide the counter + ), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + maxLength: 6, + textAlign: TextAlign.center, + enabled: !widget.isLoading, + validator: (value) { + if (value == null || value.isEmpty) { + return l10n.emailCodeValidationEmptyError; + } + if (value.length != 6) { + return l10n.emailCodeValidationLengthError; + } + return null; + }, + onFieldSubmitted: widget.isLoading ? null : (_) => _submitForm(), ), ), - ), + const SizedBox(height: AppSpacing.xl), + ElevatedButton( + onPressed: widget.isLoading ? null : _submitForm, + child: + widget.isLoading + ? const SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(l10n.emailCodeVerificationButtonLabel), + ), + ], ), ); } diff --git a/lib/authentication/view/request_code_page.dart b/lib/authentication/view/request_code_page.dart index 191ddc47..88e3b216 100644 --- a/lib/authentication/view/request_code_page.dart +++ b/lib/authentication/view/request_code_page.dart @@ -9,13 +9,14 @@ import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/router/routes.dart'; import 'package:ht_main/shared/constants/app_spacing.dart'; -/// {@template email_sign_in_page} -/// Page for initiating the email link sign-in process. -/// Explains the passwordless flow and collects the user's email. +/// {@template request_code_page} +/// Page for initiating the email code sign-in process. +/// Explains the passwordless flow and collects the user's email to send a +/// verification code. /// {@endtemplate} -class EmailSignInPage extends StatelessWidget { - /// {@macro email_sign_in_page} - const EmailSignInPage({ +class RequestCodePage extends StatelessWidget { + /// {@macro request_code_page} + const RequestCodePage({ required this.isLinkingContext, // Accept the flag super.key, }); @@ -25,16 +26,15 @@ class EmailSignInPage extends StatelessWidget { @override Widget build(BuildContext context) { - // Assuming AuthenticationBloc is provided by the parent route (AuthenticationPage) - // If not, it needs to be provided here or higher up. - // Pass the flag down to the view. - return _EmailSignInView(isLinkingContext: isLinkingContext); + // AuthenticationBloc is assumed to be provided by a parent route. + // Pass the linking context flag down to the view. + return _RequestCodeView(isLinkingContext: isLinkingContext); } } -class _EmailSignInView extends StatelessWidget { +class _RequestCodeView extends StatelessWidget { // Accept the flag from the parent page. - const _EmailSignInView({required this.isLinkingContext}); + const _RequestCodeView({required this.isLinkingContext}); final bool isLinkingContext; diff --git a/lib/headlines-feed/bloc/headlines_feed_bloc.dart b/lib/headlines-feed/bloc/headlines_feed_bloc.dart index 45c674aa..978a8a31 100644 --- a/lib/headlines-feed/bloc/headlines_feed_bloc.dart +++ b/lib/headlines-feed/bloc/headlines_feed_bloc.dart @@ -79,7 +79,7 @@ class HeadlinesFeedBloc extends Bloc { .whereType() .map((c) => c.isoCode) .toList(), - }, limit: _headlinesFetchLimit); + }, limit: _headlinesFetchLimit,); emit( HeadlinesFeedLoaded( headlines: response.items, @@ -254,7 +254,7 @@ class HeadlinesFeedBloc extends Bloc { .whereType() .map((c) => c.isoCode) .toList(), - }, limit: _headlinesFetchLimit); + }, limit: _headlinesFetchLimit,); emit( HeadlinesFeedLoaded( headlines: response.items, // Replace headlines on refresh diff --git a/lib/headlines-search/bloc/headlines_search_bloc.dart b/lib/headlines-search/bloc/headlines_search_bloc.dart index c3efae8f..14494a6c 100644 --- a/lib/headlines-search/bloc/headlines_search_bloc.dart +++ b/lib/headlines-search/bloc/headlines_search_bloc.dart @@ -1,4 +1,5 @@ import 'package:bloc/bloc.dart'; +import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:equatable/equatable.dart'; import 'package:ht_data_repository/ht_data_repository.dart'; // Generic Data Repository import 'package:ht_shared/ht_shared.dart'; // Shared models, including Headline @@ -10,8 +11,12 @@ class HeadlinesSearchBloc extends Bloc { HeadlinesSearchBloc({required HtDataRepository headlinesRepository}) : _headlinesRepository = headlinesRepository, - super(HeadlinesSearchLoading()) { - on(_onSearchFetchRequested); + super(const HeadlinesSearchInitial()) { + // Start with Initial state + on( + _onSearchFetchRequested, + transformer: restartable(), // Process only the latest search + ); } final HtDataRepository _headlinesRepository; @@ -32,54 +37,75 @@ class HeadlinesSearchBloc return; } - if (state is HeadlinesSearchSuccess && - event.searchTerm == state.lastSearchTerm) { - final currentState = state as HeadlinesSearchSuccess; - if (!currentState.hasMore) return; + // Check if current state is success and if the search term is the same for pagination + if (state is HeadlinesSearchSuccess) { + final successState = state as HeadlinesSearchSuccess; + if (event.searchTerm == successState.lastSearchTerm) { + // This is a pagination request for the current search term + if (!successState.hasMore) return; // No more items to paginate - try { - final response = await _headlinesRepository.readAllByQuery( - {'query': event.searchTerm}, // Use query map - limit: _limit, - startAfterId: currentState.cursor, - ); - emit( - response.items.isEmpty - ? currentState.copyWith(hasMore: false) - : currentState.copyWith( - headlines: List.of(currentState.headlines) - ..addAll(response.items), - hasMore: response.hasMore, - cursor: response.cursor, - ), - ); - } catch (e) { - emit(currentState.copyWith(errorMessage: e.toString())); - } - } else { - try { - final response = await _headlinesRepository.readAllByQuery( - {'query': event.searchTerm}, // Use query map - limit: _limit, - ); - emit( - HeadlinesSearchSuccess( - headlines: response.items, - hasMore: response.hasMore, - cursor: response.cursor, - lastSearchTerm: event.searchTerm, - ), - ); - } catch (e) { - emit( - HeadlinesSearchSuccess( - headlines: const [], - hasMore: false, - errorMessage: e.toString(), - lastSearchTerm: event.searchTerm, - ), - ); + // It's a bit unusual to emit Loading here for pagination, + // typically UI handles this. Let's keep it simple for now. + // emit(HeadlinesSearchLoading(lastSearchTerm: event.searchTerm)); + try { + final response = await _headlinesRepository.readAllByQuery( + {'query': event.searchTerm}, + limit: _limit, + startAfterId: successState.cursor, + ); + emit( + response.items.isEmpty + ? successState.copyWith(hasMore: false) + : successState.copyWith( + headlines: List.of(successState.headlines) + ..addAll(response.items), + hasMore: response.hasMore, + cursor: response.cursor, + ), + ); + } on HtHttpException catch (e) { + emit(successState.copyWith(errorMessage: e.message)); + } catch (e, st) { + print('Search pagination error: $e\n$st'); + emit( + successState.copyWith(errorMessage: 'Failed to load more results.'), + ); + } + return; // Pagination handled } } + + // If not paginating for the same term, it's a new search or different term + emit( + HeadlinesSearchLoading(lastSearchTerm: event.searchTerm), + ); // Show loading for new search + try { + final response = await _headlinesRepository.readAllByQuery({ + 'query': event.searchTerm, + }, limit: _limit,); + emit( + HeadlinesSearchSuccess( + headlines: response.items, + hasMore: response.hasMore, + cursor: response.cursor, + lastSearchTerm: event.searchTerm, + ), + ); + } on HtHttpException catch (e) { + emit( + HeadlinesSearchFailure( + errorMessage: e.message, + lastSearchTerm: event.searchTerm, + ), + ); + } catch (e, st) { + print('Search error: $e\n$st'); + emit( + HeadlinesSearchFailure( + errorMessage: 'An unexpected error occurred during search.', + lastSearchTerm: event.searchTerm, + ), + ); + } } } diff --git a/lib/headlines-search/bloc/headlines_search_state.dart b/lib/headlines-search/bloc/headlines_search_state.dart index d7768784..2a0007d1 100644 --- a/lib/headlines-search/bloc/headlines_search_state.dart +++ b/lib/headlines-search/bloc/headlines_search_state.dart @@ -2,46 +2,56 @@ part of 'headlines_search_bloc.dart'; abstract class HeadlinesSearchState extends Equatable { const HeadlinesSearchState(); - abstract final String? lastSearchTerm; + // lastSearchTerm will be defined in specific states that need it. @override List get props => []; } +/// Initial state before any search is performed. +class HeadlinesSearchInitial extends HeadlinesSearchState { + const HeadlinesSearchInitial(); + // No lastSearchTerm needed for initial state. +} + +/// State when a search is actively in progress. class HeadlinesSearchLoading extends HeadlinesSearchState { + const HeadlinesSearchLoading({this.lastSearchTerm}); + final String? lastSearchTerm; // Term being loaded + @override - final String? lastSearchTerm = null; - @override - List get props => []; + List get props => [lastSearchTerm]; } +/// State when a search has successfully returned results. class HeadlinesSearchSuccess extends HeadlinesSearchState { const HeadlinesSearchSuccess({ required this.headlines, required this.hasMore, required this.lastSearchTerm, this.cursor, - this.errorMessage, + this.errorMessage, // For non-critical errors like pagination failure }); final List headlines; final bool hasMore; final String? cursor; - final String? errorMessage; - @override - final String? lastSearchTerm; + final String? errorMessage; // e.g., for pagination errors + final String lastSearchTerm; // The term that yielded these results HeadlinesSearchSuccess copyWith({ List? headlines, bool? hasMore, String? cursor, - String? errorMessage, + String? errorMessage, // Allow clearing/setting error String? lastSearchTerm, + bool clearErrorMessage = false, }) { return HeadlinesSearchSuccess( headlines: headlines ?? this.headlines, hasMore: hasMore ?? this.hasMore, cursor: cursor ?? this.cursor, - errorMessage: errorMessage ?? this.errorMessage, + errorMessage: + clearErrorMessage ? null : errorMessage ?? this.errorMessage, lastSearchTerm: lastSearchTerm ?? this.lastSearchTerm, ); } @@ -55,3 +65,17 @@ class HeadlinesSearchSuccess extends HeadlinesSearchState { lastSearchTerm, ]; } + +/// State when a search operation has failed. +class HeadlinesSearchFailure extends HeadlinesSearchState { + const HeadlinesSearchFailure({ + required this.errorMessage, + required this.lastSearchTerm, + }); + + final String errorMessage; + final String lastSearchTerm; // The term that failed + + @override + List get props => [errorMessage, lastSearchTerm]; +} diff --git a/lib/headlines-search/view/headlines_search_page.dart b/lib/headlines-search/view/headlines_search_page.dart index bd0ddeea..87439d62 100644 --- a/lib/headlines-search/view/headlines_search_page.dart +++ b/lib/headlines-search/view/headlines_search_page.dart @@ -68,7 +68,7 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { final searchTerm = state.lastSearchTerm; if (state.hasMore) { context.read().add( - HeadlinesSearchFetchRequested(searchTerm: searchTerm!), + HeadlinesSearchFetchRequested(searchTerm: searchTerm), ); } } @@ -189,7 +189,7 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { // Retry with the last successful search term context.read().add( HeadlinesSearchFetchRequested( - searchTerm: lastSearchTerm ?? '', + searchTerm: lastSearchTerm, ), ); }, diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index b2c86ab5..c13c0717 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -541,5 +541,13 @@ "emailCodeVerificationButtonLabel": "تحقق من الرمز", "@emailCodeVerificationButtonLabel": { "description": "Label for the button to verify the email code" + }, + "emailCodeValidationEmptyError": "الرجاء إدخال الرمز المكون من 6 أرقام.", + "@emailCodeValidationEmptyError": { + "description": "Validation error when the email verification code is empty." + }, + "emailCodeValidationLengthError": "يجب أن يتكون الرمز من 6 أرقام.", + "@emailCodeValidationLengthError": { + "description": "Validation error when the email verification code is not 6 digits." } } diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index a6c63b3d..94094d12 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -541,5 +541,13 @@ "emailCodeVerificationButtonLabel": "Verify Code", "@emailCodeVerificationButtonLabel": { "description": "Button text for verifying the email code" + }, + "emailCodeValidationEmptyError": "Please enter the 6-digit code.", + "@emailCodeValidationEmptyError": { + "description": "Validation error when the email verification code is empty." + }, + "emailCodeValidationLengthError": "The code must be 6 digits.", + "@emailCodeValidationLengthError": { + "description": "Validation error when the email verification code is not 6 digits." } } diff --git a/lib/main.dart b/lib/main.dart index 6ec834b0..7005c44c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -116,9 +116,6 @@ void main() async { ); // 7. Run the App, injecting repositories - // NOTE: The App widget constructor currently expects specific repository types. - // This will cause type errors that will be fixed in the next step (Step 3) - // when we refactor the App widget and router. runApp( App( htAuthenticationRepository: authenticationRepository, diff --git a/lib/router/router.dart b/lib/router/router.dart index 3ee518f7..97005f87 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -10,7 +10,7 @@ import 'package:ht_main/app/view/app_shell.dart'; import 'package:ht_main/authentication/bloc/authentication_bloc.dart'; import 'package:ht_main/authentication/view/authentication_page.dart'; import 'package:ht_main/authentication/view/email_code_verification_page.dart'; -import 'package:ht_main/authentication/view/request_code_page.dart'; // Will be renamed to request_code_page.dart later +import 'package:ht_main/authentication/view/request_code_page.dart'; import 'package:ht_main/headline-details/bloc/headline_details_bloc.dart'; import 'package:ht_main/headline-details/view/headline_details_page.dart'; import 'package:ht_main/headlines-feed/bloc/categories_filter_bloc.dart'; // Import new BLoC @@ -28,7 +28,6 @@ import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/router/routes.dart'; import 'package:ht_main/settings/bloc/settings_bloc.dart'; // Added import 'package:ht_main/settings/view/appearance_settings_page.dart'; // Added -import 'package:ht_main/settings/view/article_settings_page.dart'; // Added import 'package:ht_main/settings/view/feed_settings_page.dart'; // Added import 'package:ht_main/settings/view/notification_settings_page.dart'; // Added import 'package:ht_main/settings/view/settings_page.dart'; // Added @@ -264,9 +263,7 @@ GoRouter createRouter({ builder: (context, state) { // Extract the linking context flag from 'extra', default to false. final isLinking = (state.extra as bool?) ?? false; - return EmailSignInPage( - isLinkingContext: isLinking, - ); // Page will be renamed later + return RequestCodePage(isLinkingContext: isLinking); }, ), GoRoute( @@ -451,17 +448,34 @@ GoRouter createRouter({ name: Routes.settingsName, builder: (context, state) { // Provide SettingsBloc here for SettingsPage and its children + // Access AppBloc to get the current user ID + final appBloc = context.read(); + final userId = appBloc.state.user?.id; + return BlocProvider( - create: - (context) => SettingsBloc( - userAppSettingsRepository: - context - .read< - HtDataRepository - >(), - )..add( - const SettingsLoadRequested(), - ), // Load on entry + create: (context) { + final settingsBloc = SettingsBloc( + userAppSettingsRepository: + context + .read>(), + ); + // Only load settings if a userId is available + if (userId != null) { + settingsBloc.add( + SettingsLoadRequested(userId: userId), + ); + } else { + // Handle case where user is unexpectedly null. + // This might involve logging or emitting an error state + // directly in SettingsBloc if it's designed to handle it, + // or simply not loading settings. + // For now, we'll assume router redirects prevent this. + print( + 'Warning: User ID is null when creating SettingsBloc. Settings will not be loaded.', + ); + } + return settingsBloc; + }, child: const SettingsPage(), // Use the actual page ); }, @@ -479,12 +493,6 @@ GoRouter createRouter({ name: Routes.settingsFeedName, builder: (context, state) => const FeedSettingsPage(), ), - GoRoute( - path: Routes.settingsArticle, // 'article' - name: Routes.settingsArticleName, - builder: - (context, state) => const ArticleSettingsPage(), - ), GoRoute( path: Routes.settingsNotifications, // 'notifications' name: Routes.settingsNotificationsName, diff --git a/lib/settings/bloc/settings_bloc.dart b/lib/settings/bloc/settings_bloc.dart index 3f9377b4..591ef72a 100644 --- a/lib/settings/bloc/settings_bloc.dart +++ b/lib/settings/bloc/settings_bloc.dart @@ -12,8 +12,8 @@ part 'settings_state.dart'; /// {@template settings_bloc} /// Manages the state for the application settings feature. /// -/// Handles loading settings from [HtDataRepository] and processing -/// user actions to update settings. +/// Handles loading [UserAppSettings] from [HtDataRepository] and processing +/// user actions to update these settings. /// {@endtemplate} class SettingsBloc extends Bloc { /// {@macro settings_bloc} @@ -25,7 +25,7 @@ class SettingsBloc extends Bloc { on(_onLoadRequested); on( _onAppThemeModeChanged, - transformer: sequential(), // Ensure saves happen sequentially + transformer: sequential(), ); on( _onAppThemeNameChanged, @@ -39,71 +39,66 @@ class SettingsBloc extends Bloc { _onAppFontTypeChanged, transformer: sequential(), ); + on( + // Added handler for font weight + _onAppFontWeightChanged, + transformer: sequential(), + ); on( _onFeedTileTypeChanged, transformer: sequential(), ); - // on( - // _onNotificationsEnabledChanged, // Corrected handler name if it was misspelled - // transformer: sequential(), - // ); + // SettingsNotificationsEnabledChanged event and handler removed. } final HtDataRepository _userAppSettingsRepository; - /// Handles the initial loading of all settings. - Future _onLoadRequested( - SettingsLoadRequested event, + Future _persistSettings( + UserAppSettings settingsToSave, Emitter emit, ) async { - emit(state.copyWith(status: SettingsStatus.loading)); try { - // Fetch all settings concurrently - // Note: UserAppSettings and UserContentPreferences are fetched as single objects - // from the new generic repositories. - // TODO(cline): Get actual user ID - final appSettings = await _userAppSettingsRepository.read( - id: 'user_id', - ); // Assuming a fixed ID for user settings - - // Process results - emit( - state.copyWith( - status: SettingsStatus.success, - userAppSettings: appSettings, // Update state with new model - clearError: true, - ), + await _userAppSettingsRepository.update( + id: settingsToSave.id, // UserID is the ID of UserAppSettings + item: settingsToSave, + userId: settingsToSave.id, // Pass userId for repository method ); + // State already updated optimistically, no need to emit success here + // unless we want a specific "save success" status. } on HtHttpException catch (e) { - // Catch standardized HTTP exceptions emit(state.copyWith(status: SettingsStatus.failure, error: e)); } catch (e) { - // Catch any other unexpected errors emit(state.copyWith(status: SettingsStatus.failure, error: e)); } } - /// Handles changes to the App Theme Mode setting. - Future _onAppThemeModeChanged( - SettingsAppThemeModeChanged event, + Future _onLoadRequested( + SettingsLoadRequested event, Emitter emit, ) async { - // Read current settings, update, and save + emit(state.copyWith(status: SettingsStatus.loading, clearError: true)); try { - // TODO(cline): Get actual user ID - final currentSettings = await _userAppSettingsRepository.read( - id: 'user_id', + final appSettings = await _userAppSettingsRepository.read( + id: event.userId, + userId: event.userId, ); - final updatedSettings = currentSettings.copyWith( - displaySettings: currentSettings.displaySettings.copyWith( - baseTheme: event.themeMode, + emit( + state.copyWith( + status: SettingsStatus.success, + userAppSettings: appSettings, ), ); - await _userAppSettingsRepository.update( - id: 'user_id', - item: updatedSettings, + } on NotFoundException { + // Settings not found for the user, create and persist defaults + final defaultSettings = UserAppSettings(id: event.userId); + emit( + state.copyWith( + status: SettingsStatus.success, + userAppSettings: defaultSettings, + ), ); - emit(state.copyWith(userAppSettings: updatedSettings, clearError: true)); + // Persist these default settings + await _persistSettings(defaultSettings, emit); } on HtHttpException catch (e) { emit(state.copyWith(status: SettingsStatus.failure, error: e)); } catch (e) { @@ -111,155 +106,93 @@ class SettingsBloc extends Bloc { } } - /// Handles changes to the App Theme Name setting. + Future _onAppThemeModeChanged( + SettingsAppThemeModeChanged event, + Emitter emit, + ) async { + if (state.userAppSettings == null) return; // Guard against null settings + + final updatedSettings = state.userAppSettings!.copyWith( + displaySettings: state.userAppSettings!.displaySettings.copyWith( + baseTheme: event.themeMode, + ), + ); + emit(state.copyWith(userAppSettings: updatedSettings, clearError: true)); + await _persistSettings(updatedSettings, emit); + } + Future _onAppThemeNameChanged( SettingsAppThemeNameChanged event, Emitter emit, ) async { - // Read current settings, update, and save - try { - // TODO(cline): Get actual user ID - final currentSettings = await _userAppSettingsRepository.read( - id: 'user_id', - ); - final updatedSettings = currentSettings.copyWith( - displaySettings: currentSettings.displaySettings.copyWith( - accentTheme: event.themeName, - ), - ); - await _userAppSettingsRepository.update( - id: 'user_id', - item: updatedSettings, - ); - emit(state.copyWith(userAppSettings: updatedSettings, clearError: true)); - } on HtHttpException catch (e) { - emit(state.copyWith(status: SettingsStatus.failure, error: e)); - } catch (e) { - emit(state.copyWith(status: SettingsStatus.failure, error: e)); - } + if (state.userAppSettings == null) return; + + final updatedSettings = state.userAppSettings!.copyWith( + displaySettings: state.userAppSettings!.displaySettings.copyWith( + accentTheme: event.themeName, + ), + ); + emit(state.copyWith(userAppSettings: updatedSettings, clearError: true)); + await _persistSettings(updatedSettings, emit); } - /// Handles changes to the App Font Size setting. Future _onAppFontSizeChanged( SettingsAppFontSizeChanged event, Emitter emit, ) async { - // Read current settings, update, and save - try { - // TODO(cline): Get actual user ID - final currentSettings = await _userAppSettingsRepository.read( - id: 'user_id', - ); - final updatedSettings = currentSettings.copyWith( - displaySettings: currentSettings.displaySettings.copyWith( - textScaleFactor: event.fontSize, - ), - ); - await _userAppSettingsRepository.update( - id: 'user_id', - item: updatedSettings, - ); - emit(state.copyWith(userAppSettings: updatedSettings, clearError: true)); - } on HtHttpException catch (e) { - emit(state.copyWith(status: SettingsStatus.failure, error: e)); - } catch (e) { - emit(state.copyWith(status: SettingsStatus.failure, error: e)); - } + if (state.userAppSettings == null) return; + + final updatedSettings = state.userAppSettings!.copyWith( + displaySettings: state.userAppSettings!.displaySettings.copyWith( + textScaleFactor: event.fontSize, + ), + ); + emit(state.copyWith(userAppSettings: updatedSettings, clearError: true)); + await _persistSettings(updatedSettings, emit); } - /// Handles changes to the App Font Type setting. Future _onAppFontTypeChanged( SettingsAppFontTypeChanged event, Emitter emit, ) async { - // Read current settings, update, and save - try { - // TODO(cline): Get actual user ID - final currentSettings = await _userAppSettingsRepository.read( - id: 'user_id', - ); - final updatedSettings = currentSettings.copyWith( - displaySettings: currentSettings.displaySettings.copyWith( - fontFamily: event.fontType, - ), - ); - await _userAppSettingsRepository.update( - id: 'user_id', - item: updatedSettings, - ); - emit(state.copyWith(userAppSettings: updatedSettings, clearError: true)); - } on HtHttpException catch (e) { - emit(state.copyWith(status: SettingsStatus.failure, error: e)); - } catch (e) { - emit(state.copyWith(status: SettingsStatus.failure, error: e)); - } + if (state.userAppSettings == null) return; + + final updatedSettings = state.userAppSettings!.copyWith( + displaySettings: state.userAppSettings!.displaySettings.copyWith( + fontFamily: event.fontType, + ), + ); + emit(state.copyWith(userAppSettings: updatedSettings, clearError: true)); + await _persistSettings(updatedSettings, emit); } - /// Handles changes to the Feed Tile Type setting. - Future _onFeedTileTypeChanged( - SettingsFeedTileTypeChanged event, + Future _onAppFontWeightChanged( + SettingsAppFontWeightChanged event, Emitter emit, ) async { - // Read current settings, update, and save - try { - // TODO(cline): Get actual user ID - final currentSettings = await _userAppSettingsRepository.read( - id: 'user_id', - ); - // Note: This event currently only handles HeadlineImageStyle. - // A separate event/logic might be needed for HeadlineDensity. - final updatedSettings = currentSettings.copyWith( - feedPreferences: currentSettings.feedPreferences.copyWith( - headlineImageStyle: event.tileType, - ), - ); - await _userAppSettingsRepository.update( - id: 'user_id', - item: updatedSettings, - ); - emit(state.copyWith(userAppSettings: updatedSettings, clearError: true)); - } on HtHttpException catch (e) { - emit(state.copyWith(status: SettingsStatus.failure, error: e)); - } catch (e) { - emit(state.copyWith(status: SettingsStatus.failure, error: e)); - } - } + if (state.userAppSettings == null) return; - /// Handles changes to the Notifications Enabled setting. - // Future _onNotificationsEnabledChanged( - // SettingsNotificationsEnabledChanged event, - // Emitter emit, - // ) async { - // // Read current preferences, update, and save - // try { - // // TODO(cline): Get actual user ID - // final currentPreferences = await _userContentPreferencesRepository.read(id: 'user_id'); - // // Note: This only updates the 'enabled' flag. Updating followed items - // // would require copying the lists as well. - // // The NotificationSettings model from the old preferences client doesn't directly map - // // to UserContentPreferences. Assuming notification enabled state is part of UserAppSettings. - // // Re-evaluating based on UserAppSettings model... UserAppSettings has engagementShownCounts - // // and engagementLastShownTimestamps, but no general notification enabled flag. - // // This suggests the notification enabled setting might need to be added to UserAppSettings - // // or handled differently. For now, I will add a TODO and emit a failure state. - // // TODO(cline): Determine where notification enabled setting is stored in new models. - // emit(state.copyWith(status: SettingsStatus.failure, error: Exception('Notification enabled setting location in new models is TBD.'))); + final updatedSettings = state.userAppSettings!.copyWith( + displaySettings: state.userAppSettings!.displaySettings.copyWith( + fontWeight: event.fontWeight, + ), + ); + emit(state.copyWith(userAppSettings: updatedSettings, clearError: true)); + await _persistSettings(updatedSettings, emit); + } - // // If it were in UserAppSettings: - // /* - // final currentSettings = await _userAppSettingsRepository.read(id: 'user_id'); - // final updatedSettings = currentSettings.copyWith( - // // Assuming a field like 'notificationsEnabled' exists in UserAppSettings - // notificationsEnabled: event.enabled, - // ); - // await _userAppSettingsRepository.update(id: 'user_id', item: updatedSettings); - // emit(state.copyWith(userAppSettings: updatedSettings, clearError: true)); - // */ + Future _onFeedTileTypeChanged( + SettingsFeedTileTypeChanged event, + Emitter emit, + ) async { + if (state.userAppSettings == null) return; - // } on HtHttpException catch (e) { - // emit(state.copyWith(status: SettingsStatus.failure, error: e)); - // } catch (e) { - // emit(state.copyWith(status: SettingsStatus.failure, error: e)); - // } - // } + final updatedSettings = state.userAppSettings!.copyWith( + feedPreferences: state.userAppSettings!.feedPreferences.copyWith( + headlineImageStyle: event.tileType, + ), + ); + emit(state.copyWith(userAppSettings: updatedSettings, clearError: true)); + await _persistSettings(updatedSettings, emit); + } } diff --git a/lib/settings/bloc/settings_event.dart b/lib/settings/bloc/settings_event.dart index 865ffaa7..782d1af2 100644 --- a/lib/settings/bloc/settings_event.dart +++ b/lib/settings/bloc/settings_event.dart @@ -17,11 +17,17 @@ abstract class SettingsEvent extends Equatable { } /// {@template settings_load_requested} -/// Event added when the settings page is entered to load initial settings. +/// Event added when the settings page is entered to load initial settings +/// for a specific user. /// {@endtemplate} class SettingsLoadRequested extends SettingsEvent { /// {@macro settings_load_requested} - const SettingsLoadRequested(); + const SettingsLoadRequested({required this.userId}); + + final String userId; + + @override + List get props => [userId]; } // --- Appearance Settings Events --- @@ -114,20 +120,8 @@ class SettingsFeedTileTypeChanged extends SettingsEvent { } // --- Notification Settings Events --- - -/// {@template settings_notifications_enabled_changed} -/// Event added when the user toggles the global notification setting. -/// {@endtemplate} -class SettingsNotificationsEnabledChanged extends SettingsEvent { - /// {@macro settings_notifications_enabled_changed} - const SettingsNotificationsEnabledChanged(this.enabled); - - /// The new state of the notification enabled flag. - final bool enabled; - - @override - List get props => [enabled]; -} +// SettingsNotificationsEnabledChanged event removed as UserAppSettings +// does not currently support a general notifications enabled flag. // TODO(cline): Add events for changing followed categories/sources/countries // for notifications if needed later. Example: diff --git a/lib/settings/bloc/settings_state.dart b/lib/settings/bloc/settings_state.dart index 7a12321f..9c2365cb 100644 --- a/lib/settings/bloc/settings_state.dart +++ b/lib/settings/bloc/settings_state.dart @@ -17,19 +17,13 @@ enum SettingsStatus { /// {@template settings_state} /// Represents the state of the settings feature, including loading status -/// and the current values of all user-configurable settings. +/// and the current values of all user-configurable application settings. /// {@endtemplate} class SettingsState extends Equatable { /// {@macro settings_state} const SettingsState({ this.status = SettingsStatus.initial, - // Use new models from ht_shared - this.userAppSettings = const UserAppSettings( - id: '', - ), // Provide a default empty instance - this.userContentPreferences = const UserContentPreferences( - id: '', - ), // Provide a default empty instance + this.userAppSettings, // Nullable, populated after successful load this.error, }); @@ -37,10 +31,9 @@ class SettingsState extends Equatable { final SettingsStatus status; /// Current user application settings. - final UserAppSettings userAppSettings; - - /// Current user content preferences. - final UserContentPreferences userContentPreferences; + /// Null if settings haven't been loaded or if there's no authenticated user + /// context for settings yet. + final UserAppSettings? userAppSettings; /// An optional error object if the status is [SettingsStatus.failure]. final Object? error; @@ -48,25 +41,19 @@ class SettingsState extends Equatable { /// Creates a copy of the current state with updated values. SettingsState copyWith({ SettingsStatus? status, - UserAppSettings? userAppSettings, // Update parameter type - UserContentPreferences? userContentPreferences, // Update parameter type + UserAppSettings? userAppSettings, Object? error, bool clearError = false, // Flag to explicitly clear error + bool clearUserAppSettings = false, // Flag to explicitly clear settings }) { return SettingsState( status: status ?? this.status, - userAppSettings: userAppSettings ?? this.userAppSettings, // Update field - userContentPreferences: - userContentPreferences ?? this.userContentPreferences, // Update field + userAppSettings: + clearUserAppSettings ? null : userAppSettings ?? this.userAppSettings, error: clearError ? null : error ?? this.error, ); } @override - List get props => [ - status, - userAppSettings, // Update field - userContentPreferences, // Update field - error, - ]; + List get props => [status, userAppSettings, error]; } diff --git a/lib/settings/view/appearance_settings_page.dart b/lib/settings/view/appearance_settings_page.dart index b5cd18d7..048d0776 100644 --- a/lib/settings/view/appearance_settings_page.dart +++ b/lib/settings/view/appearance_settings_page.dart @@ -103,7 +103,7 @@ class AppearanceSettingsPage extends StatelessWidget { _buildDropdownSetting( context: context, title: l10n.settingsAppearanceThemeModeLabel, - currentValue: state.userAppSettings.displaySettings.baseTheme, + currentValue: state.userAppSettings!.displaySettings.baseTheme, items: AppBaseTheme.values, itemToString: (mode) => _baseThemeToString(mode, l10n), onChanged: (value) { @@ -118,7 +118,7 @@ class AppearanceSettingsPage extends StatelessWidget { _buildDropdownSetting( context: context, title: l10n.settingsAppearanceThemeNameLabel, - currentValue: state.userAppSettings.displaySettings.accentTheme, + currentValue: state.userAppSettings!.displaySettings.accentTheme, items: AppAccentTheme.values, itemToString: (name) => _accentThemeToString(name, l10n), onChanged: (value) { @@ -136,7 +136,8 @@ class AppearanceSettingsPage extends StatelessWidget { context: context, title: l10n.settingsAppearanceAppFontSizeLabel, // Reusing key for text size - currentValue: state.userAppSettings.displaySettings.textScaleFactor, + currentValue: + state.userAppSettings!.displaySettings.textScaleFactor, items: AppTextScaleFactor.values, itemToString: (size) => _textScaleFactorToString(size, l10n), onChanged: (value) { @@ -155,7 +156,7 @@ class AppearanceSettingsPage extends StatelessWidget { context: context, title: l10n.settingsAppearanceAppFontTypeLabel, // Reusing key for font family - currentValue: state.userAppSettings.displaySettings.fontFamily, + currentValue: state.userAppSettings!.displaySettings.fontFamily, items: const [ 'SystemDefault', ], // Only SystemDefault supported for now @@ -174,7 +175,7 @@ class AppearanceSettingsPage extends StatelessWidget { _buildDropdownSetting( context: context, title: l10n.settingsAppearanceFontWeightLabel, // Add l10n key - currentValue: state.userAppSettings.displaySettings.fontWeight, + currentValue: state.userAppSettings!.displaySettings.fontWeight, items: AppFontWeight.values, itemToString: (weight) => _fontWeightToString(weight, l10n), // Use helper diff --git a/lib/settings/view/article_settings_page.dart b/lib/settings/view/article_settings_page.dart deleted file mode 100644 index 634ed646..00000000 --- a/lib/settings/view/article_settings_page.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:ht_main/l10n/l10n.dart'; -import 'package:ht_main/settings/bloc/settings_bloc.dart'; -import 'package:ht_main/shared/constants/constants.dart'; -import 'package:ht_shared/ht_shared.dart' - show AppTextScaleFactor; // Import new enum - -/// {@template article_settings_page} -/// A page for configuring article display settings. -/// {@endtemplate} -class ArticleSettingsPage extends StatelessWidget { - /// {@macro article_settings_page} - const ArticleSettingsPage({super.key}); - - // Helper to map AppTextScaleFactor enum to user-friendly strings - String _textScaleFactorToString( - AppTextScaleFactor factor, - AppLocalizations l10n, - ) { - switch (factor) { - case AppTextScaleFactor.small: - return l10n.settingsAppearanceFontSizeSmall; // Reuse key - case AppTextScaleFactor.medium: - return l10n.settingsAppearanceFontSizeMedium; // Reuse key - case AppTextScaleFactor.large: - return l10n.settingsAppearanceFontSizeLarge; // Reuse key - case AppTextScaleFactor.extraLarge: - return l10n - .settingsAppearanceFontSizeExtraLarge; // Add l10n key if needed - } - } - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - final settingsBloc = context.watch(); - final state = settingsBloc.state; - - // Ensure we have loaded state before building controls - if (state.status != SettingsStatus.success) { - return Scaffold( - appBar: AppBar( - title: Text(l10n.settingsArticleDisplayTitle), - ), // Reuse title - body: const Center(child: CircularProgressIndicator()), - ); - } - - return Scaffold( - appBar: AppBar( - title: Text(l10n.settingsArticleDisplayTitle), // Reuse title - ), - body: ListView( - padding: const EdgeInsets.all(AppSpacing.lg), - children: [ - // --- Article Font Size --- - _buildDropdownSetting( - context: context, - title: l10n.settingsArticleFontSizeLabel, // Add l10n key - currentValue: - state - .userAppSettings - .displaySettings - .textScaleFactor, // Use new model field - items: AppTextScaleFactor.values, - itemToString: (factor) => _textScaleFactorToString(factor, l10n), - onChanged: (value) { - if (value != null) { - settingsBloc.add( - SettingsAppFontSizeChanged(value), - ); // Use new event - } - }, - ), - ], - ), - ); - } - - /// Generic helper to build a setting row with a title and a dropdown. - Widget _buildDropdownSetting({ - required BuildContext context, - required String title, - required T currentValue, - required List items, - required String Function(T) itemToString, - required ValueChanged onChanged, - }) { - final textTheme = Theme.of(context).textTheme; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: textTheme.titleMedium), - const SizedBox(height: AppSpacing.sm), - DropdownButtonFormField( - value: currentValue, - items: - items.map((T value) { - return DropdownMenuItem( - value: value, - child: Text(itemToString(value)), - ); - }).toList(), - onChanged: onChanged, - decoration: const InputDecoration( - border: OutlineInputBorder(), - contentPadding: EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm, - ), - ), - ), - ], - ); - } -} diff --git a/lib/settings/view/feed_settings_page.dart b/lib/settings/view/feed_settings_page.dart index 9d304ec9..4b00cf70 100644 --- a/lib/settings/view/feed_settings_page.dart +++ b/lib/settings/view/feed_settings_page.dart @@ -54,7 +54,7 @@ class FeedSettingsPage extends StatelessWidget { title: l10n.settingsFeedTileTypeLabel, // Add l10n key currentValue: state - .userAppSettings + .userAppSettings! .feedPreferences .headlineImageStyle, // Use new model field items: HeadlineImageStyle.values, diff --git a/lib/settings/view/settings_page.dart b/lib/settings/view/settings_page.dart index 71e56c20..c4d2d0f7 100644 --- a/lib/settings/view/settings_page.dart +++ b/lib/settings/view/settings_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:ht_main/app/bloc/app_bloc.dart'; // Import AppBloc import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/router/routes.dart'; // Assuming sub-routes will be added here import 'package:ht_main/settings/bloc/settings_bloc.dart'; @@ -43,13 +44,25 @@ class SettingsPage extends StatelessWidget { // Handle error state if (state.status == SettingsStatus.failure) { return FailureStateWidget( - message: - state.error?.toString() ?? - l10n.settingsErrorDefault, // Add l10n key - onRetry: - () => context.read().add( - const SettingsLoadRequested(), - ), + message: state.error?.toString() ?? l10n.settingsErrorDefault, + onRetry: () { + // Access AppBloc to get the current user ID for retry + final appBloc = context.read(); + final userId = appBloc.state.user?.id; + if (userId != null) { + context.read().add( + SettingsLoadRequested(userId: userId), + ); + } else { + // Handle case where user is null on retry, though unlikely + // if router guards are effective. + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.unknownError), // Or a specific error + ), + ); + } + }, ); } @@ -71,13 +84,6 @@ class SettingsPage extends StatelessWidget { onTap: () => context.goNamed(Routes.settingsFeedName), ), const Divider(indent: AppSpacing.lg, endIndent: AppSpacing.lg), - _buildSettingsTile( - context: context, - icon: Icons.article_outlined, - title: l10n.settingsArticleDisplayTitle, // Add l10n key - onTap: () => context.goNamed(Routes.settingsArticleName), - ), - const Divider(indent: AppSpacing.lg, endIndent: AppSpacing.lg), _buildSettingsTile( context: context, icon: Icons.notifications_outlined,