diff --git a/analysis_options.yaml b/analysis_options.yaml index 15574759..e324f571 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,3 +1,6 @@ +analyzer: + errors: + avoid_print: ignore include: package:very_good_analysis/analysis_options.9.0.0.yaml linter: rules: diff --git a/lib/app/app.dart b/lib/app/app.dart new file mode 100644 index 00000000..f23ab3c8 --- /dev/null +++ b/lib/app/app.dart @@ -0,0 +1 @@ +export 'view/app.dart'; diff --git a/lib/app/bloc/app_bloc.dart b/lib/app/bloc/app_bloc.dart new file mode 100644 index 00000000..ad5d2b74 --- /dev/null +++ b/lib/app/bloc/app_bloc.dart @@ -0,0 +1,75 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:ht_auth_repository/ht_auth_repository.dart'; +import 'package:ht_data_repository/ht_data_repository.dart'; +import 'package:ht_dashboard/app/config/config.dart' as local_config; +import 'package:ht_shared/ht_shared.dart'; + +part 'app_event.dart'; +part 'app_state.dart'; + +class AppBloc extends Bloc { + AppBloc({ + required HtAuthRepository authenticationRepository, + required HtDataRepository userAppSettingsRepository, + required HtDataRepository appConfigRepository, + required local_config.AppEnvironment environment, + }) : _authenticationRepository = authenticationRepository, + _userAppSettingsRepository = userAppSettingsRepository, + _appConfigRepository = appConfigRepository, + _environment = environment, + super( + const AppState( + user: null, + status: AppStatus.initial, + environment: null, + ), + ) { + on(_onAppUserChanged); + on(_onLogoutRequested); + + _userSubscription = _authenticationRepository.authStateChanges.listen( + (User? user) => add(AppUserChanged(user)), + ); + } + + final HtAuthRepository _authenticationRepository; + final HtDataRepository _userAppSettingsRepository; + final HtDataRepository _appConfigRepository; + final local_config.AppEnvironment _environment; + late final StreamSubscription _userSubscription; + + /// Handles user changes and loads initial settings once user is available. + Future _onAppUserChanged( + AppUserChanged event, + Emitter emit, + ) async { + // Determine the AppStatus based on the user object and its role + final AppStatus status; + + switch (event.user?.role) { + case null: + status = AppStatus.unauthenticated; + case UserRole.standardUser: + status = AppStatus.authenticated; + // ignore: no_default_cases + default: // Fallback for any other roles not explicitly handled + status = AppStatus.unauthenticated; // Treat other roles as unauthenticated for dashboard + } + + // Emit user and status update + emit(state.copyWith(status: status, user: event.user)); + } + + void _onLogoutRequested(AppLogoutRequested event, Emitter emit) { + unawaited(_authenticationRepository.signOut()); + } + + @override + Future close() { + _userSubscription.cancel(); + return super.close(); + } +} diff --git a/lib/app/bloc/app_event.dart b/lib/app/bloc/app_event.dart new file mode 100644 index 00000000..26697696 --- /dev/null +++ b/lib/app/bloc/app_event.dart @@ -0,0 +1,25 @@ +part of 'app_bloc.dart'; + +abstract class AppEvent extends Equatable { + const AppEvent(); + + @override + List get props => []; +} + +class AppUserChanged extends AppEvent { + const AppUserChanged(this.user); + + final User? user; + + @override + List get props => [user]; +} + +/// {@template app_logout_requested} +/// Event to request user logout. +/// {@endtemplate} +class AppLogoutRequested extends AppEvent { + /// {@macro app_logout_requested} + const AppLogoutRequested(); +} diff --git a/lib/app/bloc/app_state.dart b/lib/app/bloc/app_state.dart new file mode 100644 index 00000000..04d11c18 --- /dev/null +++ b/lib/app/bloc/app_state.dart @@ -0,0 +1,55 @@ +part of 'app_bloc.dart'; + +/// Represents the application's authentication status. +enum AppStatus { + /// The application is initializing and the status is unknown. + initial, + + /// The user is authenticated. + authenticated, + + /// The user is unauthenticated. + unauthenticated, + + /// The user is anonymous (signed in using an anonymous provider). + anonymous, +} + +class AppState extends Equatable { + /// {@macro app_state} + const AppState({ + this.status = AppStatus.initial, + this.user, + this.environment, + }); + + /// The current authentication status of the application. + final AppStatus status; + + /// The current user details. Null if unauthenticated. + final User? user; + + /// The current application environment (e.g., production, development, demo). + final local_config.AppEnvironment? environment; + + /// Creates a copy of the current state with updated values. + AppState copyWith({ + AppStatus? status, + User? user, + local_config.AppEnvironment? environment, + bool clearEnvironment = false, + }) { + return AppState( + status: status ?? this.status, + user: user ?? this.user, + environment: clearEnvironment ? null : environment ?? this.environment, + ); + } + + @override + List get props => [ + status, + user, + environment, + ]; +} diff --git a/lib/app/config/app_config.dart b/lib/app/config/app_config.dart new file mode 100644 index 00000000..289f1e45 --- /dev/null +++ b/lib/app/config/app_config.dart @@ -0,0 +1,26 @@ +import 'package:ht_dashboard/app/config/app_environment.dart'; + +class AppConfig { + const AppConfig({ + required this.environment, + required this.baseUrl, + }); + + factory AppConfig.production() => const AppConfig( + environment: AppEnvironment.production, + baseUrl: 'http://api.yourproductiondomain.com', + ); + + factory AppConfig.demo() => const AppConfig( + environment: AppEnvironment.demo, + baseUrl: '', + ); + + factory AppConfig.development() => const AppConfig( + environment: AppEnvironment.development, + baseUrl: 'http://localhost:8080', + ); + + final AppEnvironment environment; + final String baseUrl; +} diff --git a/lib/app/config/app_environment.dart b/lib/app/config/app_environment.dart new file mode 100644 index 00000000..afeca55e --- /dev/null +++ b/lib/app/config/app_environment.dart @@ -0,0 +1,24 @@ +/// Defines the different application environments. +enum AppEnvironment { + /// Represents the production environment. + /// + /// This environment is used for the live application, connecting to + /// production backend services and configurations. + production, + + /// Represents a development environment connecting to a local API. + /// + /// This environment is used during local development, typically connecting + /// to a locally running Dart Frog backend API. + development, + + /// Represents a demonstration environment with in-memory data. + /// + /// This environment is designed for showcasing the application's user + /// interface and features without requiring any external backend services. + /// All data operations are handled by mock data stored directly in memory, + /// eliminating the need for API access. + demo, + + // Add other environments like staging, etc. as needed +} diff --git a/lib/app/config/config.dart b/lib/app/config/config.dart new file mode 100644 index 00000000..6195435a --- /dev/null +++ b/lib/app/config/config.dart @@ -0,0 +1,2 @@ +export 'app_config.dart'; +export 'app_environment.dart'; diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart new file mode 100644 index 00000000..f8d3b81d --- /dev/null +++ b/lib/app/view/app.dart @@ -0,0 +1,149 @@ +// +// ignore_for_file: deprecated_member_use + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:ht_auth_repository/ht_auth_repository.dart'; +import 'package:ht_data_repository/ht_data_repository.dart'; +import 'package:ht_kv_storage_service/ht_kv_storage_service.dart'; +import 'package:ht_dashboard/app/bloc/app_bloc.dart'; +import 'package:ht_dashboard/app/config/app_environment.dart'; +import 'package:ht_dashboard/authentication/bloc/authentication_bloc.dart'; +import 'package:ht_dashboard/l10n/app_localizations.dart'; +import 'package:ht_dashboard/l10n/l10n.dart'; +import 'package:ht_dashboard/router/router.dart'; +import 'package:ht_shared/ht_shared.dart'; + +class App extends StatelessWidget { + const App({ + required HtAuthRepository htAuthenticationRepository, + required HtDataRepository htHeadlinesRepository, + required HtDataRepository htCategoriesRepository, + required HtDataRepository htCountriesRepository, + required HtDataRepository htSourcesRepository, + required HtDataRepository htUserAppSettingsRepository, + required HtDataRepository + htUserContentPreferencesRepository, + required HtDataRepository htAppConfigRepository, + required HtKVStorageService kvStorageService, + required AppEnvironment environment, + super.key, + }) : _htAuthenticationRepository = htAuthenticationRepository, + _htHeadlinesRepository = htHeadlinesRepository, + _htCategoriesRepository = htCategoriesRepository, + _htCountriesRepository = htCountriesRepository, + _htSourcesRepository = htSourcesRepository, + _htUserAppSettingsRepository = htUserAppSettingsRepository, + _htUserContentPreferencesRepository = htUserContentPreferencesRepository, + _htAppConfigRepository = htAppConfigRepository, + _kvStorageService = kvStorageService, + _environment = environment; + + final HtAuthRepository _htAuthenticationRepository; + final HtDataRepository _htHeadlinesRepository; + final HtDataRepository _htCategoriesRepository; + final HtDataRepository _htCountriesRepository; + final HtDataRepository _htSourcesRepository; + final HtDataRepository _htUserAppSettingsRepository; + final HtDataRepository + _htUserContentPreferencesRepository; + final HtDataRepository _htAppConfigRepository; + final HtKVStorageService _kvStorageService; + final AppEnvironment _environment; + + @override + Widget build(BuildContext context) { + return MultiRepositoryProvider( + providers: [ + RepositoryProvider.value(value: _htAuthenticationRepository), + RepositoryProvider.value(value: _htHeadlinesRepository), + RepositoryProvider.value(value: _htCategoriesRepository), + RepositoryProvider.value(value: _htCountriesRepository), + RepositoryProvider.value(value: _htSourcesRepository), + RepositoryProvider.value(value: _htUserAppSettingsRepository), + RepositoryProvider.value(value: _htUserContentPreferencesRepository), + RepositoryProvider.value(value: _htAppConfigRepository), + RepositoryProvider.value(value: _kvStorageService), + ], + child: MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => AppBloc( + authenticationRepository: context.read(), + userAppSettingsRepository: + context.read>(), + appConfigRepository: context.read>(), + environment: _environment, + ), + ), + BlocProvider( + create: (context) => AuthenticationBloc( + authenticationRepository: context.read(), + ), + ), + ], + child: _AppView( + htAuthenticationRepository: _htAuthenticationRepository, + environment: _environment, + ), + ), + ); + } +} + +class _AppView extends StatefulWidget { + const _AppView({ + required this.htAuthenticationRepository, + required this.environment, + }); + + final HtAuthRepository htAuthenticationRepository; + final AppEnvironment environment; + + @override + State<_AppView> createState() => _AppViewState(); +} + +class _AppViewState extends State<_AppView> { + late final GoRouter _router; + late final ValueNotifier _statusNotifier; + + @override + void initState() { + super.initState(); + final appBloc = context.read(); + _statusNotifier = ValueNotifier(appBloc.state.status); + _router = createRouter( + authStatusNotifier: _statusNotifier, + htAuthenticationRepository: widget.htAuthenticationRepository, + environment: widget.environment, + ); + } + + @override + void dispose() { + _statusNotifier.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listenWhen: (previous, current) => previous.status != current.status, + listener: (context, state) { + _statusNotifier.value = state.status; + }, + child: BlocBuilder( + builder: (context, state) { + return MaterialApp.router( + debugShowCheckedModeBanner: false, + routerConfig: _router, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + ); + }, + ), + ); + } +} diff --git a/lib/app/view/app_shell.dart b/lib/app/view/app_shell.dart new file mode 100644 index 00000000..604e6f1c --- /dev/null +++ b/lib/app/view/app_shell.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart'; +import 'package:go_router/go_router.dart'; +import 'package:ht_dashboard/l10n/l10n.dart'; +import 'package:ht_dashboard/router/routes.dart'; + +/// A responsive scaffold shell for the main application sections. +/// +/// Uses [AdaptiveScaffold] to provide appropriate navigation +/// (bottom bar, rail, or drawer) based on screen size. +class AppShell extends StatelessWidget { + /// Creates an [AppShell]. + /// + /// Requires a [navigationShell] to manage the nested navigators + /// for each section. + const AppShell({required this.navigationShell, super.key}); + + /// The [StatefulNavigationShell] provided by [GoRouter] for managing nested + /// navigators in a stateful way. + final StatefulNavigationShell navigationShell; + + void _goBranch(int index) { + navigationShell.goBranch( + index, + initialLocation: index == navigationShell.currentIndex, + ); + } + + @override + Widget build(BuildContext context) { + return AdaptiveScaffold( + selectedIndex: navigationShell.currentIndex, + onSelectedIndexChange: _goBranch, + destinations: [ + NavigationDestination( + icon: const Icon(Icons.dashboard_outlined), + selectedIcon: const Icon(Icons.dashboard), + label: context.l10n.dashboard, + ), + NavigationDestination( + icon: const Icon(Icons.folder_open_outlined), + selectedIcon: const Icon(Icons.folder), + label: context.l10n.contentManagement, + ), + NavigationDestination( + icon: const Icon(Icons.settings_applications_outlined), + selectedIcon: const Icon(Icons.settings_applications), + label: context.l10n.appConfiguration, + ), + NavigationDestination( + icon: const Icon(Icons.settings_outlined), + selectedIcon: const Icon(Icons.settings), + label: context.l10n.settings, + ), + ], + body: (_) => navigationShell, + ); + } +} diff --git a/lib/app_configuration/view/app_configuration_page.dart b/lib/app_configuration/view/app_configuration_page.dart new file mode 100644 index 00000000..09833605 --- /dev/null +++ b/lib/app_configuration/view/app_configuration_page.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +/// {@template app_configuration_page} +/// A placeholder page for App Configuration. +/// {@endtemplate} +class AppConfigurationPage extends StatelessWidget { + /// {@macro app_configuration_page} + const AppConfigurationPage({super.key}); + + @override + Widget build(BuildContext context) { + return const Scaffold( + body: Center( + child: Text('App Configuration Page'), + ), + ); + } +} diff --git a/lib/authentication/bloc/authentication_bloc.dart b/lib/authentication/bloc/authentication_bloc.dart new file mode 100644 index 00000000..0e2fffcc --- /dev/null +++ b/lib/authentication/bloc/authentication_bloc.dart @@ -0,0 +1,154 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:ht_auth_repository/ht_auth_repository.dart'; +import 'package:ht_shared/ht_shared.dart' + show + AuthenticationException, + HtHttpException, + InvalidInputException, + NetworkException, + OperationFailedException, + ServerException, + User; + +part 'authentication_event.dart'; +part 'authentication_state.dart'; + +/// {@template authentication_bloc} +/// Bloc responsible for managing the authentication state of the application. +/// {@endtemplate} +class AuthenticationBloc + extends Bloc { + /// {@macro authentication_bloc} + AuthenticationBloc({required HtAuthRepository authenticationRepository}) + : _authenticationRepository = authenticationRepository, + super(AuthenticationInitial()) { + // Listen to authentication state changes from the repository + _userAuthSubscription = _authenticationRepository.authStateChanges.listen( + (user) => add(_AuthenticationUserChanged(user: user)), + ); + + on<_AuthenticationUserChanged>(_onAuthenticationUserChanged); + on( + _onAuthenticationRequestSignInCodeRequested, + ); + on(_onAuthenticationVerifyCodeRequested); + on(_onAuthenticationSignOutRequested); + } + + final HtAuthRepository _authenticationRepository; + late final StreamSubscription _userAuthSubscription; + + /// Handles [_AuthenticationUserChanged] events. + Future _onAuthenticationUserChanged( + _AuthenticationUserChanged event, + Emitter emit, + ) async { + if (event.user != null) { + emit(AuthenticationAuthenticated(user: event.user!)); + } else { + emit(AuthenticationUnauthenticated()); + } + } + + /// Handles [AuthenticationRequestSignInCodeRequested] events. + Future _onAuthenticationRequestSignInCodeRequested( + AuthenticationRequestSignInCodeRequested event, + Emitter emit, + ) async { + // Validate email format (basic check) + if (event.email.isEmpty || !event.email.contains('@')) { + emit(const AuthenticationFailure('Please enter a valid email address.')); + return; + } + emit(AuthenticationRequestCodeLoading()); + try { + await _authenticationRepository.requestSignInCode(event.email); + emit(AuthenticationCodeSentSuccess(email: event.email)); + } on InvalidInputException catch (e) { + emit(AuthenticationFailure('Invalid input: ${e.message}')); + } on NetworkException catch (_) { + emit(const AuthenticationFailure('Network error occurred.')); + } on ServerException catch (e) { + emit(AuthenticationFailure('Server error: ${e.message}')); + } on OperationFailedException catch (e) { + emit(AuthenticationFailure('Operation failed: ${e.message}')); + } on HtHttpException catch (e) { + // Catch any other HtHttpException subtypes + final message = e.message.isNotEmpty + ? e.message + : 'An unspecified HTTP error occurred.'; + emit(AuthenticationFailure('HTTP error: $message')); + } catch (e) { + // Catch any other unexpected errors + emit(AuthenticationFailure('An unexpected error occurred: $e')); + // Optionally log the stackTrace here + } + } + + /// Handles [AuthenticationVerifyCodeRequested] events. + Future _onAuthenticationVerifyCodeRequested( + AuthenticationVerifyCodeRequested event, + Emitter emit, + ) async { + emit(AuthenticationLoading()); + try { + await _authenticationRepository.verifySignInCode(event.email, event.code); + // On success, the _AuthenticationUserChanged listener will handle + // emitting AuthenticationAuthenticated. + } on InvalidInputException catch (e) { + emit(AuthenticationFailure(e.message)); + } on AuthenticationException catch (e) { + emit(AuthenticationFailure(e.message)); + } on NetworkException catch (_) { + emit(const AuthenticationFailure('Network error occurred.')); + } on ServerException catch (e) { + emit(AuthenticationFailure('Server error: ${e.message}')); + } on OperationFailedException catch (e) { + emit(AuthenticationFailure('Operation failed: ${e.message}')); + } on HtHttpException catch (e) { + // Catch any other HtHttpException subtypes + emit(AuthenticationFailure('HTTP error: ${e.message}')); + } catch (e) { + // Catch any other unexpected errors + emit(AuthenticationFailure('An unexpected error occurred: $e')); + // Optionally log the stackTrace here + } + } + + /// Handles [AuthenticationSignOutRequested] events. + Future _onAuthenticationSignOutRequested( + AuthenticationSignOutRequested event, + Emitter emit, + ) async { + emit(AuthenticationLoading()); + try { + await _authenticationRepository.signOut(); + // On success, the _AuthenticationUserChanged listener will handle + // emitting AuthenticationUnauthenticated. + // No need to emit AuthenticationLoading() before calling signOut if + // the authStateChanges listener handles the subsequent state update. + // However, if immediate feedback is desired, it can be kept. + // For now, let's assume the listener is sufficient. + } on NetworkException catch (_) { + emit(const AuthenticationFailure('Network error occurred.')); + } on ServerException catch (e) { + emit(AuthenticationFailure('Server error: ${e.message}')); + } on OperationFailedException catch (e) { + emit(AuthenticationFailure('Operation failed: ${e.message}')); + } on HtHttpException catch (e) { + // Catch any other HtHttpException subtypes + emit(AuthenticationFailure('HTTP error: ${e.message}')); + } catch (e) { + emit(AuthenticationFailure('An unexpected error occurred: $e')); + } + } + + @override + Future close() { + _userAuthSubscription.cancel(); + return super.close(); + } +} diff --git a/lib/authentication/bloc/authentication_event.dart b/lib/authentication/bloc/authentication_event.dart new file mode 100644 index 00000000..6034e484 --- /dev/null +++ b/lib/authentication/bloc/authentication_event.dart @@ -0,0 +1,70 @@ +part of 'authentication_bloc.dart'; + +/// {@template authentication_event} +/// Base class for authentication events. +/// {@endtemplate} +sealed class AuthenticationEvent extends Equatable { + /// {@macro authentication_event} + const AuthenticationEvent(); + + @override + List get props => []; +} + +/// {@template authentication_request_sign_in_code_requested} +/// Event triggered when the user requests a sign-in code to be sent +/// to their email. +/// {@endtemplate} +final class AuthenticationRequestSignInCodeRequested + extends AuthenticationEvent { + /// {@macro authentication_request_sign_in_code_requested} + const AuthenticationRequestSignInCodeRequested({required this.email}); + + /// The user's email address. + final String email; + + @override + List get props => [email]; +} + +/// {@template authentication_verify_code_requested} +/// Event triggered when the user attempts to sign in using an email and code. +/// {@endtemplate} +final class AuthenticationVerifyCodeRequested extends AuthenticationEvent { + /// {@macro authentication_verify_code_requested} + const AuthenticationVerifyCodeRequested({ + required this.email, + required this.code, + }); + + /// The user's email address. + final String email; + + /// The verification code received by the user. + final String code; + + @override + List get props => [email, code]; +} + +/// {@template authentication_sign_out_requested} +/// Event triggered when the user requests to sign out. +/// {@endtemplate} +final class AuthenticationSignOutRequested extends AuthenticationEvent { + /// {@macro authentication_sign_out_requested} + const AuthenticationSignOutRequested(); +} + +/// {@template _authentication_user_changed} +/// Internal event triggered when the authentication state changes. +/// {@endtemplate} +final class _AuthenticationUserChanged extends AuthenticationEvent { + /// {@macro _authentication_user_changed} + const _AuthenticationUserChanged({required this.user}); + + /// The current authenticated user, or null if unauthenticated. + final User? user; + + @override + List get props => [user]; +} diff --git a/lib/authentication/bloc/authentication_state.dart b/lib/authentication/bloc/authentication_state.dart new file mode 100644 index 00000000..67c16504 --- /dev/null +++ b/lib/authentication/bloc/authentication_state.dart @@ -0,0 +1,74 @@ +part of 'authentication_bloc.dart'; + +/// {@template authentication_state} +/// Base class for authentication states. +/// {@endtemplate} +sealed class AuthenticationState extends Equatable { + /// {@macro authentication_state} + const AuthenticationState(); + + @override + List get props => []; +} + +/// {@template authentication_initial} +/// The initial authentication state. +/// {@endtemplate} +final class AuthenticationInitial extends AuthenticationState {} + +/// {@template authentication_loading} +/// A state indicating that an authentication operation is in progress. +/// {@endtemplate} +final class AuthenticationLoading extends AuthenticationState {} + +/// {@template authentication_authenticated} +/// Represents a successful authentication. +/// {@endtemplate} +final class AuthenticationAuthenticated extends AuthenticationState { + /// {@macro authentication_authenticated} + const AuthenticationAuthenticated({required this.user}); + + /// The authenticated [User] object. + final User user; + + @override + List get props => [user]; +} + +/// {@template authentication_unauthenticated} +/// Represents an unauthenticated state. +/// {@endtemplate} +final class AuthenticationUnauthenticated extends AuthenticationState {} + +/// {@template authentication_request_code_loading} +/// State indicating that the sign-in code is being requested. +/// {@endtemplate} +final class AuthenticationRequestCodeLoading extends AuthenticationState {} + +/// {@template authentication_code_sent_success} +/// State indicating that the sign-in code was sent successfully. +/// {@endtemplate} +final class AuthenticationCodeSentSuccess extends AuthenticationState { + /// {@macro authentication_code_sent_success} + const AuthenticationCodeSentSuccess({required this.email}); + + /// The email address the code was sent to. + final String email; + + @override + List get props => [email]; +} + +/// {@template authentication_failure} +/// Represents an authentication failure. +/// {@endtemplate} +final class AuthenticationFailure extends AuthenticationState { + /// {@macro authentication_failure} + const AuthenticationFailure(this.errorMessage); + + /// The error message describing the authentication failure. + final String errorMessage; + + @override + List get props => [errorMessage]; +} diff --git a/lib/authentication/view/authentication_page.dart b/lib/authentication/view/authentication_page.dart new file mode 100644 index 00000000..d023f5ef --- /dev/null +++ b/lib/authentication/view/authentication_page.dart @@ -0,0 +1,145 @@ +// +// ignore_for_file: lines_longer_than_80_chars + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:ht_dashboard/authentication/bloc/authentication_bloc.dart'; +import 'package:ht_dashboard/l10n/l10n.dart'; +import 'package:ht_dashboard/router/routes.dart'; +import 'package:ht_dashboard/shared/constants/app_spacing.dart'; + +/// {@template authentication_page} +/// Displays authentication options (Google, Email, Anonymous) based on context. +/// +/// This page can be used for both initial sign-in and for connecting an +/// existing anonymous account. +/// {@endtemplate} +class AuthenticationPage extends StatelessWidget { + /// {@macro authentication_page} + const AuthenticationPage({ + required this.headline, + required this.subHeadline, + required this.showAnonymousButton, + super.key, + }); + + /// The main title displayed on the page. + final String headline; + + /// The descriptive text displayed below the headline. + final String subHeadline; + + /// Whether to show the "Continue Anonymously" button. + final bool showAnonymousButton; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final textTheme = Theme.of(context).textTheme; + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + ), + body: SafeArea( + child: BlocConsumer( + // Listener remains crucial for feedback (errors) + listener: (context, state) { + if (state is AuthenticationFailure) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + // Provide a more user-friendly error message if possible + state.errorMessage, + ), + backgroundColor: colorScheme.error, + ), + ); + } + // Success states (Google/Anonymous) are typically handled by + // the AppBloc listening to repository changes and triggering redirects. + // Email link success is handled in the dedicated email flow pages. + }, + 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, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // --- Icon --- + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.xl), + child: Icon( + Icons.newspaper, + size: AppSpacing.xxl * 2, + color: colorScheme.primary, + ), + ), + // const SizedBox(height: AppSpacing.lg), + // --- Headline and Subheadline --- + Text( + headline, + style: textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.md), + Text( + subHeadline, + style: textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.xxl), + + // --- Email Sign-In Button --- + ElevatedButton.icon( + icon: const Icon(Icons.email_outlined), + onPressed: isLoading + ? null + : () { + context.goNamed( + Routes.requestCodeName, + ); + }, + label: Text(l10n.authenticationEmailSignInButton), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.md, + ), + textStyle: textTheme.labelLarge, + ), + ), + const SizedBox(height: AppSpacing.lg), + + // --- 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 new file mode 100644 index 00000000..78fd6061 --- /dev/null +++ b/lib/authentication/view/email_code_verification_page.dart @@ -0,0 +1,215 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:ht_dashboard/app/bloc/app_bloc.dart'; +import 'package:ht_dashboard/app/config/config.dart'; +import 'package:ht_dashboard/authentication/bloc/authentication_bloc.dart'; +import 'package:ht_dashboard/l10n/l10n.dart'; +import 'package:ht_dashboard/shared/constants/app_spacing.dart'; + +/// {@template email_code_verification_page} +/// Page where the user enters the 6-digit code sent to their email +/// to complete the sign-in or account linking process. +/// {@endtemplate} +class EmailCodeVerificationPage extends StatelessWidget { + /// {@macro email_code_verification_page} + const EmailCodeVerificationPage({required this.email, super.key}); + + /// The email address the sign-in code was sent to. + final String email; + + @override + 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)), + body: SafeArea( + child: BlocConsumer( + listener: (context, state) { + if (state is AuthenticationFailure) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(state.errorMessage), + backgroundColor: colorScheme.error, + ), + ); + } + // 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, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Icon( + Icons.mark_email_read_outlined, + size: AppSpacing.xxl * 2, + color: colorScheme.primary, + ), + const SizedBox(height: AppSpacing.xl), + Text( + l10n.emailCodeSentConfirmation(email), + style: textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.lg), + Text( + l10n.emailCodeSentInstructions, + style: textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + // Display demo code if in demo environment + BlocSelector( + selector: (state) => state.environment, + builder: (context, environment) { + if (environment == AppEnvironment.demo) { + return Column( + children: [ + const SizedBox(height: AppSpacing.md), + Text( + l10n.demoVerificationCodeMessage('123456'), + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.secondary, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ], + ); + } + return const SizedBox.shrink(); + }, + ), + const SizedBox(height: AppSpacing.xl), + _EmailCodeVerificationForm( + email: email, + isLoading: isLoading, + ), + ], + ), + ), + ), + ); + }, + ), + ), + ); + } +} + +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; + final textTheme = Theme.of(context).textTheme; + + return Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + // No horizontal padding needed if column is stretched + // padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + padding: EdgeInsets.zero, + child: TextFormField( + controller: _codeController, + decoration: InputDecoration( + labelText: l10n.emailCodeVerificationHint, + // border: const OutlineInputBorder(), + counterText: '', + ), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + maxLength: 6, + textAlign: TextAlign.center, + style: textTheme.headlineSmall, + 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.xxl), + ElevatedButton( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.md, + horizontal: AppSpacing.lg, + ), + textStyle: textTheme.labelLarge, + ), + onPressed: widget.isLoading ? null : _submitForm, + child: widget.isLoading + ? const SizedBox( + height: AppSpacing.xl, + width: AppSpacing.xl, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text(l10n.emailCodeVerificationButtonLabel), + ), + ], + ), + ); + } +} diff --git a/lib/authentication/view/request_code_page.dart b/lib/authentication/view/request_code_page.dart new file mode 100644 index 00000000..8ebada2a --- /dev/null +++ b/lib/authentication/view/request_code_page.dart @@ -0,0 +1,229 @@ +// +// ignore_for_file: lines_longer_than_80_chars + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:ht_dashboard/authentication/bloc/authentication_bloc.dart'; +import 'package:ht_dashboard/l10n/l10n.dart'; +import 'package:ht_dashboard/router/routes.dart'; +import 'package:ht_dashboard/shared/constants/app_spacing.dart'; + +/// {@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 RequestCodePage extends StatelessWidget { + /// {@macro request_code_page} + const RequestCodePage({required this.isLinkingContext, super.key}); + + /// Whether this page is being shown in the account linking context. + final bool isLinkingContext; + + @override + Widget build(BuildContext context) { + // AuthenticationBloc is assumed to be provided by a parent route. + // Pass the linking context flag down to the view. + return _RequestCodeView(isLinkingContext: isLinkingContext); + } +} + +class _RequestCodeView extends StatelessWidget { + // Accept the flag from the parent page. + const _RequestCodeView({required this.isLinkingContext}); + + final bool isLinkingContext; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Scaffold( + appBar: AppBar( + title: Text(l10n.emailSignInPageTitle), + // Add a custom leading back button to control navigation based on context. + leading: IconButton( + icon: const Icon(Icons.arrow_back), + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + onPressed: () { + // Navigate back differently based on the context. + if (isLinkingContext) { + // If linking, go back to Auth page preserving the linking query param. + context.goNamed( + Routes.authenticationName, + queryParameters: isLinkingContext + ? {'context': 'linking'} + : const {}, + ); + } else { + // If normal sign-in, just go back to the Auth page. + context.goNamed(Routes.authenticationName); + } + }, + ), + ), + body: SafeArea( + child: BlocConsumer( + listener: (context, state) { + if (state is AuthenticationFailure) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(state.errorMessage), + backgroundColor: colorScheme.error, + ), + ); + } else if (state is AuthenticationCodeSentSuccess) { + // Navigate to the code verification page on success, passing the email + context.goNamed( + isLinkingContext + ? Routes.linkingVerifyCodeName + : Routes.verifyCodeName, + pathParameters: {'email': state.email}, + ); + } + }, + // BuildWhen prevents unnecessary rebuilds if only listening + buildWhen: (previous, current) => + current is AuthenticationInitial || + current is AuthenticationRequestCodeLoading || + current is AuthenticationFailure, + builder: (context, state) { + final isLoading = state is AuthenticationRequestCodeLoading; + + return Padding( + padding: const EdgeInsets.all(AppSpacing.paddingLarge), + child: Center( + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // --- Icon --- + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.xl), + child: Icon( + Icons.email_outlined, + size: AppSpacing.xxl * 2, + color: colorScheme.primary, + ), + ), + // const SizedBox(height: AppSpacing.lg), + // --- Explanation Text --- + Text( + l10n.requestCodePageHeadline, + style: textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.md), + Text( + l10n.requestCodePageSubheadline, + style: textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.xxl), + _EmailLinkForm(isLoading: isLoading), + ], + ), + ), + ), + ); + }, + ), + ), + ); + } +} + +/// --- Reusable Email Form Widget --- /// + +class _EmailLinkForm extends StatefulWidget { + const _EmailLinkForm({required this.isLoading}); + + final bool isLoading; + + @override + State<_EmailLinkForm> createState() => _EmailLinkFormState(); +} + +class _EmailLinkFormState extends State<_EmailLinkForm> { + final _emailController = TextEditingController(); + final _formKey = GlobalKey(); + + @override + void dispose() { + _emailController.dispose(); + super.dispose(); + } + + void _submitForm() { + if (_formKey.currentState!.validate()) { + context.read().add( + AuthenticationRequestSignInCodeRequested( + email: _emailController.text.trim(), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final textTheme = Theme.of(context).textTheme; + final colorScheme = Theme.of(context).colorScheme; + + return Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextFormField( + controller: _emailController, + decoration: InputDecoration( + labelText: l10n.requestCodeEmailLabel, + hintText: l10n.requestCodeEmailHint, + // border: const OutlineInputBorder(), + ), + keyboardType: TextInputType.emailAddress, + autocorrect: false, + textInputAction: TextInputAction.done, + enabled: !widget.isLoading, + validator: (value) { + if (value == null || value.isEmpty || !value.contains('@')) { + return l10n.accountLinkingEmailValidationError; + } + return null; + }, + onFieldSubmitted: (_) => _submitForm(), + ), + const SizedBox(height: AppSpacing.lg), + ElevatedButton( + onPressed: widget.isLoading ? null : _submitForm, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), + textStyle: textTheme.labelLarge, + ), + child: widget.isLoading + ? SizedBox( + height: AppSpacing.xl, + width: AppSpacing.xl, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.onPrimary, + ), + ) + : Text(l10n.requestCodeSendCodeButton), + ), + ], + ), + ); + } +} diff --git a/lib/bloc_observer.dart b/lib/bloc_observer.dart new file mode 100644 index 00000000..97afcf43 --- /dev/null +++ b/lib/bloc_observer.dart @@ -0,0 +1,19 @@ +import 'dart:developer'; + +import 'package:bloc/bloc.dart'; + +class AppBlocObserver extends BlocObserver { + const AppBlocObserver(); + + @override + void onChange(BlocBase bloc, Change change) { + super.onChange(bloc, change); + log('onChange(${bloc.runtimeType}, $change)'); + } + + @override + void onError(BlocBase bloc, Object error, StackTrace stackTrace) { + log('onError(${bloc.runtimeType}, $error, $stackTrace)'); + super.onError(bloc, error, stackTrace); + } +} diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart new file mode 100644 index 00000000..ea8a2f14 --- /dev/null +++ b/lib/bootstrap.dart @@ -0,0 +1,220 @@ +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:ht_auth_api/ht_auth_api.dart'; +import 'package:ht_auth_client/ht_auth_client.dart'; +import 'package:ht_auth_inmemory/ht_auth_inmemory.dart'; +import 'package:ht_auth_repository/ht_auth_repository.dart'; +import 'package:ht_dashboard/app/app.dart'; +import 'package:ht_dashboard/app/config/config.dart' as app_config; +import 'package:ht_dashboard/bloc_observer.dart'; +import 'package:ht_dashboard/shared/localization/ar_timeago_messages.dart'; +import 'package:ht_dashboard/shared/localization/en_timeago_messages.dart'; +import 'package:ht_data_api/ht_data_api.dart'; +import 'package:ht_data_client/ht_data_client.dart'; +import 'package:ht_data_inmemory/ht_data_inmemory.dart'; +import 'package:ht_data_repository/ht_data_repository.dart'; +import 'package:ht_http_client/ht_http_client.dart'; +import 'package:ht_kv_storage_shared_preferences/ht_kv_storage_shared_preferences.dart'; +import 'package:ht_shared/ht_shared.dart'; +import 'package:timeago/timeago.dart' as timeago; + +Future bootstrap( + app_config.AppConfig appConfig, + app_config.AppEnvironment environment, +) async { + WidgetsFlutterBinding.ensureInitialized(); + Bloc.observer = const AppBlocObserver(); + + timeago.setLocaleMessages('en', EnTimeagoMessages()); + timeago.setLocaleMessages('ar', ArTimeagoMessages()); + + final kvStorage = await HtKvStorageSharedPreferences.getInstance(); + + late final HtAuthClient authClient; + late final HtAuthRepository authenticationRepository; + HtHttpClient? httpClient; + + if (appConfig.environment == app_config.AppEnvironment.demo) { + authClient = HtAuthInmemory(); + authenticationRepository = HtAuthRepository( + authClient: authClient, + storageService: kvStorage, + ); + } else { + httpClient = HtHttpClient( + baseUrl: appConfig.baseUrl, + tokenProvider: () => authenticationRepository.getAuthToken(), + isWeb: kIsWeb, + ); + authClient = HtAuthApi(httpClient: httpClient); + authenticationRepository = HtAuthRepository( + authClient: authClient, + storageService: kvStorage, + ); + } + + HtDataClient headlinesClient; + HtDataClient categoriesClient; + HtDataClient countriesClient; + HtDataClient sourcesClient; + HtDataClient userContentPreferencesClient; + HtDataClient userAppSettingsClient; + HtDataClient appConfigClient; + + if (appConfig.environment == app_config.AppEnvironment.demo) { + headlinesClient = HtDataInMemory( + toJson: (i) => i.toJson(), + getId: (i) => i.id, + initialData: headlinesFixturesData.map(Headline.fromJson).toList(), + ); + categoriesClient = HtDataInMemory( + toJson: (i) => i.toJson(), + getId: (i) => i.id, + initialData: categoriesFixturesData.map(Category.fromJson).toList(), + ); + countriesClient = HtDataInMemory( + toJson: (i) => i.toJson(), + getId: (i) => i.id, + initialData: countriesFixturesData.map(Country.fromJson).toList(), + ); + sourcesClient = HtDataInMemory( + toJson: (i) => i.toJson(), + getId: (i) => i.id, + initialData: sourcesFixturesData.map(Source.fromJson).toList(), + ); + userContentPreferencesClient = HtDataInMemory( + toJson: (i) => i.toJson(), + getId: (i) => i.id, + ); + userAppSettingsClient = HtDataInMemory( + toJson: (i) => i.toJson(), + getId: (i) => i.id, + ); + appConfigClient = HtDataInMemory( + toJson: (i) => i.toJson(), + getId: (i) => i.id, + initialData: [AppConfig.fromJson(appConfigFixtureData)], + ); + } else if (appConfig.environment == app_config.AppEnvironment.development) { + headlinesClient = HtDataApi( + httpClient: httpClient!, + modelName: 'headline', + fromJson: Headline.fromJson, + toJson: (headline) => headline.toJson(), + ); + categoriesClient = HtDataApi( + httpClient: httpClient, + modelName: 'category', + fromJson: Category.fromJson, + toJson: (category) => category.toJson(), + ); + countriesClient = HtDataApi( + httpClient: httpClient, + modelName: 'country', + fromJson: Country.fromJson, + toJson: (country) => country.toJson(), + ); + sourcesClient = HtDataApi( + httpClient: httpClient, + modelName: 'source', + fromJson: Source.fromJson, + toJson: (source) => source.toJson(), + ); + userContentPreferencesClient = HtDataApi( + httpClient: httpClient, + modelName: 'user_content_preferences', + fromJson: UserContentPreferences.fromJson, + toJson: (prefs) => prefs.toJson(), + ); + userAppSettingsClient = HtDataApi( + httpClient: httpClient, + modelName: 'user_app_settings', + fromJson: UserAppSettings.fromJson, + toJson: (settings) => settings.toJson(), + ); + appConfigClient = HtDataApi( + httpClient: httpClient, + modelName: 'app_config', + fromJson: AppConfig.fromJson, + toJson: (config) => config.toJson(), + ); + } else { + headlinesClient = HtDataApi( + httpClient: httpClient!, + modelName: 'headline', + fromJson: Headline.fromJson, + toJson: (headline) => headline.toJson(), + ); + categoriesClient = HtDataApi( + httpClient: httpClient, + modelName: 'category', + fromJson: Category.fromJson, + toJson: (category) => category.toJson(), + ); + countriesClient = HtDataApi( + httpClient: httpClient, + modelName: 'country', + fromJson: Country.fromJson, + toJson: (country) => country.toJson(), + ); + sourcesClient = HtDataApi( + httpClient: httpClient, + modelName: 'source', + fromJson: Source.fromJson, + toJson: (source) => source.toJson(), + ); + userContentPreferencesClient = HtDataApi( + httpClient: httpClient, + modelName: 'user_content_preferences', + fromJson: UserContentPreferences.fromJson, + toJson: (prefs) => prefs.toJson(), + ); + userAppSettingsClient = HtDataApi( + httpClient: httpClient, + modelName: 'user_app_settings', + fromJson: UserAppSettings.fromJson, + toJson: (settings) => settings.toJson(), + ); + appConfigClient = HtDataApi( + httpClient: httpClient, + modelName: 'app_config', + fromJson: AppConfig.fromJson, + toJson: (config) => config.toJson(), + ); + } + + final headlinesRepository = HtDataRepository( + dataClient: headlinesClient, + ); + final categoriesRepository = HtDataRepository( + dataClient: categoriesClient, + ); + final countriesRepository = HtDataRepository( + dataClient: countriesClient, + ); + final sourcesRepository = HtDataRepository(dataClient: sourcesClient); + final userContentPreferencesRepository = + HtDataRepository( + dataClient: userContentPreferencesClient, + ); + final userAppSettingsRepository = HtDataRepository( + dataClient: userAppSettingsClient, + ); + final appConfigRepository = HtDataRepository( + dataClient: appConfigClient, + ); + + return App( + htAuthenticationRepository: authenticationRepository, + htHeadlinesRepository: headlinesRepository, + htCategoriesRepository: categoriesRepository, + htCountriesRepository: countriesRepository, + htSourcesRepository: sourcesRepository, + htUserAppSettingsRepository: userAppSettingsRepository, + htUserContentPreferencesRepository: userContentPreferencesRepository, + htAppConfigRepository: appConfigRepository, + kvStorageService: kvStorage, + environment: environment, + ); +} diff --git a/lib/content_management/view/categories_page.dart b/lib/content_management/view/categories_page.dart new file mode 100644 index 00000000..b32c0273 --- /dev/null +++ b/lib/content_management/view/categories_page.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +/// {@template categories_page} +/// A placeholder page for Categories. +/// {@endtemplate} +class CategoriesPage extends StatelessWidget { + /// {@macro categories_page} + const CategoriesPage({super.key}); + + @override + Widget build(BuildContext context) { + return const Center( + child: Text('Categories Page'), + ); + } +} diff --git a/lib/content_management/view/content_management_page.dart b/lib/content_management/view/content_management_page.dart new file mode 100644 index 00000000..29b65ada --- /dev/null +++ b/lib/content_management/view/content_management_page.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:ht_dashboard/content_management/view/categories_page.dart'; +import 'package:ht_dashboard/content_management/view/headlines_page.dart'; +import 'package:ht_dashboard/content_management/view/sources_page.dart'; +import 'package:ht_dashboard/l10n/l10n.dart'; + +/// {@template content_management_page} +/// A page for Content Management with tabbed navigation for sub-sections. +/// {@endtemplate} +class ContentManagementPage extends StatefulWidget { + /// {@macro content_management_page} + const ContentManagementPage({super.key}); + + @override + State createState() => _ContentManagementPageState(); +} + +class _ContentManagementPageState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 3, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Scaffold( + appBar: AppBar( + title: Text(l10n.contentManagement), + bottom: TabBar( + controller: _tabController, + tabs: [ + Tab(text: l10n.headlines), + Tab(text: l10n.categories), + Tab(text: l10n.sources), + ], + ), + ), + body: TabBarView( + controller: _tabController, + children: const [ + HeadlinesPage(), + CategoriesPage(), + SourcesPage(), + ], + ), + ); + } +} diff --git a/lib/content_management/view/headlines_page.dart b/lib/content_management/view/headlines_page.dart new file mode 100644 index 00000000..851a9663 --- /dev/null +++ b/lib/content_management/view/headlines_page.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +/// {@template headlines_page} +/// A placeholder page for Headlines. +/// {@endtemplate} +class HeadlinesPage extends StatelessWidget { + /// {@macro headlines_page} + const HeadlinesPage({super.key}); + + @override + Widget build(BuildContext context) { + return const Center( + child: Text('Headlines Page'), + ); + } +} diff --git a/lib/content_management/view/sources_page.dart b/lib/content_management/view/sources_page.dart new file mode 100644 index 00000000..3f216a7d --- /dev/null +++ b/lib/content_management/view/sources_page.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +/// {@template sources_page} +/// A placeholder page for Sources. +/// {@endtemplate} +class SourcesPage extends StatelessWidget { + /// {@macro sources_page} + const SourcesPage({super.key}); + + @override + Widget build(BuildContext context) { + return const Center( + child: Text('Sources Page'), + ); + } +} diff --git a/lib/dashboard/view/dashboard_page.dart b/lib/dashboard/view/dashboard_page.dart new file mode 100644 index 00000000..a0d7d136 --- /dev/null +++ b/lib/dashboard/view/dashboard_page.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +/// {@template dashboard_page} +/// A placeholder page for the dashboard. +/// {@endtemplate} +class DashboardPage extends StatelessWidget { + /// {@macro dashboard_page} + const DashboardPage({super.key}); + + @override + Widget build(BuildContext context) { + return const Scaffold( + body: Center( + child: Text('Welcome to the Dashboard!'), + ), + ); + } +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart new file mode 100644 index 00000000..2a86f2b8 --- /dev/null +++ b/lib/l10n/app_localizations.dart @@ -0,0 +1,278 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_ar.dart'; +import 'app_localizations_en.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of AppLocalizations +/// returned by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'l10n/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations of(BuildContext context) { + return Localizations.of(context, AppLocalizations)!; + } + + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [ + Locale('ar'), + Locale('en'), + ]; + + /// The conventional newborn programmer greeting + /// + /// In en, this message translates to: + /// **'Hello World!'** + String get helloWorld; + + /// Button label for signing in with email + /// + /// In en, this message translates to: + /// **'Sign in with Email'** + String get authenticationEmailSignInButton; + + /// Title for the email sign-in page + /// + /// In en, this message translates to: + /// **'Email Sign In'** + String get emailSignInPageTitle; + + /// Headline for the request code page + /// + /// In en, this message translates to: + /// **'Sign in or create an account'** + String get requestCodePageHeadline; + + /// Subheadline for the request code page + /// + /// In en, this message translates to: + /// **'Enter your email to receive a verification code. No password needed!'** + String get requestCodePageSubheadline; + + /// Label for the email input field on the request code page + /// + /// In en, this message translates to: + /// **'Email'** + String get requestCodeEmailLabel; + + /// Hint text for the email input field on the request code page + /// + /// In en, this message translates to: + /// **'your.email@example.com'** + String get requestCodeEmailHint; + + /// Validation error message for invalid email format + /// + /// In en, this message translates to: + /// **'Please enter a valid email address.'** + String get accountLinkingEmailValidationError; + + /// Button label for sending the verification code + /// + /// In en, this message translates to: + /// **'Send Code'** + String get requestCodeSendCodeButton; + + /// Title for the email code verification page + /// + /// In en, this message translates to: + /// **'Verify Code'** + String get emailCodeSentPageTitle; + + /// Confirmation message that a code has been sent to the email + /// + /// In en, this message translates to: + /// **'We sent a 6-digit code to {email}'** + String emailCodeSentConfirmation(String email); + + /// Instructions for the user to verify the code + /// + /// In en, this message translates to: + /// **'Please check your inbox and enter the code below to continue.'** + String get emailCodeSentInstructions; + + /// Message displaying the demo verification code + /// + /// In en, this message translates to: + /// **'In demo mode, use code: {code}'** + String demoVerificationCodeMessage(String code); + + /// Hint text for the code input field on the verification page + /// + /// In en, this message translates to: + /// **'6-digit code'** + String get emailCodeVerificationHint; + + /// Validation error message when the code field is empty + /// + /// In en, this message translates to: + /// **'Code cannot be empty.'** + String get emailCodeValidationEmptyError; + + /// Validation error message when the code length is incorrect + /// + /// In en, this message translates to: + /// **'Code must be 6 digits.'** + String get emailCodeValidationLengthError; + + /// Button label for verifying the code + /// + /// In en, this message translates to: + /// **'Verify Code'** + String get emailCodeVerificationButtonLabel; + + /// Label for the dashboard navigation item + /// + /// In en, this message translates to: + /// **'Dashboard'** + String get dashboard; + + /// Label for the content management navigation item + /// + /// In en, this message translates to: + /// **'Content Management'** + String get contentManagement; + + /// Label for the headlines subpage + /// + /// In en, this message translates to: + /// **'Headlines'** + String get headlines; + + /// Label for the categories subpage + /// + /// In en, this message translates to: + /// **'Categories'** + String get categories; + + /// Label for the sources subpage + /// + /// In en, this message translates to: + /// **'Sources'** + String get sources; + + /// Label for the app configuration navigation item + /// + /// In en, this message translates to: + /// **'App Configuration'** + String get appConfiguration; + + /// Label for the settings navigation item + /// + /// In en, this message translates to: + /// **'Settings'** + String get settings; +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => + ['ar', 'en'].contains(locale.languageCode); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +AppLocalizations lookupAppLocalizations(Locale locale) { + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'ar': + return AppLocalizationsAr(); + case 'en': + return AppLocalizationsEn(); + } + + throw FlutterError( + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.', + ); +} diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart new file mode 100644 index 00000000..35ae9252 --- /dev/null +++ b/lib/l10n/app_localizations_ar.dart @@ -0,0 +1,90 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Arabic (`ar`). +class AppLocalizationsAr extends AppLocalizations { + AppLocalizationsAr([String locale = 'ar']) : super(locale); + + @override + String get helloWorld => 'مرحبا بالعالم!'; + + @override + String get authenticationEmailSignInButton => + 'تسجيل الدخول بالبريد الإلكتروني'; + + @override + String get emailSignInPageTitle => 'تسجيل الدخول بالبريد الإلكتروني'; + + @override + String get requestCodePageHeadline => 'سجل الدخول أو أنشئ حسابًا'; + + @override + String get requestCodePageSubheadline => + 'أدخل بريدك الإلكتروني لتلقي رمز التحقق. لا حاجة لكلمة مرور!'; + + @override + String get requestCodeEmailLabel => 'البريد الإلكتروني'; + + @override + String get requestCodeEmailHint => 'بريدك.الإلكتروني@مثال.كوم'; + + @override + String get accountLinkingEmailValidationError => + 'الرجاء إدخال عنوان بريد إلكتروني صالح.'; + + @override + String get requestCodeSendCodeButton => 'إرسال الرمز'; + + @override + String get emailCodeSentPageTitle => 'التحقق من الرمز'; + + @override + String emailCodeSentConfirmation(String email) { + return 'لقد أرسلنا رمزًا مكونًا من 6 أرقام إلى $email'; + } + + @override + String get emailCodeSentInstructions => + 'الرجاء التحقق من بريدك الوارد وإدخال الرمز أدناه للمتابعة.'; + + @override + String demoVerificationCodeMessage(String code) { + return 'في الوضع التجريبي، استخدم الرمز: $code'; + } + + @override + String get emailCodeVerificationHint => 'رمز مكون من 6 أرقام'; + + @override + String get emailCodeValidationEmptyError => 'لا يمكن أن يكون الرمز فارغًا.'; + + @override + String get emailCodeValidationLengthError => 'يجب أن يتكون الرمز من 6 أرقام.'; + + @override + String get emailCodeVerificationButtonLabel => 'التحقق من الرمز'; + + @override + String get dashboard => 'لوحة القيادة'; + + @override + String get contentManagement => 'إدارة المحتوى'; + + @override + String get headlines => 'العناوين الرئيسية'; + + @override + String get categories => 'الفئات'; + + @override + String get sources => 'المصادر'; + + @override + String get appConfiguration => 'إعدادات التطبيق'; + + @override + String get settings => 'الإعدادات'; +} diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart new file mode 100644 index 00000000..0f81c2fc --- /dev/null +++ b/lib/l10n/app_localizations_en.dart @@ -0,0 +1,89 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class AppLocalizationsEn extends AppLocalizations { + AppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get helloWorld => 'Hello World!'; + + @override + String get authenticationEmailSignInButton => 'Sign in with Email'; + + @override + String get emailSignInPageTitle => 'Email Sign In'; + + @override + String get requestCodePageHeadline => 'Sign in or create an account'; + + @override + String get requestCodePageSubheadline => + 'Enter your email to receive a verification code. No password needed!'; + + @override + String get requestCodeEmailLabel => 'Email'; + + @override + String get requestCodeEmailHint => 'your.email@example.com'; + + @override + String get accountLinkingEmailValidationError => + 'Please enter a valid email address.'; + + @override + String get requestCodeSendCodeButton => 'Send Code'; + + @override + String get emailCodeSentPageTitle => 'Verify Code'; + + @override + String emailCodeSentConfirmation(String email) { + return 'We sent a 6-digit code to $email'; + } + + @override + String get emailCodeSentInstructions => + 'Please check your inbox and enter the code below to continue.'; + + @override + String demoVerificationCodeMessage(String code) { + return 'In demo mode, use code: $code'; + } + + @override + String get emailCodeVerificationHint => '6-digit code'; + + @override + String get emailCodeValidationEmptyError => 'Code cannot be empty.'; + + @override + String get emailCodeValidationLengthError => 'Code must be 6 digits.'; + + @override + String get emailCodeVerificationButtonLabel => 'Verify Code'; + + @override + String get dashboard => 'Dashboard'; + + @override + String get contentManagement => 'Content Management'; + + @override + String get headlines => 'Headlines'; + + @override + String get categories => 'Categories'; + + @override + String get sources => 'Sources'; + + @override + String get appConfiguration => 'App Configuration'; + + @override + String get settings => 'Settings'; +} diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb new file mode 100644 index 00000000..9c9dd01d --- /dev/null +++ b/lib/l10n/arb/app_ar.arb @@ -0,0 +1,108 @@ +{ + "helloWorld": "مرحبا بالعالم!", + "@helloWorld": { + "description": "التحية التقليدية للمبرمج حديث الولادة" + }, + "authenticationEmailSignInButton": "تسجيل الدخول بالبريد الإلكتروني", + "@authenticationEmailSignInButton": { + "description": "زر تسجيل الدخول بالبريد الإلكتروني" + }, + "emailSignInPageTitle": "تسجيل الدخول بالبريد الإلكتروني", + "@emailSignInPageTitle": { + "description": "عنوان صفحة تسجيل الدخول بالبريد الإلكتروني" + }, + "requestCodePageHeadline": "سجل الدخول أو أنشئ حسابًا", + "@requestCodePageHeadline": { + "description": "عنوان صفحة طلب الرمز" + }, + "requestCodePageSubheadline": "أدخل بريدك الإلكتروني لتلقي رمز التحقق. لا حاجة لكلمة مرور!", + "@requestCodePageSubheadline": { + "description": "عنوان فرعي لصفحة طلب الرمز" + }, + "requestCodeEmailLabel": "البريد الإلكتروني", + "@requestCodeEmailLabel": { + "description": "تسمية حقل إدخال البريد الإلكتروني في صفحة طلب الرمز" + }, + "requestCodeEmailHint": "بريدك.الإلكتروني@مثال.كوم", + "@requestCodeEmailHint": { + "description": "نص تلميح لحقل إدخال البريد الإلكتروني في صفحة طلب الرمز" + }, + "accountLinkingEmailValidationError": "الرجاء إدخال عنوان بريد إلكتروني صالح.", + "@accountLinkingEmailValidationError": { + "description": "رسالة خطأ التحقق لتنسيق البريد الإلكتروني غير الصالح" + }, + "requestCodeSendCodeButton": "إرسال الرمز", + "@requestCodeSendCodeButton": { + "description": "تسمية زر إرسال رمز التحقق" + }, + "emailCodeSentPageTitle": "التحقق من الرمز", + "@emailCodeSentPageTitle": { + "description": "عنوان صفحة التحقق من رمز البريد الإلكتروني" + }, + "emailCodeSentConfirmation": "لقد أرسلنا رمزًا مكونًا من 6 أرقام إلى {email}", + "@emailCodeSentConfirmation": { + "description": "رسالة تأكيد بإرسال الرمز إلى البريد الإلكتروني", + "placeholders": { + "email": { + "type": "String" + } + } + }, + "emailCodeSentInstructions": "الرجاء التحقق من بريدك الوارد وإدخال الرمز أدناه للمتابعة.", + "@emailCodeSentInstructions": { + "description": "تعليمات للمستخدم للتحقق من الرمز" + }, + "demoVerificationCodeMessage": "في الوضع التجريبي، استخدم الرمز: {code}", + "@demoVerificationCodeMessage": { + "description": "رسالة تعرض رمز التحقق التجريبي", + "placeholders": { + "code": { + "type": "String" + } + } + }, + "emailCodeVerificationHint": "رمز مكون من 6 أرقام", + "@emailCodeVerificationHint": { + "description": "نص تلميح لحقل إدخال الرمز في صفحة التحقق" + }, + "emailCodeValidationEmptyError": "لا يمكن أن يكون الرمز فارغًا.", + "@emailCodeValidationEmptyError": { + "description": "رسالة خطأ التحقق عندما يكون حقل الرمز فارغًا" + }, + "emailCodeValidationLengthError": "يجب أن يتكون الرمز من 6 أرقام.", + "@emailCodeValidationLengthError": { + "description": "رسالة خطأ التحقق عندما يكون طول الرمز غير صحيح" + }, + "emailCodeVerificationButtonLabel": "التحقق من الرمز", + "@emailCodeVerificationButtonLabel": { + "description": "تسمية زر التحقق من الرمز" + }, + "dashboard": "لوحة القيادة", + "@dashboard": { + "description": "تسمية عنصر التنقل للوحة القيادة" + }, + "contentManagement": "إدارة المحتوى", + "@contentManagement": { + "description": "تسمية عنصر التنقل لإدارة المحتوى" + }, + "headlines": "العناوين الرئيسية", + "@headlines": { + "description": "تسمية الصفحة الفرعية للعناوين الرئيسية" + }, + "categories": "الفئات", + "@categories": { + "description": "تسمية الصفحة الفرعية للفئات" + }, + "sources": "المصادر", + "@sources": { + "description": "تسمية الصفحة الفرعية للمصادر" + }, + "appConfiguration": "إعدادات التطبيق", + "@appConfiguration": { + "description": "تسمية عنصر التنقل لإعدادات التطبيق" + }, + "settings": "الإعدادات", + "@settings": { + "description": "تسمية عنصر التنقل للإعدادات" + } +} diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb new file mode 100644 index 00000000..cffc9c21 --- /dev/null +++ b/lib/l10n/arb/app_en.arb @@ -0,0 +1,108 @@ +{ + "helloWorld": "Hello World!", + "@helloWorld": { + "description": "The conventional newborn programmer greeting" + }, + "authenticationEmailSignInButton": "Sign in with Email", + "@authenticationEmailSignInButton": { + "description": "Button label for signing in with email" + }, + "emailSignInPageTitle": "Email Sign In", + "@emailSignInPageTitle": { + "description": "Title for the email sign-in page" + }, + "requestCodePageHeadline": "Sign in or create an account", + "@requestCodePageHeadline": { + "description": "Headline for the request code page" + }, + "requestCodePageSubheadline": "Enter your email to receive a verification code. No password needed!", + "@requestCodePageSubheadline": { + "description": "Subheadline for the request code page" + }, + "requestCodeEmailLabel": "Email", + "@requestCodeEmailLabel": { + "description": "Label for the email input field on the request code page" + }, + "requestCodeEmailHint": "your.email@example.com", + "@requestCodeEmailHint": { + "description": "Hint text for the email input field on the request code page" + }, + "accountLinkingEmailValidationError": "Please enter a valid email address.", + "@accountLinkingEmailValidationError": { + "description": "Validation error message for invalid email format" + }, + "requestCodeSendCodeButton": "Send Code", + "@requestCodeSendCodeButton": { + "description": "Button label for sending the verification code" + }, + "emailCodeSentPageTitle": "Verify Code", + "@emailCodeSentPageTitle": { + "description": "Title for the email code verification page" + }, + "emailCodeSentConfirmation": "We sent a 6-digit code to {email}", + "@emailCodeSentConfirmation": { + "description": "Confirmation message that a code has been sent to the email", + "placeholders": { + "email": { + "type": "String" + } + } + }, + "emailCodeSentInstructions": "Please check your inbox and enter the code below to continue.", + "@emailCodeSentInstructions": { + "description": "Instructions for the user to verify the code" + }, + "demoVerificationCodeMessage": "In demo mode, use code: {code}", + "@demoVerificationCodeMessage": { + "description": "Message displaying the demo verification code", + "placeholders": { + "code": { + "type": "String" + } + } + }, + "emailCodeVerificationHint": "6-digit code", + "@emailCodeVerificationHint": { + "description": "Hint text for the code input field on the verification page" + }, + "emailCodeValidationEmptyError": "Code cannot be empty.", + "@emailCodeValidationEmptyError": { + "description": "Validation error message when the code field is empty" + }, + "emailCodeValidationLengthError": "Code must be 6 digits.", + "@emailCodeValidationLengthError": { + "description": "Validation error message when the code length is incorrect" + }, + "emailCodeVerificationButtonLabel": "Verify Code", + "@emailCodeVerificationButtonLabel": { + "description": "Button label for verifying the code" + }, + "dashboard": "Dashboard", + "@dashboard": { + "description": "Label for the dashboard navigation item" + }, + "contentManagement": "Content Management", + "@contentManagement": { + "description": "Label for the content management navigation item" + }, + "headlines": "Headlines", + "@headlines": { + "description": "Label for the headlines subpage" + }, + "categories": "Categories", + "@categories": { + "description": "Label for the categories subpage" + }, + "sources": "Sources", + "@sources": { + "description": "Label for the sources subpage" + }, + "appConfiguration": "App Configuration", + "@appConfiguration": { + "description": "Label for the app configuration navigation item" + }, + "settings": "Settings", + "@settings": { + "description": "Label for the settings navigation item" + } +} diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart new file mode 100644 index 00000000..e0bd0275 --- /dev/null +++ b/lib/l10n/l10n.dart @@ -0,0 +1,6 @@ +import 'package:flutter/widgets.dart'; +import 'package:ht_dashboard/l10n/app_localizations.dart'; + +extension AppLocalizationsX on BuildContext { + AppLocalizations get l10n => AppLocalizations.of(this); +} diff --git a/lib/main.dart b/lib/main.dart index a7256585..34b787b4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,20 +1,28 @@ +import 'dart:js_interop'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:ht_dashboard/app/config/config.dart'; +import 'package:ht_dashboard/bootstrap.dart'; -void main() { - runApp(const MainApp()); -} +// Define the current application environment (production/development/demo). +const AppEnvironment appEnvironment = AppEnvironment.demo; + +@JS('removeSplashFromWeb') +external void removeSplashFromWeb(); + +void main() async { + final appConfig = switch (appEnvironment) { + AppEnvironment.production => AppConfig.production(), + AppEnvironment.development => AppConfig.development(), + AppEnvironment.demo => AppConfig.demo(), + }; -class MainApp extends StatelessWidget { - const MainApp({super.key}); + final appWidget = await bootstrap(appConfig, appEnvironment); - @override - Widget build(BuildContext context) { - return const MaterialApp( - home: Scaffold( - body: Center( - child: Text('Hello World!'), - ), - ), - ); + // Only remove the splash screen on web after the app is ready. + if (kIsWeb) { + removeSplashFromWeb(); } + runApp(appWidget); } diff --git a/lib/router/router.dart b/lib/router/router.dart new file mode 100644 index 00000000..070dfec6 --- /dev/null +++ b/lib/router/router.dart @@ -0,0 +1,195 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:ht_auth_repository/ht_auth_repository.dart'; +import 'package:ht_dashboard/app/bloc/app_bloc.dart'; +import 'package:ht_dashboard/app/config/config.dart' as local_config; +import 'package:ht_dashboard/app/view/app_shell.dart'; +import 'package:ht_dashboard/authentication/bloc/authentication_bloc.dart'; +import 'package:ht_dashboard/authentication/view/authentication_page.dart'; +import 'package:ht_dashboard/authentication/view/email_code_verification_page.dart'; +import 'package:ht_dashboard/authentication/view/request_code_page.dart'; +import 'package:ht_dashboard/l10n/l10n.dart'; +import 'package:ht_dashboard/router/routes.dart'; +import 'package:ht_dashboard/app_configuration/view/app_configuration_page.dart'; +import 'package:ht_dashboard/content_management/view/categories_page.dart'; +import 'package:ht_dashboard/content_management/view/content_management_page.dart'; +import 'package:ht_dashboard/content_management/view/headlines_page.dart'; +import 'package:ht_dashboard/content_management/view/sources_page.dart'; +import 'package:ht_dashboard/dashboard/view/dashboard_page.dart'; +import 'package:ht_dashboard/settings/view/settings_page.dart'; +import 'package:ht_shared/ht_shared.dart'; + +/// Creates and configures the GoRouter instance for the application. +/// +/// Requires an [authStatusNotifier] to trigger route re-evaluation when +/// authentication state changes. +GoRouter createRouter({ + required ValueNotifier authStatusNotifier, + required HtAuthRepository htAuthenticationRepository, + required local_config.AppEnvironment environment, +}) { + return GoRouter( + refreshListenable: authStatusNotifier, + initialLocation: Routes.authentication, + debugLogDiagnostics: true, + // --- Redirect Logic --- + redirect: (BuildContext context, GoRouterState state) { + final appStatus = context.read().state.status; + final currentLocation = state.matchedLocation; + + print( + 'GoRouter Redirect Check:\n' + ' Current Location (Matched): $currentLocation\n' + ' AppStatus: $appStatus\n', + ); + + // --- Define Key Paths --- + const authenticationPath = Routes.authentication; + const dashboardPath = Routes.dashboard; + final isGoingToAuth = currentLocation.startsWith(authenticationPath); + final isGoingToDashboard = currentLocation.startsWith(dashboardPath); + + // --- Case 1: Unauthenticated User --- + if (appStatus == AppStatus.unauthenticated || + appStatus == AppStatus.initial) { + print(' Redirect Decision: User is UNauthenticated or INITIAL.'); + if (!isGoingToAuth) { + print( + ' Action: Not going to auth. Redirecting to $authenticationPath', + ); + return authenticationPath; + } + print(' Action: Already going to auth. Allowing navigation.'); + return null; + } + + // --- Case 2: Authenticated User --- + if (appStatus == AppStatus.authenticated) { + print(' Redirect Decision: User is $appStatus.'); + + // If an authenticated user is on any authentication-related path: + if (isGoingToAuth) { + print( + ' Action: Authenticated user on auth path ($currentLocation). ' + 'Redirecting to $dashboardPath', + ); + return dashboardPath; + } + // Allow access to other routes (non-auth paths), which should only be dashboard for now + print( + ' Action: Allowing navigation to $currentLocation for $appStatus ' + 'user (non-auth path).', + ); + return null; + } + + // Fallback (should ideally not be reached if all statuses are handled) + print( + ' Redirect Decision: Fallback, no specific condition met for $appStatus. ' + 'Allowing navigation.', + ); + return null; + }, + // --- Routes --- + routes: [ + GoRoute( + path: Routes.authentication, + name: Routes.authenticationName, + builder: (BuildContext context, GoRouterState state) { + final l10n = context.l10n; + const String headline = 'Sign In to Dashboard'; + const String subHeadline = 'Enter your email to get a verification code.'; + const bool showAnonymousButton = false; + + return BlocProvider( + create: (context) => AuthenticationBloc( + authenticationRepository: context.read(), + ), + child: const AuthenticationPage( + headline: headline, + subHeadline: subHeadline, + showAnonymousButton: showAnonymousButton, + ), + ); + }, + routes: [ + GoRoute( + path: Routes.requestCode, + name: Routes.requestCodeName, + builder: (context, state) => + const RequestCodePage(isLinkingContext: false), + ), + GoRoute( + path: '${Routes.verifyCode}/:email', + name: Routes.verifyCodeName, + builder: (context, state) { + final email = state.pathParameters['email']!; + return EmailCodeVerificationPage(email: email); + }, + ), + ], + ), + StatefulShellRoute.indexedStack( + builder: (context, state, navigationShell) { + return AppShell(navigationShell: navigationShell); + }, + branches: [ + StatefulShellBranch( + routes: [ + GoRoute( + path: Routes.dashboard, + name: Routes.dashboardName, + builder: (context, state) => const DashboardPage(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: Routes.contentManagement, + name: Routes.contentManagementName, + builder: (context, state) => const ContentManagementPage(), + routes: [ + GoRoute( + path: Routes.headlines, + name: Routes.headlinesName, + builder: (context, state) => const HeadlinesPage(), + ), + GoRoute( + path: Routes.categories, + name: Routes.categoriesName, + builder: (context, state) => const CategoriesPage(), + ), + GoRoute( + path: Routes.sources, + name: Routes.sourcesName, + builder: (context, state) => const SourcesPage(), + ), + ], + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: Routes.appConfiguration, + name: Routes.appConfigurationName, + builder: (context, state) => const AppConfigurationPage(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: Routes.settings, + name: Routes.settingsName, + builder: (context, state) => const SettingsPage(), + ), + ], + ), + ], + ), + ], + ); +} diff --git a/lib/router/routes.dart b/lib/router/routes.dart new file mode 100644 index 00000000..ccfde190 --- /dev/null +++ b/lib/router/routes.dart @@ -0,0 +1,76 @@ +/// Defines named constants for route paths and names used throughout the application. +/// +/// Using constants helps prevent typos and makes route management easier. +abstract final class Routes { + /// The path for the authentication page. + static const String authentication = '/authentication'; + + /// The name for the authentication page route. + static const String authenticationName = 'authentication'; + + /// The path for the request code page. + static const String requestCode = 'request-code'; + + /// The name for the request code page route. + static const String requestCodeName = 'requestCode'; + + /// The path for the verify code page. + static const String verifyCode = 'verify-code'; + + /// The name for the verify code page route. + static const String verifyCodeName = 'verifyCode'; + + /// The path for the account linking flow. + static const String accountLinking = 'linking'; + + /// The name for the account linking flow route. + static const String accountLinkingName = 'accountLinking'; + + /// The name for the request code page route within the linking flow. + static const String linkingRequestCodeName = 'linkingRequestCode'; + + /// The name for the verify code page route within the linking flow. + static const String linkingVerifyCodeName = 'linkingVerifyCode'; + + /// The path for the dashboard page. + static const String dashboard = '/dashboard'; + + /// The name for the dashboard page route. + static const String dashboardName = 'dashboard'; + + /// The path for the content management section. + static const String contentManagement = '/content-management'; + + /// The name for the content management section route. + static const String contentManagementName = 'contentManagement'; + + /// The path for the headlines page within content management. + static const String headlines = 'headlines'; + + /// The name for the headlines page route. + static const String headlinesName = 'headlines'; + + /// The path for the categories page within content management. + static const String categories = 'categories'; + + /// The name for the categories page route. + static const String categoriesName = 'categories'; + + /// The path for the sources page within content management. + static const String sources = 'sources'; + + /// The name for the sources page route. + static const String sourcesName = 'sources'; + + /// The path for the app configuration page. + static const String appConfiguration = '/app-configuration'; + + /// The name for the app configuration page route. + static const String appConfigurationName = 'appConfiguration'; + + /// The path for the settings page. + static const String settings = '/settings'; + + /// The name for the settings page route. + static const String settingsName = 'settings'; +} diff --git a/lib/settings/view/settings_page.dart b/lib/settings/view/settings_page.dart new file mode 100644 index 00000000..343725ca --- /dev/null +++ b/lib/settings/view/settings_page.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +/// {@template settings_page} +/// A placeholder page for Settings. +/// {@endtemplate} +class SettingsPage extends StatelessWidget { + /// {@macro settings_page} + const SettingsPage({super.key}); + + @override + Widget build(BuildContext context) { + return const Scaffold( + body: Center( + child: Text('Settings Page'), + ), + ); + } +} diff --git a/lib/shared/constants/app_spacing.dart b/lib/shared/constants/app_spacing.dart new file mode 100644 index 00000000..ddc23d98 --- /dev/null +++ b/lib/shared/constants/app_spacing.dart @@ -0,0 +1,40 @@ +/// Defines standard spacing constants used throughout the application. +/// +/// Consistent spacing is crucial for a clean and professional UI. +/// Using these constants ensures uniformity and makes global +/// adjustments easier. +abstract final class AppSpacing { + /// Extra small spacing value (e.g., 4.0). + static const double xs = 4; + + /// Small spacing value (e.g., 8.0). + static const double sm = 8; + + /// Medium spacing value (e.g., 12.0). + static const double md = 12; + + /// Large spacing value (e.g., 16.0). + static const double lg = 16; + + /// Extra large spacing value (e.g., 24.0). + static const double xl = 24; + + /// Extra extra large spacing value (e.g., 32.0). + static const double xxl = 32; + + // --- Padding Specific --- + // While the above can be used for padding, specific names can + // improve clarity. + + /// Small padding value (equivalent to sm). + static const double paddingSmall = sm; + + /// Medium padding value (equivalent to md). + static const double paddingMedium = md; + + /// Large padding value (equivalent to lg). + static const double paddingLarge = lg; + + /// Extra large padding value (equivalent to xl). + static const double paddingExtraLarge = xl; +} diff --git a/lib/shared/constants/constants.dart b/lib/shared/constants/constants.dart new file mode 100644 index 00000000..e92c41a1 --- /dev/null +++ b/lib/shared/constants/constants.dart @@ -0,0 +1,5 @@ +/// Barrel file for shared constants. +/// Exports application-wide constant definitions. +library; + +export 'app_spacing.dart'; diff --git a/lib/shared/localization/ar_timeago_messages.dart b/lib/shared/localization/ar_timeago_messages.dart new file mode 100644 index 00000000..a4de4608 --- /dev/null +++ b/lib/shared/localization/ar_timeago_messages.dart @@ -0,0 +1,43 @@ +import 'package:timeago/timeago.dart' as timeago; + +/// Custom Arabic lookup messages for the timeago package. +class ArTimeagoMessages implements timeago.LookupMessages { + @override + String prefixAgo() => ''; + @override + String prefixFromNow() => 'بعد '; + @override + String suffixAgo() => ''; + @override + String suffixFromNow() => ''; + + @override + String lessThanOneMinute(int seconds) => 'الآن'; + @override + String aboutAMinute(int minutes) => 'منذ 1د'; + @override + String minutes(int minutes) => 'منذ $minutesد'; + + @override + String aboutAnHour(int minutes) => 'منذ 1س'; + @override + String hours(int hours) => 'منذ $hoursس'; + + @override + String aDay(int hours) => 'منذ 1ي'; + @override + String days(int days) => 'منذ $daysي'; + + @override + String aboutAMonth(int days) => 'منذ 1ش'; + @override + String months(int months) => 'منذ $monthsش'; + + @override + String aboutAYear(int year) => 'منذ 1سنة'; + @override + String years(int years) => 'منذ $yearsسنوات'; + + @override + String wordSeparator() => ' '; +} diff --git a/lib/shared/localization/en_timeago_messages.dart b/lib/shared/localization/en_timeago_messages.dart new file mode 100644 index 00000000..f3f284aa --- /dev/null +++ b/lib/shared/localization/en_timeago_messages.dart @@ -0,0 +1,43 @@ +import 'package:timeago/timeago.dart' as timeago; + +/// Custom English lookup messages for the timeago package (concise). +class EnTimeagoMessages implements timeago.LookupMessages { + @override + String prefixAgo() => ''; + @override + String prefixFromNow() => ''; + @override + String suffixAgo() => ' ago'; + @override + String suffixFromNow() => ' from now'; + + @override + String lessThanOneMinute(int seconds) => 'now'; + @override + String aboutAMinute(int minutes) => '1m'; + @override + String minutes(int minutes) => '${minutes}m'; + + @override + String aboutAnHour(int minutes) => '1h'; + @override + String hours(int hours) => '${hours}h'; + + @override + String aDay(int hours) => '1d'; + @override + String days(int days) => '${days}d'; + + @override + String aboutAMonth(int days) => '1mo'; + @override + String months(int months) => '${months}mo'; + + @override + String aboutAYear(int year) => '1y'; + @override + String years(int years) => '${years}y'; + + @override + String wordSeparator() => ' '; +} diff --git a/lib/shared/shared.dart b/lib/shared/shared.dart new file mode 100644 index 00000000..a0f4b74e --- /dev/null +++ b/lib/shared/shared.dart @@ -0,0 +1,10 @@ +/// Barrel file for the shared library. +/// +/// Exports common constants, theme elements, and widgets used across +/// the application to promote consistency and maintainability. +library; + +export 'constants/constants.dart'; +export 'theme/theme.dart'; +export 'utils/utils.dart'; +export 'widgets/widgets.dart'; diff --git a/lib/shared/theme/app_theme.dart b/lib/shared/theme/app_theme.dart new file mode 100644 index 00000000..96a701af --- /dev/null +++ b/lib/shared/theme/app_theme.dart @@ -0,0 +1,211 @@ +// +// ignore_for_file: lines_longer_than_80_chars + +import 'package:flex_color_scheme/flex_color_scheme.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:ht_shared/ht_shared.dart'; + +// --- Common Sub-theme Settings --- +// Defines customizations for various components, shared between light/dark themes. +const FlexSubThemesData _commonSubThemesData = FlexSubThemesData( + // --- Card Theme --- + // Slightly rounded corners for cards (headline items) + cardRadius: 8, + // Use default elevation or specify if needed: cardElevation: 2.0, + + // --- AppBar Theme --- + // Example: Use scheme surface color for app bar (often less distracting) + appBarBackgroundSchemeColor: SchemeColor.surface, + // Or keep default: appBarBackgroundSchemeColor: SchemeColor.primary, + // Example: Center title? appBarCenterTitle: true, + + // --- Input Decorator (for Search TextField) --- + // Example: Add a border radius + inputDecoratorRadius: 8, + // Example: Use outline border (common modern style) + inputDecoratorIsFilled: false, + inputDecoratorBorderType: FlexInputBorderType.outline, + + // Add other component themes as needed (Buttons, Dialogs, etc.) +); + +// Helper function to apply common text theme customizations +TextTheme _customizeTextTheme( + TextTheme baseTextTheme, { + required AppTextScaleFactor appTextScaleFactor, + required AppFontWeight appFontWeight, +}) { + print( + '[_customizeTextTheme] Received appFontWeight: $appFontWeight, appTextScaleFactor: $appTextScaleFactor', + ); + // Define font size factors + double factor; + switch (appTextScaleFactor) { + case AppTextScaleFactor.small: + factor = 0.85; + case AppTextScaleFactor.large: + factor = 1.15; + case AppTextScaleFactor.medium: + factor = 1.0; + case AppTextScaleFactor.extraLarge: + factor = 1.3; + } + + // Helper to apply factor safely + double? applyFactor(double? baseSize) => + baseSize != null ? (baseSize * factor).roundToDouble() : null; + + // Map AppFontWeight to FontWeight + FontWeight selectedFontWeight; + switch (appFontWeight) { + case AppFontWeight.light: + selectedFontWeight = FontWeight.w300; + case AppFontWeight.regular: + selectedFontWeight = FontWeight.w400; + case AppFontWeight.bold: + selectedFontWeight = FontWeight.w700; + } + print( + '[_customizeTextTheme] Mapped to selectedFontWeight: $selectedFontWeight', + ); + + return baseTextTheme.copyWith( + // --- Headline/Title Styles --- + // Headlines and titles often have their own explicit weights, + // but we can make them configurable if needed. For now, let's assume + // body text is the primary target for user-defined weight. + headlineLarge: baseTextTheme.headlineLarge?.copyWith( + fontSize: applyFactor(28), + fontWeight: FontWeight.bold, + ), + headlineMedium: baseTextTheme.headlineMedium?.copyWith( + fontSize: applyFactor(24), + fontWeight: FontWeight.bold, + ), + titleLarge: baseTextTheme.titleLarge?.copyWith( + fontSize: applyFactor(18), + fontWeight: FontWeight.w600, + ), + titleMedium: baseTextTheme.titleMedium?.copyWith( + fontSize: applyFactor(16), + fontWeight: FontWeight.w600, + ), + + // --- Body/Content Styles --- + // Apply user-selected font weight to body text + bodyLarge: baseTextTheme.bodyLarge?.copyWith( + fontSize: applyFactor(16), + height: 1.5, + fontWeight: selectedFontWeight, + ), + bodyMedium: baseTextTheme.bodyMedium?.copyWith( + fontSize: applyFactor(14), + height: 1.4, + fontWeight: selectedFontWeight, + ), + + // --- Metadata/Caption Styles --- + // Captions might also benefit from user-defined weight or stay regular. + labelSmall: baseTextTheme.labelSmall?.copyWith( + fontSize: applyFactor(12), + fontWeight: selectedFontWeight, + ), + + // --- Button Style (Usually default is fine) --- + // labelLarge: baseTextTheme.labelLarge?.copyWith(fontSize: 14, fontWeight: FontWeight.bold), + ); +} + +// Helper function to get the appropriate GoogleFonts text theme function +// based on the provided font family name. +// Corrected return type to match GoogleFonts functions (positional optional) +TextTheme Function([TextTheme?]) _getGoogleFontTextTheme(String? fontFamily) { + print('[_getGoogleFontTextTheme] Received fontFamily: $fontFamily'); + switch (fontFamily) { + case 'Roboto': + print('[_getGoogleFontTextTheme] Returning GoogleFonts.robotoTextTheme'); + return GoogleFonts.robotoTextTheme; + case 'OpenSans': + print( + '[_getGoogleFontTextTheme] Returning GoogleFonts.openSansTextTheme', + ); + return GoogleFonts.openSansTextTheme; + case 'Lato': + print('[_getGoogleFontTextTheme] Returning GoogleFonts.latoTextTheme'); + return GoogleFonts.latoTextTheme; + case 'Montserrat': + print( + '[_getGoogleFontTextTheme] Returning GoogleFonts.montserratTextTheme', + ); + return GoogleFonts.montserratTextTheme; + case 'Merriweather': + print( + '[_getGoogleFontTextTheme] Returning GoogleFonts.merriweatherTextTheme', + ); + return GoogleFonts.merriweatherTextTheme; + case 'SystemDefault': + case null: + default: + print( + '[_getGoogleFontTextTheme] Defaulting to GoogleFonts.notoSansTextTheme for input: $fontFamily', + ); + return GoogleFonts.notoSansTextTheme; + } +} + +/// Defines the application's light theme using FlexColorScheme. +/// +/// Takes the active [scheme], [appTextScaleFactor], [appFontWeight], and optional [fontFamily]. +ThemeData lightTheme({ + required FlexScheme scheme, + required AppTextScaleFactor appTextScaleFactor, + required AppFontWeight appFontWeight, + String? fontFamily, +}) { + print( + '[AppTheme.lightTheme] Received scheme: $scheme, appTextScaleFactor: $appTextScaleFactor, appFontWeight: $appFontWeight, fontFamily: $fontFamily', + ); + final textThemeGetter = _getGoogleFontTextTheme(fontFamily); + final baseTextTheme = textThemeGetter(); + + return FlexThemeData.light( + scheme: scheme, + fontFamily: fontFamily, + textTheme: _customizeTextTheme( + baseTextTheme, + appTextScaleFactor: appTextScaleFactor, + appFontWeight: appFontWeight, + ), + subThemesData: _commonSubThemesData, + ); +} + +/// Defines the application's dark theme using FlexColorScheme. +/// +/// Takes the active [scheme], [appTextScaleFactor], [appFontWeight], and optional [fontFamily]. +ThemeData darkTheme({ + required FlexScheme scheme, + required AppTextScaleFactor appTextScaleFactor, + required AppFontWeight appFontWeight, + String? fontFamily, +}) { + print( + '[AppTheme.darkTheme] Received scheme: $scheme, appTextScaleFactor: $appTextScaleFactor, appFontWeight: $appFontWeight, fontFamily: $fontFamily', + ); + final textThemeGetter = _getGoogleFontTextTheme(fontFamily); + final baseTextTheme = textThemeGetter( + ThemeData(brightness: Brightness.dark).textTheme, + ); + + return FlexThemeData.dark( + scheme: scheme, + fontFamily: fontFamily, + textTheme: _customizeTextTheme( + baseTextTheme, + appTextScaleFactor: appTextScaleFactor, + appFontWeight: appFontWeight, + ), + subThemesData: _commonSubThemesData, + ); +} diff --git a/lib/shared/theme/theme.dart b/lib/shared/theme/theme.dart new file mode 100644 index 00000000..5d889ee2 --- /dev/null +++ b/lib/shared/theme/theme.dart @@ -0,0 +1,5 @@ +/// Barrel file for shared theme elements. +/// Exports application-wide theme definitions like colors and theme data. +library; + +export 'app_theme.dart'; diff --git a/lib/shared/utils/date_formatter.dart b/lib/shared/utils/date_formatter.dart new file mode 100644 index 00000000..3ac01296 --- /dev/null +++ b/lib/shared/utils/date_formatter.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; +import 'package:timeago/timeago.dart' as timeago; + +/// Formats the given [dateTime] into a relative time string +/// (e.g., "5m ago", "Yesterday", "now"). +/// +/// Uses the current locale from [context] to format appropriately. +/// Returns an empty string if [dateTime] is null. +String formatRelativeTime(BuildContext context, DateTime? dateTime) { + if (dateTime == null) { + return ''; + } + final locale = Localizations.localeOf(context).languageCode; + return timeago.format(dateTime, locale: locale); +} diff --git a/lib/shared/utils/utils.dart b/lib/shared/utils/utils.dart new file mode 100644 index 00000000..46a14dda --- /dev/null +++ b/lib/shared/utils/utils.dart @@ -0,0 +1,4 @@ +/// Barrel file for shared utility functions. +library; + +export 'date_formatter.dart'; diff --git a/lib/shared/widgets/failure_state_widget.dart b/lib/shared/widgets/failure_state_widget.dart new file mode 100644 index 00000000..4bea388a --- /dev/null +++ b/lib/shared/widgets/failure_state_widget.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +/// A widget to display an error message and an optional retry button. +class FailureStateWidget extends StatelessWidget { + /// Creates a [FailureStateWidget]. + /// + /// The [message] is the error message to display. + /// + /// The [onRetry] is an optional callback to be called + /// when the retry button is pressed. + const FailureStateWidget({ + required this.message, + super.key, + this.onRetry, + this.retryButtonText, + }); + + /// The error message to display. + final String message; + + /// An optional callback to be called when the retry button is pressed. + final VoidCallback? onRetry; + + /// Optional custom text for the retry button. Defaults to "Retry". + final String? retryButtonText; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + message, + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + // Show the retry button only if onRetry is provided + if (onRetry != null) + Padding( + padding: const EdgeInsets.only(top: 16), + child: ElevatedButton( + onPressed: onRetry, + child: Text(retryButtonText ?? 'Retry'), + ), + ), + ], + ), + ); + } +} diff --git a/lib/shared/widgets/initial_state_widget.dart b/lib/shared/widgets/initial_state_widget.dart new file mode 100644 index 00000000..223b485d --- /dev/null +++ b/lib/shared/widgets/initial_state_widget.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +class InitialStateWidget extends StatelessWidget { + const InitialStateWidget({ + required this.icon, + required this.headline, + required this.subheadline, + super.key, + }); + + final IconData icon; + final String headline; + final String subheadline; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 64), + const SizedBox(height: 16), + Text(headline, style: const TextStyle(fontSize: 24)), + Text(subheadline), + ], + ), + ); + } +} diff --git a/lib/shared/widgets/loading_state_widget.dart b/lib/shared/widgets/loading_state_widget.dart new file mode 100644 index 00000000..a4648740 --- /dev/null +++ b/lib/shared/widgets/loading_state_widget.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +class LoadingStateWidget extends StatelessWidget { + const LoadingStateWidget({ + required this.icon, + required this.headline, + required this.subheadline, + super.key, + }); + + final IconData icon; + final String headline; + final String subheadline; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 64), + const SizedBox(height: 16), + Text(headline, style: const TextStyle(fontSize: 24)), + Text(subheadline), + const SizedBox(height: 16), + CircularProgressIndicator( + color: Theme.of(context).colorScheme.secondary, + ), + ], + ), + ); + } +} diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart new file mode 100644 index 00000000..08c4d251 --- /dev/null +++ b/lib/shared/widgets/widgets.dart @@ -0,0 +1,7 @@ +/// Barrel file for shared widgets. +/// Exports common, reusable UI components. +library; + +export 'failure_state_widget.dart'; +export 'initial_state_widget.dart'; +export 'loading_state_widget.dart'; diff --git a/pubspec.lock b/pubspec.lock index 6d2e0fff..af182faf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -545,6 +545,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.2" + timeago: + dependency: "direct main" + description: + name: timeago + sha256: b05159406a97e1cbb2b9ee4faa9fb096fe0e2dfcd8b08fcd2a00553450d3422e + url: "https://pub.dev" + source: hosted + version: "3.7.1" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 541bac4f..e8d71840 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -57,6 +57,7 @@ dependencies: git: url: https://github.com/headlines-toolkit/ht-shared.git intl: ^0.20.2 + timeago: ^3.7.1 dev_dependencies: @@ -64,3 +65,4 @@ dev_dependencies: flutter: uses-material-design: true + generate: true \ No newline at end of file diff --git a/web/favicon.png b/web/favicon.png index 8aaa46ac..27649566 100644 Binary files a/web/favicon.png and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png index b749bfef..073e2f00 100644 Binary files a/web/icons/Icon-192.png and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png index 88cfd48d..8b2140af 100644 Binary files a/web/icons/Icon-512.png and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png index eb9b4d76..073e2f00 100644 Binary files a/web/icons/Icon-maskable-192.png and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png index d69c5669..8b2140af 100644 Binary files a/web/icons/Icon-maskable-512.png and b/web/icons/Icon-maskable-512.png differ diff --git a/web/icons/favicon.png b/web/icons/favicon.png new file mode 100644 index 00000000..66a69cb1 Binary files /dev/null and b/web/icons/favicon.png differ diff --git a/web/index.html b/web/index.html index 85252f8d..73d218ea 100644 --- a/web/index.html +++ b/web/index.html @@ -1,6 +1,4 @@ - - - + - + - + - + - ht_dashboard + Headlines Toolkit + + + - - - +
+
+
+ + + + diff --git a/web/manifest.json b/web/manifest.json index d0e3fb66..d2a5c5a8 100644 --- a/web/manifest.json +++ b/web/manifest.json @@ -1,11 +1,11 @@ { - "name": "ht_dashboard", - "short_name": "ht_dashboard", + "name": "Headlines Toolkit", + "short_name": "Headlines Toolkit", "start_url": ".", "display": "standalone", - "background_color": "#0175C2", - "theme_color": "#0175C2", - "description": "A new Flutter project.", + "background_color": "#FFFFFF", + "theme_color": "#FFFFFF", + "description": "Develop News Headlines Apps Rapidly & Reliably.", "orientation": "portrait-primary", "prefer_related_applications": false, "icons": [ @@ -32,4 +32,4 @@ "purpose": "maskable" } ] -} +} \ No newline at end of file