From 3cdbeebe9a72dcacb2cb2d9897e0e546b9fe683d Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Thu, 22 Jan 2026 21:19:49 +0100 Subject: [PATCH 01/23] feat(deep_links): add deep linking support for iOS & dynamic url scheme based on flavour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added deep linking support for iOS (“custom URL scheme"). - App now accepts custom scheme based on the build flavour, which enables us to have both a development and release flavour of the app installed at the same time without conflict with deep links. - Removed staging as a flavour, leaving only development and production. - App now builds as ‘Analog’ rather than ‘Router Test App’. From b798ae19e32c70f2829ab87f92ca99d6b3597891 Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Fri, 23 Jan 2026 19:54:43 +0100 Subject: [PATCH 02/23] set initial location to login screen From fb1fc8fecd1fe08c038539ff1ba87f40f20a2c5a Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Fri, 23 Jan 2026 19:56:12 +0100 Subject: [PATCH 03/23] make basic version of login screen w/o logic From eb243ed3ed9cb9b6f2be418ee784378e7ab2056e Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Fri, 23 Jan 2026 19:56:42 +0100 Subject: [PATCH 04/23] add navigation when tapping "log out" in settings screen --- lib/settings/settings_screen.dart | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/settings/settings_screen.dart b/lib/settings/settings_screen.dart index dca6344..e930609 100644 --- a/lib/settings/settings_screen.dart +++ b/lib/settings/settings_screen.dart @@ -45,7 +45,16 @@ class SettingsScreen extends StatelessWidget { ListTile( leading: const Icon(Icons.logout_outlined), title: const Text('Log out'), +<<<<<<< ours onTap: () => _onLogOutTap(context), +||||||| ancestor + onTap: () {}, +======= + onTap: () { + // TODO(marfavi): Implement actual logout logic + context.go('/login'); + }, +>>>>>>> theirs ), const Gap(24), const SectionTitle('About'), From d2b3edf65fd467ebb7733e2b1002b2fb1d00b99a Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Fri, 23 Jan 2026 20:36:53 +0100 Subject: [PATCH 05/23] avoid overflow on large zoom in login screen & move loading overlay to new file --- lib/login/login_screen.dart | 79 +++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 lib/login/login_screen.dart diff --git a/lib/login/login_screen.dart b/lib/login/login_screen.dart new file mode 100644 index 0000000..4c6dcc5 --- /dev/null +++ b/lib/login/login_screen.dart @@ -0,0 +1,79 @@ +import 'dart:async'; + +import 'package:cafe_analog_app/core/loading_overlay.dart'; +import 'package:cafe_analog_app/core/widgets/form.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + + return Scaffold( + body: Stack( + children: [ + // Subtle background graphic + Positioned.fill( + child: Opacity( + opacity: 0.075, + child: Image.asset('assets/images/beans_half.png', fit: .cover), + ), + ), + // Main content + SafeArea( + child: Column( + children: [ + Flexible( + child: Center( + child: FittedBox( + fit: .scaleDown, + child: Column( + mainAxisSize: .min, + children: [ + Text( + 'Café Analog', + style: textTheme.headlineLarge?.copyWith( + fontWeight: .w900, + ), + ), + Text( + 'Enter your email to continue', + style: textTheme.bodyMedium, + ), + ], + ), + ), + ), + ), + AnalogForm( + inputType: .email, + labelText: 'Your email', + submitText: 'Continue', + errorMessage: 'Enter a valid email', + onSubmit: (email) async { + showLoadingOverlay(context); + // TODO(marfavi): Implement actual login logic + await Future.delayed(const Duration(seconds: 2)); + if (context.mounted) { + context + ..pop() + ..go('/tickets'); + } + }, + ), + ], + ), + ), + ], + ), + ); + } +} From b4615706048abb8fdc470c130fa9af5d460ccbaa Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Fri, 23 Jan 2026 20:41:55 +0100 Subject: [PATCH 06/23] use flexfit.tight instead of center widget --- lib/login/secret_page.dart | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 lib/login/secret_page.dart diff --git a/lib/login/secret_page.dart b/lib/login/secret_page.dart new file mode 100644 index 0000000..f48c78b --- /dev/null +++ b/lib/login/secret_page.dart @@ -0,0 +1,16 @@ +import 'package:cafe_analog_app/core/widgets/app_bar.dart'; +import 'package:flutter/material.dart'; + +class SecretScreen extends StatelessWidget { + const SecretScreen({required this.id, super.key}); + + final String id; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: const AnalogAppBar(title: 'OMG'), + body: Center(child: Text('SECRET PAGE!!! $id')), + ); + } +} From ddcb578e6357e1b485733c54d56d4c66b21ef450 Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Mon, 26 Jan 2026 14:04:24 +0100 Subject: [PATCH 07/23] depend on flutter_secure_storage package From 7f1e814ab8a2c3053aed72a3aaf80dbb9b49822d Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Mon, 26 Jan 2026 14:28:24 +0100 Subject: [PATCH 08/23] overhaul login/auth --- lib/app/dependencies_provider.dart | 55 ++++ lib/app/router.dart | 278 ++++++++++++++++++ lib/login/bloc/authentication_cubit.dart | 97 ++++++ lib/login/bloc/authentication_state.dart | 8 - lib/login/data/authentication_repository.dart | 64 ++++ lib/login/data/authentication_tokens.dart | 12 + lib/login/data/login_repository.dart | 43 +++ lib/login/login_screen.dart | 79 ----- lib/login/secret_page.dart | 16 - lib/login/ui/auth_navigator.dart | 72 +++++ lib/login/ui/login_screen.dart | 70 +++++ lib/login/ui/verify_magic_link_screen.dart | 41 +++ lib/settings/settings_screen.dart | 37 ++- 13 files changed, 760 insertions(+), 112 deletions(-) create mode 100644 lib/login/data/authentication_repository.dart delete mode 100644 lib/login/login_screen.dart delete mode 100644 lib/login/secret_page.dart create mode 100644 lib/login/ui/auth_navigator.dart diff --git a/lib/app/dependencies_provider.dart b/lib/app/dependencies_provider.dart index dd57090..42b4713 100644 --- a/lib/app/dependencies_provider.dart +++ b/lib/app/dependencies_provider.dart @@ -54,3 +54,58 @@ class DependenciesProvider extends StatelessWidget { ); } } +import 'dart:async'; + +import 'package:cafe_analog_app/core/http_client.dart'; +import 'package:cafe_analog_app/core/network_request_executor.dart'; +import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; +import 'package:cafe_analog_app/login/data/authentication_repository.dart'; +import 'package:cafe_analog_app/login/data/login_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:logger/logger.dart'; + +/// Provides the dependencies required throughout the app. +class DependenciesProvider extends StatelessWidget { + const DependenciesProvider({required this.child, super.key}); + + final MaterialApp child; + + @override + Widget build(BuildContext context) { + return MultiRepositoryProvider( + providers: [ + RepositoryProvider(create: (_) => const FlutterSecureStorage()), + RepositoryProvider.value(value: apiV1), + RepositoryProvider.value(value: apiV2), + RepositoryProvider(create: (_) => Logger()), + RepositoryProvider( + create: (context) => NetworkRequestExecutor(logger: context.read()), + ), + RepositoryProvider( + create: (context) => + LoginRepository(apiV2: context.read(), executor: context.read()), + ), + RepositoryProvider( + create: (context) => AuthRepository(secureStorage: context.read()), + ), + ], + child: MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) { + final authCubit = AuthCubit( + authRepository: context.read(), + loginRepository: context.read(), + ); + unawaited(authCubit.start()); + return authCubit; + }, + ), + ], + child: child, + ), + ); + } +} diff --git a/lib/app/router.dart b/lib/app/router.dart index ac5dbe7..6ca869c 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -276,3 +276,281 @@ class AnalogGoRouter { return const Allow(); } } +import 'dart:async'; + +import 'package:cafe_analog_app/app/navigation_scaffolds.dart'; +import 'package:cafe_analog_app/app/splash_screen.dart'; +import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; +import 'package:cafe_analog_app/login/ui/verify_magic_link_screen.dart'; +import 'package:cafe_analog_app/login/ui/auth_navigator.dart'; +import 'package:cafe_analog_app/login/ui/email_sent_screen.dart'; +import 'package:cafe_analog_app/login/ui/login_screen.dart'; +import 'package:cafe_analog_app/receipts/receipts_screen.dart'; +import 'package:cafe_analog_app/redeem_voucher/redeem_voucher_screen.dart'; +import 'package:cafe_analog_app/settings/settings_screen.dart'; +import 'package:cafe_analog_app/settings/your_profile_screen.dart'; +import 'package:cafe_analog_app/stats/view/stats_screen.dart'; +import 'package:cafe_analog_app/tickets/buy_tickets/buy_tickets_screen.dart'; +import 'package:cafe_analog_app/tickets/buy_tickets/product.dart'; +import 'package:cafe_analog_app/tickets/buy_tickets/ticket_detail_screen.dart'; +import 'package:cafe_analog_app/tickets/my_tickets/tickets_screen.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +class AnalogGoRouter { + AnalogGoRouter._internal(); + + static final AnalogGoRouter instance = AnalogGoRouter._internal(); + + late final goRouter = GoRouter( + initialLocation: '/', + debugLogDiagnostics: kDebugMode, + routes: routes, + onEnter: onEnter, + redirect: redirect, + ); + + late final routes = [ + // Root shell that listens to auth state changes + ShellRoute( + builder: (_, _, child) => AuthNavigator(child: child), + routes: [ + // Splash screen shown at app start + GoRoute( + path: '/', + pageBuilder: (_, _) => const NoTransitionPage(child: SplashScreen()), + ), + GoRoute( + path: '/login', + pageBuilder: (_, _) => CustomTransitionPage( + child: const LoginScreen(), + transitionsBuilder: (_, animation, _, child) { + return FadeTransition(opacity: animation, child: child); + }, + ), + routes: [ + GoRoute( + path: 'email-sent', + pageBuilder: (_, state) { + final email = state.uri.queryParameters['email'] ?? ''; + return MaterialPage(child: EmailSentScreen(email: email)); + }, + ), + GoRoute( + path: 'auth/:token', + pageBuilder: (_, state) => CustomTransitionPage( + child: VerifyMagicLinkScreen( + magicLinkToken: state.pathParameters['token']!, + ), + transitionsBuilder: (_, animation, _, child) { + return FadeTransition(opacity: animation, child: child); + }, + ), + ), + ], + ), + GoRoute( + path: '/verify-mobilepay/:id', + pageBuilder: (_, state) => MaterialPage( + // TODO(marfavi): Implement MobilePay verification screen + child: Container(), + ), + ), + StatefulShellRoute.indexedStack( + // fade in the main scaffold (doesn't affect branch transitions) + pageBuilder: (_, _, shell) => CustomTransitionPage( + child: ScaffoldWithNestedNavigation(navigationShell: shell), + transitionsBuilder: (_, animation, _, child) { + return FadeTransition(opacity: animation, child: child); + }, + ), + branches: [ + StatefulShellBranch( + routes: [ + GoRoute( + path: '/tickets', + pageBuilder: (context, state) => const NoTransitionPage( + child: TicketsScreen(), + ), + routes: [ + GoRoute( + path: 'buy', + builder: (_, _) => const BuyTicketsScreen(), + routes: [ + GoRoute( + path: 'ticket/:id', + pageBuilder: (context, state) { + // we don't use id here, but in a real app you might + // fetch the product details based on the id + // we pass the whole product via extra + // + // cast state.extra to Product + final product = state.extra! as Product; + return MaterialPage( + fullscreenDialog: true, + child: TicketDetailScreen(product: product), + ); + }, + ), + ], + ), + GoRoute( + path: 'redeem_voucher', + pageBuilder: (context, state) => const MaterialPage( + child: RedeemVoucherScreen(), + ), + ), + ], + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/receipts', + pageBuilder: (context, state) => const NoTransitionPage( + child: ReceiptsScreen(), + ), + routes: [ + GoRoute( + path: 'purchase_receipt/:id', + // TODO(marfavi): Implement receipt screen + builder: (context, state) => Container(), + ), + GoRoute( + path: 'swipe_receipt/:id', + // TODO(marfavi): Implement receipt screen + builder: (context, state) => Container(), + ), + ], + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/stats', + pageBuilder: (context, state) => + const NoTransitionPage(child: StatsScreen()), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/settings', + pageBuilder: (context, state) => + const NoTransitionPage(child: SettingsScreen()), + routes: [ + GoRoute( + path: 'your-profile', + pageBuilder: (context, state) => + const MaterialPage(child: YourProfileScreen()), + ), + ], + ), + ], + ), + ], + ), + ], + ), + ]; + + FutureOr redirect(BuildContext context, GoRouterState state) { + final loc = state.matchedLocation; + final isLoggedIn = context.read().state is AuthAuthenticated; + + // User is going anywhere within [/login, /login/email-sent, /login/auth/] + final goingToLoginFlow = loc.startsWith('/login'); + + // User is specifically accessing the app via a magic link (/login/auth/) + final goingToAuthenticate = loc.startsWith('/login/auth/'); + + // User is starting the app + final isStartingApp = loc == '/'; + + // If not logged in, always go to login unless already going there + // (or starting the app, which will handle redirection itself) + if (!isLoggedIn && + !goingToLoginFlow && + !goingToAuthenticate && + !isStartingApp) { + if (kDebugMode) { + print('Redirecting to /login'); + } + return '/login'; + } + + // If logged in and accessing app via login deep link, redirect to main app + if (isLoggedIn && goingToAuthenticate) { + // Show a snackbar after the frame is rendered + WidgetsBinding.instance.addPostFrameCallback((_) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('You are already logged in.')), + ); + }); + return '/tickets'; + } + + // If logged in and going to login, redirect to main app + if (isLoggedIn && goingToLoginFlow) { + return '/tickets'; + } + + // No need to redirect at all + return null; + } + + FutureOr onEnter( + BuildContext context, + GoRouterState currentState, + GoRouterState nextState, + GoRouter goRouter, + ) { + final currentLoc = currentState.matchedLocation; + final nextLoc = nextState.matchedLocation; + final isLoggedIn = context.read().state is AuthAuthenticated; + + // User is going anywhere within [/login, /login/email-sent, /login/auth/] + final goingToLoginFlow = nextLoc.startsWith('/login'); + + // User is starting the app + final isStartingApp = nextLoc == '/'; + + // We consider the 'main' app sections to be the branches under the shell. + final isInMainArea = + currentLoc.startsWith('/tickets') || + currentLoc.startsWith('/receipts') || + currentLoc.startsWith('/stats') || + currentLoc.startsWith('/settings'); + + // If the user is in the main app area and trying to go to the login flow + // while already logged in, block the navigation and show a snackbar. + if (isLoggedIn && goingToLoginFlow && isInMainArea) { + if (kDebugMode) { + print('Navigation to $nextLoc blocked: already logged in.'); + } + return Block.then( + () => ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('You are already logged in.')), + ), + ); + } + // If the user is not logged in and trying to go to the main app area, + // block the navigation and show a snackbar. + if (!isLoggedIn && !goingToLoginFlow && !isStartingApp) { + if (kDebugMode) { + print('Navigation to $nextLoc blocked: not logged in.'); + } + return Block.then( + () => ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please log in to continue.')), + ), + ); + } + return const Allow(); + } +} diff --git a/lib/login/bloc/authentication_cubit.dart b/lib/login/bloc/authentication_cubit.dart index f89bc82..94ff38e 100644 --- a/lib/login/bloc/authentication_cubit.dart +++ b/lib/login/bloc/authentication_cubit.dart @@ -96,3 +96,100 @@ class AuthCubit extends Cubit { throw UnimplementedError(); } } +import 'package:bloc/bloc.dart'; +import 'package:cafe_analog_app/login/data/authentication_repository.dart'; +import 'package:cafe_analog_app/login/data/authentication_tokens.dart'; +import 'package:cafe_analog_app/login/data/login_repository.dart'; +import 'package:equatable/equatable.dart'; + +part 'authentication_state.dart'; + +/// Cubit responsible for managing authentication state. +/// +/// It handles login, logout, token refresh, and emits appropriate +/// states based on the authentication status. These states are used by +/// +class AuthCubit extends Cubit { + AuthCubit({ + required AuthRepository authRepository, + required LoginRepository loginRepository, + }) : _authRepository = authRepository, + _loginRepository = loginRepository, + super(const AuthInitial()); + + final AuthRepository _authRepository; + final LoginRepository _loginRepository; + + /// Check current authentication status and emit appropriate state. + Future start() async { + emit(const AuthLoading()); + final newState = await _authRepository + .getTokens() + .match( + (failure) => AuthFailure(reason: failure.reason), + (maybeTokens) => maybeTokens.match( + AuthUnauthenticated.new, // on none + (tokens) => AuthAuthenticated(tokens: tokens), // on some + ), + ) + .run(); + emit(newState); + } + + /// Log the user out and clear stored tokens. + Future logOut() async { + emit(const AuthLoading()); + final newState = await _authRepository + .clearTokens() + .match( + (failure) => AuthFailure(reason: failure.reason), + (_) => const AuthUnauthenticated(), + ) + .run(); + emit(newState); + } + + /// The user has requested a login magic link to be sent. + Future sendLoginLink({required String email}) async { + emit(const AuthLoading()); + final newState = await _loginRepository + .requestMagicLink(email) + .match( + (failure) => AuthFailure(reason: failure.reason), + (_) => AuthEmailSent(email: email), + ) + .run(); + emit(newState); + } + + /// Authorize the user with the token provided from the magic link. + Future authorizeWithToken({required String magicLinkToken}) async { + emit(const AuthLoading()); + + // Exchange the magic link token for auth tokens. + final authorizeEither = await _loginRepository + .authorizeWithToken(magicLinkToken) + .run(); + + // If authorization failed, emit failure. + // Otherwise save tokens and emit authenticated. + final newState = await authorizeEither.match( + (didNotAuth) async => AuthFailure(reason: didNotAuth.reason), + (tokens) async { + final saveEither = await _authRepository.saveTokens(tokens).run(); + return saveEither.match( + (couldNotSave) => AuthFailure(reason: couldNotSave.reason), + (savedTokens) => AuthAuthenticated(tokens: savedTokens), + ); + }, + ); + + emit(newState); + } + + /// Refresh the JWT token. + Future refreshToken({required AuthTokens tokens}) async { + // FIXME(marfavi): implement token refresh logic + throw UnimplementedError(); + } +} diff --git a/lib/login/bloc/authentication_state.dart b/lib/login/bloc/authentication_state.dart index 8bcb8bc..e49bbd9 100644 --- a/lib/login/bloc/authentication_state.dart +++ b/lib/login/bloc/authentication_state.dart @@ -7,18 +7,14 @@ sealed class AuthState extends Equatable { List get props => []; } -/// The cubit was initialized but no action has been taken yet. final class AuthInitial extends AuthState { const AuthInitial(); } -/// Some action is happening (such as requesting a magic link, clearing saved -/// tokens from local storage, etc). final class AuthLoading extends AuthState { const AuthLoading(); } -/// The user is successfully authenticated. final class AuthAuthenticated extends AuthState { const AuthAuthenticated({required this.tokens}); final AuthTokens tokens; @@ -27,7 +23,6 @@ final class AuthAuthenticated extends AuthState { List get props => [tokens]; } -/// A login magic link has been sent to the user's email. final class AuthEmailSent extends AuthState { const AuthEmailSent({required this.email}); final String email; @@ -36,13 +31,10 @@ final class AuthEmailSent extends AuthState { List get props => [email]; } -/// The user is not authenticated. final class AuthUnauthenticated extends AuthState { const AuthUnauthenticated(); } -/// Some failure occurred during an authentication-related operation (such as -/// requesting a magic link, clearing saved tokens from local storage, etc). final class AuthFailure extends AuthState { const AuthFailure({required this.reason}); final String reason; diff --git a/lib/login/data/authentication_repository.dart b/lib/login/data/authentication_repository.dart new file mode 100644 index 0000000..97d1985 --- /dev/null +++ b/lib/login/data/authentication_repository.dart @@ -0,0 +1,64 @@ +import 'package:cafe_analog_app/core/failures.dart'; +import 'package:cafe_analog_app/login/data/authentication_tokens.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:fpdart/fpdart.dart'; + +/// Handles storing and retrieving JWT and refresh tokens securely. +class AuthRepository { + AuthRepository({required FlutterSecureStorage secureStorage}) + : _secureStorage = secureStorage; + + final FlutterSecureStorage _secureStorage; + + static const _jwtKey = 'jwt_token'; + static const _refreshTokenKey = 'refresh_token'; + + /// Saves authentication tokens securely. + /// + /// Returns the saved tokens on success. + TaskEither saveTokens(AuthTokens tokens) { + return TaskEither.tryCatch( + () async { + await Future.wait([ + _secureStorage.write(key: _jwtKey, value: tokens.jwt), + _secureStorage.write( + key: _refreshTokenKey, + value: tokens.refreshToken, + ), + ]); + return tokens; + }, + (error, _) => LocalStorageFailure('Failed to save auth tokens: $error'), + ); + } + + /// Retrieves the authentication tokens, if they exist. + TaskEither> getTokens() { + return TaskEither.tryCatch( + () async { + final jwt = await _secureStorage.read(key: _jwtKey); + final refreshToken = await _secureStorage.read(key: _refreshTokenKey); + if (jwt != null && refreshToken != null) { + return some(AuthTokens(jwt: jwt, refreshToken: refreshToken)); + } + return none(); + }, + (error, _) => + LocalStorageFailure('Failed to retrieve auth tokens: $error'), + ); + } + + /// Clears all authentication tokens (logout). + TaskEither clearTokens() { + return TaskEither.tryCatch( + () async { + await Future.wait([ + _secureStorage.delete(key: _jwtKey), + _secureStorage.delete(key: _refreshTokenKey), + ]); + return unit; + }, + (error, _) => LocalStorageFailure('Failed to clear auth tokens: $error'), + ); + } +} diff --git a/lib/login/data/authentication_tokens.dart b/lib/login/data/authentication_tokens.dart index 58262ca..8d72001 100644 --- a/lib/login/data/authentication_tokens.dart +++ b/lib/login/data/authentication_tokens.dart @@ -1,5 +1,17 @@ import 'package:equatable/equatable.dart'; +/// Represents authentication tokens received after a successful login. +class AuthTokens extends Equatable { + const AuthTokens({required this.jwt, required this.refreshToken}); + + final String jwt; + final String refreshToken; + + @override + List get props => [jwt, refreshToken]; +} +import 'package:equatable/equatable.dart'; + /// Represents authentication tokens received after a successful authentication. class AuthTokens extends Equatable { const AuthTokens({required this.jwt, required this.refreshToken}); diff --git a/lib/login/data/login_repository.dart b/lib/login/data/login_repository.dart index 35e0347..e4d0997 100644 --- a/lib/login/data/login_repository.dart +++ b/lib/login/data/login_repository.dart @@ -6,6 +6,49 @@ import 'package:cafe_analog_app/generated/api/coffeecard_api_v2.models.swagger.d import 'package:cafe_analog_app/login/data/authentication_tokens.dart'; import 'package:fpdart/fpdart.dart'; +/// Handles data operations related to user login. +class LoginRepository { + const LoginRepository({ + required CoffeecardApiV2 apiV2, + required NetworkRequestExecutor executor, + }) : _apiV2 = apiV2, + _executor = executor; + + final CoffeecardApiV2 _apiV2; + final NetworkRequestExecutor _executor; + + /// Requests a magic link to be sent to the provided email. + TaskEither requestMagicLink(String email) { + final request = UserLoginRequest( + email: email, + loginType: LoginType.app.value, + ); + return _executor + .execute(() => _apiV2.accountLoginPost(body: request)) + .map((_) => unit); + } + + /// Authorizes the user with the provided magic link token. + TaskEither authorizeWithToken(String token) { + final request = TokenLoginRequest(token: token); + return _executor + .execute(() => _apiV2.accountAuthPost(body: request)) + .map( + (response) => AuthTokens( + jwt: response.jwt, + refreshToken: response.refreshToken, + ), + ); + } +} +import 'package:cafe_analog_app/core/failures.dart'; +import 'package:cafe_analog_app/core/network_request_executor.dart'; +import 'package:cafe_analog_app/generated/api/client_index.dart'; +import 'package:cafe_analog_app/generated/api/coffeecard_api_v2.enums.swagger.dart'; +import 'package:cafe_analog_app/generated/api/coffeecard_api_v2.models.swagger.dart'; +import 'package:cafe_analog_app/login/data/authentication_tokens.dart'; +import 'package:fpdart/fpdart.dart'; + /// Handles data operations related to user login. class LoginRepository { const LoginRepository({ diff --git a/lib/login/login_screen.dart b/lib/login/login_screen.dart deleted file mode 100644 index 4c6dcc5..0000000 --- a/lib/login/login_screen.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'dart:async'; - -import 'package:cafe_analog_app/core/loading_overlay.dart'; -import 'package:cafe_analog_app/core/widgets/form.dart'; -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; - -class LoginScreen extends StatefulWidget { - const LoginScreen({super.key}); - - @override - State createState() => _LoginScreenState(); -} - -class _LoginScreenState extends State { - @override - Widget build(BuildContext context) { - final textTheme = Theme.of(context).textTheme; - - return Scaffold( - body: Stack( - children: [ - // Subtle background graphic - Positioned.fill( - child: Opacity( - opacity: 0.075, - child: Image.asset('assets/images/beans_half.png', fit: .cover), - ), - ), - // Main content - SafeArea( - child: Column( - children: [ - Flexible( - child: Center( - child: FittedBox( - fit: .scaleDown, - child: Column( - mainAxisSize: .min, - children: [ - Text( - 'Café Analog', - style: textTheme.headlineLarge?.copyWith( - fontWeight: .w900, - ), - ), - Text( - 'Enter your email to continue', - style: textTheme.bodyMedium, - ), - ], - ), - ), - ), - ), - AnalogForm( - inputType: .email, - labelText: 'Your email', - submitText: 'Continue', - errorMessage: 'Enter a valid email', - onSubmit: (email) async { - showLoadingOverlay(context); - // TODO(marfavi): Implement actual login logic - await Future.delayed(const Duration(seconds: 2)); - if (context.mounted) { - context - ..pop() - ..go('/tickets'); - } - }, - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/lib/login/secret_page.dart b/lib/login/secret_page.dart deleted file mode 100644 index f48c78b..0000000 --- a/lib/login/secret_page.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:cafe_analog_app/core/widgets/app_bar.dart'; -import 'package:flutter/material.dart'; - -class SecretScreen extends StatelessWidget { - const SecretScreen({required this.id, super.key}); - - final String id; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: const AnalogAppBar(title: 'OMG'), - body: Center(child: Text('SECRET PAGE!!! $id')), - ); - } -} diff --git a/lib/login/ui/auth_navigator.dart b/lib/login/ui/auth_navigator.dart new file mode 100644 index 0000000..937c8a8 --- /dev/null +++ b/lib/login/ui/auth_navigator.dart @@ -0,0 +1,72 @@ +import 'package:cafe_analog_app/core/loading_overlay.dart'; +import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +/// A widget that listens to authentication state changes from [AuthCubit] +/// and performs app-wide navigation and UI side effects: +/// - shows/hides a loading overlay while `AuthLoading` is emitted +/// - navigates to `/tickets`, `/login/email-sent`, or `/login` based on state +/// - displays a `SnackBar` when `AuthFailure` occurs with the failure reason +class AuthNavigator extends StatefulWidget { + const AuthNavigator({required this.child, super.key}); + + final Widget child; + + @override + State createState() => _AuthNavigatorState(); +} + +class _AuthNavigatorState extends State { + var _overlayVisible = false; + + void _showOverlay() { + if (!_overlayVisible) { + _overlayVisible = true; + showLoadingOverlay(context); + } + } + + void _hideOverlay() { + if (_overlayVisible && + Navigator.of(context, rootNavigator: true).canPop()) { + Navigator.of(context, rootNavigator: true).pop(); + _overlayVisible = false; + } + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) { + // Manage loading overlay reactively + if (state is AuthLoading) { + _showOverlay(); + } else { + _hideOverlay(); + } + + switch (state) { + case AuthAuthenticated(): + context.go('/tickets'); + case AuthEmailSent(): + context.go('/login/email-sent?email=${state.email}'); + case AuthUnauthenticated(): + context.go('/login'); + case AuthFailure(): + context.go('/login'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Authentication failed: ${state.reason}'), + ), + ); + case AuthLoading() || AuthInitial(): + // Do nothing + return; + } + }, + child: widget.child, + ); + } +} diff --git a/lib/login/ui/login_screen.dart b/lib/login/ui/login_screen.dart index e056820..37e47b2 100644 --- a/lib/login/ui/login_screen.dart +++ b/lib/login/ui/login_screen.dart @@ -10,6 +10,76 @@ class LoginScreen extends StatefulWidget { State createState() => _LoginScreenState(); } +class _LoginScreenState extends State { + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + + return Scaffold( + body: Stack( + children: [ + // Subtle background graphic + Positioned.fill( + child: Opacity( + opacity: 0.075, + child: Image.asset('assets/images/beans_half.png', fit: .cover), + ), + ), + // Main content + SafeArea( + child: Column( + children: [ + Flexible( + child: Center( + child: FittedBox( + fit: .scaleDown, + child: Column( + mainAxisSize: .min, + children: [ + Text( + 'Café Analog', + style: textTheme.headlineLarge?.copyWith( + fontWeight: .w900, + ), + ), + Text( + 'Enter your email to continue', + style: textTheme.bodyMedium, + ), + ], + ), + ), + ), + ), + AnalogForm( + inputType: .email, + labelText: 'Your email', + submitText: 'Continue', + errorMessage: 'Enter a valid email', + onSubmit: (email) async { + await context.read().sendLoginLink(email: email); + }, + ), + ], + ), + ), + ], + ), + ); + } +} +import 'package:cafe_analog_app/core/widgets/form.dart'; +import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + + @override + State createState() => _LoginScreenState(); +} + class _LoginScreenState extends State { @override Widget build(BuildContext context) { diff --git a/lib/login/ui/verify_magic_link_screen.dart b/lib/login/ui/verify_magic_link_screen.dart index 294f172..60f8b80 100644 --- a/lib/login/ui/verify_magic_link_screen.dart +++ b/lib/login/ui/verify_magic_link_screen.dart @@ -4,6 +4,47 @@ import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +/// Screen displayed when the app is opened via a magic link. +/// +/// Blank screen, only responsible for initiating the authorization +/// process using the provided magic link token. +class VerifyMagicLinkScreen extends StatefulWidget { + const VerifyMagicLinkScreen({required this.magicLinkToken, super.key}); + + final String magicLinkToken; + + @override + State createState() => _VerifyMagicLinkScreenState(); +} + +class _VerifyMagicLinkScreenState extends State { + @override + void initState() { + super.initState(); + + // Start authorization process with the provided magic link token + unawaited( + context.read().authorizeWithToken( + magicLinkToken: widget.magicLinkToken, + ), + ); + } + + @override + Widget build(BuildContext context) { + // Prevent back navigation during the authentication process + return const PopScope( + canPop: false, + child: Scaffold(), + ); + } +} +import 'dart:async'; + +import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + /// Screen displayed when the app is opened via a magic link. /// /// Blank screen, only responsible for initiating the authentication diff --git a/lib/settings/settings_screen.dart b/lib/settings/settings_screen.dart index e930609..30ab9b8 100644 --- a/lib/settings/settings_screen.dart +++ b/lib/settings/settings_screen.dart @@ -3,9 +3,11 @@ import 'dart:async'; import 'package:cafe_analog_app/core/widgets/screen.dart'; import 'package:cafe_analog_app/core/widgets/section_title.dart'; import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; +import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; @@ -45,16 +47,7 @@ class SettingsScreen extends StatelessWidget { ListTile( leading: const Icon(Icons.logout_outlined), title: const Text('Log out'), -<<<<<<< ours onTap: () => _onLogOutTap(context), -||||||| ancestor - onTap: () {}, -======= - onTap: () { - // TODO(marfavi): Implement actual logout logic - context.go('/login'); - }, ->>>>>>> theirs ), const Gap(24), const SectionTitle('About'), @@ -125,4 +118,30 @@ class SettingsScreen extends StatelessWidget { if (!context.mounted) return; await context.read().logOut(); } + + Future _onLogOutTap(BuildContext context) async { + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text( + 'Log out', + style: TextStyle(fontWeight: .bold), + ), + content: const Text('Are you sure you want to log out?'), + actions: [ + TextButton( + onPressed: () => context.pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => context.pop(true), + child: const Text('Log out'), + ), + ], + ), + ); + if (confirm != true) return; + if (!context.mounted) return; + await context.read().logOut(); + } } From 1ac99377ed91b1732b80ed5e88cc8c5bc43f456b Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Mon, 26 Jan 2026 14:31:11 +0100 Subject: [PATCH 09/23] add tests --- test/authentication_cubit_test.dart | 130 +++++++++++++ test/router_test.dart | 273 ++++++++++++++++++++++++++++ 2 files changed, 403 insertions(+) diff --git a/test/authentication_cubit_test.dart b/test/authentication_cubit_test.dart index 26c66f2..a801b20 100644 --- a/test/authentication_cubit_test.dart +++ b/test/authentication_cubit_test.dart @@ -1,5 +1,135 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; +import 'package:cafe_analog_app/login/data/authentication_repository.dart'; +import 'package:cafe_analog_app/login/data/authentication_tokens.dart'; +import 'package:cafe_analog_app/login/data/login_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockAuthRepository extends Mock implements AuthRepository {} + +class _MockLoginRepository extends Mock implements LoginRepository {} + +void main() { + late _MockAuthRepository authRepository; + late _MockLoginRepository loginRepository; + + setUp(() { + authRepository = _MockAuthRepository(); + loginRepository = _MockLoginRepository(); + }); + + group('AuthCubit', () { + blocTest( + 'emits [LoadInProgress, Authenticated] when started ' + 'and AuthRepository reports logged in', + build: () { + when(() => authRepository.getTokens()).thenReturn( + TaskEither.right( + some(const AuthTokens(jwt: 'JWT-TOKEN', refreshToken: 'REF')), + ), + ); + return AuthCubit( + authRepository: authRepository, + loginRepository: loginRepository, + ); + }, + act: (cubit) => cubit.start(), + expect: () => [ + isA(), + isA().having( + (s) => s.tokens.jwt, + 'jwt', + 'JWT-TOKEN', + ), + ], + ); + + blocTest( + 'emits [LoadInProgress, Unauthenticated] when started and not logged in', + build: () { + when( + () => authRepository.getTokens(), + ).thenReturn(TaskEither.right(none())); + return AuthCubit( + authRepository: authRepository, + loginRepository: loginRepository, + ); + }, + act: (cubit) => cubit.start(), + expect: () => [ + isA(), + isA(), + ], + ); + + blocTest( + 'emits [LoadInProgress, Authenticated] when authorizeWithToken succeeds', + setUp: () { + when(() => loginRepository.authorizeWithToken('TOKEN')).thenReturn( + TaskEither.right( + const AuthTokens(jwt: 'PROVIDED-JWT', refreshToken: 'REF'), + ), + ); + when( + () => authRepository.saveTokens( + const AuthTokens(jwt: 'PROVIDED-JWT', refreshToken: 'REF'), + ), + ).thenReturn( + TaskEither.right( + const AuthTokens(jwt: 'PROVIDED-JWT', refreshToken: 'REF'), + ), + ); + }, + build: () => AuthCubit( + authRepository: authRepository, + loginRepository: loginRepository, + ), + act: (cubit) => cubit.authorizeWithToken(magicLinkToken: 'TOKEN'), + verify: (_) { + verify(() => loginRepository.authorizeWithToken('TOKEN')).called(1); + verify( + () => authRepository.saveTokens( + const AuthTokens(jwt: 'PROVIDED-JWT', refreshToken: 'REF'), + ), + ).called(1); + }, + expect: () => [ + isA(), + isA().having( + (s) => s.tokens.jwt, + 'jwt', + 'PROVIDED-JWT', + ), + ], + ); + + blocTest( + 'emits [LoadInProgress, Unauthenticated] when logged out ' + 'and clears tokens', + build: () { + when( + () => authRepository.clearTokens(), + ).thenReturn(TaskEither.right(unit)); + return AuthCubit( + authRepository: authRepository, + loginRepository: loginRepository, + ); + }, + act: (cubit) => cubit.logOut(), + verify: (_) { + verify(() => authRepository.clearTokens()).called(1); + }, + expect: () => [ + isA(), + isA(), + ], + ); + }); +} +import 'package:bloc_test/bloc_test.dart'; +import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; import 'package:cafe_analog_app/login/data/authentication_token_repository.dart'; import 'package:cafe_analog_app/login/data/authentication_tokens.dart'; import 'package:cafe_analog_app/login/data/login_repository.dart'; diff --git a/test/router_test.dart b/test/router_test.dart index bfe2053..98695d0 100644 --- a/test/router_test.dart +++ b/test/router_test.dart @@ -1,5 +1,278 @@ import 'dart:async'; +import 'package:bloc_test/bloc_test.dart'; +import 'package:cafe_analog_app/app/router.dart'; +import 'package:cafe_analog_app/core/widgets/analog_circular_progress_indicator.dart'; +import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; +import 'package:cafe_analog_app/login/data/authentication_tokens.dart'; +import 'package:cafe_analog_app/login/ui/auth_navigator.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockAuthCubit extends Mock implements AuthCubit {} + +void main() { + late _MockAuthCubit mockAuth; + late final goRouter = AnalogGoRouter.instance.goRouter; + + setUpAll(() { + registerFallbackValue(const AuthInitial()); + }); + + setUp(() { + mockAuth = _MockAuthCubit(); + }); + + tearDown(() { + // reset router back to root to avoid leaking state between tests + goRouter.go('/'); + }); + + testWidgets( + 'redirects to /login when not logged in', + (tester) async { + when(() => mockAuth.state).thenReturn(const AuthUnauthenticated()); + whenListen( + mockAuth, + Stream.value(const AuthUnauthenticated()), + initialState: const AuthUnauthenticated(), + ); + + await tester.pumpWidget( + BlocProvider.value( + value: mockAuth, + child: MaterialApp.router( + routerConfig: goRouter, + builder: (context, child) => + Scaffold(body: child ?? const SizedBox()), + ), + ), + ); + + // Try to navigate to a protected route + goRouter.go('/tickets'); + // Allow one frame for onEnter to run and show SnackBar without waiting + // for its duration + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + // Navigation is blocked by onEnter; ensure snackbar is shown + expect(find.text('Please log in to continue.'), findsOneWidget); + + // allow any pending timers to clean up + await tester.pump(const Duration(seconds: 1)); + }, + ); + + testWidgets( + 'blocks navigation to /login when already logged in and in main area', + (tester) async { + when(() => mockAuth.state).thenReturn( + const AuthAuthenticated( + tokens: AuthTokens(jwt: 'j', refreshToken: 'r'), + ), + ); + whenListen( + mockAuth, + Stream.value(mockAuth.state), + initialState: mockAuth.state, + ); + + await tester.pumpWidget( + BlocProvider.value( + value: mockAuth, + child: MaterialApp.router( + routerConfig: goRouter, + builder: (context, child) => + Scaffold(body: child ?? const SizedBox()), + ), + ), + ); + + // start at /tickets + goRouter.go('/tickets'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + // attempt to navigate into login + goRouter.go('/login'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + // Tickets screen should still be visible and SnackBar shown + expect(find.text('Tickets'), findsWidgets); + expect(find.text('You are already logged in.'), findsOneWidget); + + // allow any pending timers to clean up + await tester.pump(const Duration(seconds: 1)); + }, + ); + + testWidgets( + 'shows and hides loading overlay based on AuthLoading', + (tester) async { + when(() => mockAuth.state).thenReturn(const AuthInitial()); + + // Stream controller lets us emit states with precise timing. + final ctl = StreamController(); + whenListen( + mockAuth, + ctl.stream, + initialState: const AuthInitial(), + ); + + // Use a fresh local GoRouter so AuthNavigator can call context.go safely + final testRouter = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => const AuthNavigator(child: SizedBox()), + ), + GoRoute( + path: '/login', + builder: (context, state) => const SizedBox(), + ), + GoRoute( + path: '/tickets', + builder: (context, state) => const SizedBox(), + ), + ], + ); + + await tester.pumpWidget( + BlocProvider.value( + value: mockAuth, + child: MaterialApp.router( + routerConfig: testRouter, + builder: (context, child) => + Scaffold(body: child ?? const SizedBox()), + ), + ), + ); + + // Let initial frame build + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + // Count existing modal barriers and indicators + final preModalCount = tester.widgetList(find.byType(ModalBarrier)).length; + final preIndicatorCount = tester + .widgetList(find.byType(AnalogCircularProgressIndicator)) + .length; + + // Emit loading, wait a frame for the dialog to be shown + ctl.add(const AuthLoading()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + // Loading overlay should have added at least one modal barrier/indicator + final postModalCount = tester + .widgetList(find.byType(ModalBarrier)) + .length; + final postIndicatorCount = tester + .widgetList(find.byType(AnalogCircularProgressIndicator)) + .length; + + expect(postModalCount, greaterThan(preModalCount)); + expect(postIndicatorCount, greaterThan(preIndicatorCount)); + + // Emit a non-loading state to dismiss the overlay + ctl.add(const AuthUnauthenticated()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + final finalModalCount = tester + .widgetList(find.byType(ModalBarrier)) + .length; + final finalIndicatorCount = tester + .widgetList(find.byType(AnalogCircularProgressIndicator)) + .length; + + // The modal count might not return exactly to preModalCount due to other + // modals, but the number of indicators should drop compared to when + // loading was shown. + expect(finalIndicatorCount, lessThan(postIndicatorCount)); + expect(finalModalCount, greaterThanOrEqualTo(preModalCount)); + await ctl.close(); + + // allow any pending timers (e.g., DelayedFadeIn) to finish + await tester.pump(const Duration(seconds: 1)); + }, + ); + + testWidgets( + 'shows snackbar on AuthFailure', + (tester) async { + when(() => mockAuth.state).thenReturn(const AuthInitial()); + whenListen( + mockAuth, + Stream.fromIterable([ + const AuthInitial(), + const AuthFailure(reason: 'bad'), + ]), + initialState: const AuthInitial(), + ); + + await tester.pumpWidget( + BlocProvider.value( + value: mockAuth, + child: MaterialApp.router( + routerConfig: goRouter, + builder: (context, child) => + Scaffold(body: child ?? const SizedBox()), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(find.text('Authentication failed: bad'), findsOneWidget); + + // Allow any delayed timers (e.g., DelayedFadeIn) to finish + await tester.pump(const Duration(seconds: 1)); + }, + ); + + testWidgets( + 'navigates to email-sent when AuthEmailSent is emitted', + (tester) async { + when(() => mockAuth.state).thenReturn(const AuthInitial()); + whenListen( + mockAuth, + Stream.fromIterable([ + const AuthInitial(), + const AuthEmailSent(email: 'user@example.com'), + ]), + initialState: const AuthInitial(), + ); + + await tester.pumpWidget( + BlocProvider.value( + value: mockAuth, + child: MaterialApp.router( + routerConfig: goRouter, + builder: (context, child) => + Scaffold(body: child ?? const SizedBox()), + ), + ), + ); + + // Wait for navigation and the screen to settle + await tester.pumpAndSettle(); + + // Verify the EmailSentScreen content is shown + expect(find.text('Check your email'), findsOneWidget); + expect(find.text('user@example.com'), findsOneWidget); + + // Allow any timers (cooldown) to start/finish + await tester.pump(const Duration(seconds: 1)); + }, + ); +} +import 'dart:async'; + import 'package:bloc_test/bloc_test.dart'; import 'package:cafe_analog_app/app/router.dart'; import 'package:cafe_analog_app/core/widgets/analog_circular_progress_indicator.dart'; From 0da59a02440afe1d15c707442666d891578ba7af Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Mon, 26 Jan 2026 14:33:27 +0100 Subject: [PATCH 10/23] change production launch config to target development flavor From c21a38b57445aafe44c5a7d40c9090d3de845cc8 Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Mon, 26 Jan 2026 14:39:38 +0100 Subject: [PATCH 11/23] sort imports From e644addd3a523de145a07aa0b8c2259b8932e64b Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Mon, 26 Jan 2026 14:48:00 +0100 Subject: [PATCH 12/23] authorize -> authenticate --- lib/login/bloc/authentication_cubit.dart | 12 ++++++------ lib/login/data/login_repository.dart | 4 ++-- lib/login/ui/verify_magic_link_screen.dart | 6 +++--- test/authentication_cubit_test.dart | 13 +++++++++---- 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/lib/login/bloc/authentication_cubit.dart b/lib/login/bloc/authentication_cubit.dart index 94ff38e..2f0750a 100644 --- a/lib/login/bloc/authentication_cubit.dart +++ b/lib/login/bloc/authentication_cubit.dart @@ -162,18 +162,18 @@ class AuthCubit extends Cubit { emit(newState); } - /// Authorize the user with the token provided from the magic link. - Future authorizeWithToken({required String magicLinkToken}) async { + /// Authenticate the user with the token provided from the magic link. + Future authenticateWithToken({required String magicLinkToken}) async { emit(const AuthLoading()); // Exchange the magic link token for auth tokens. - final authorizeEither = await _loginRepository - .authorizeWithToken(magicLinkToken) + final authenticateEither = await _loginRepository + .authenticateWithMagicLinkToken(magicLinkToken) .run(); - // If authorization failed, emit failure. + // If authentication failed, emit failure. // Otherwise save tokens and emit authenticated. - final newState = await authorizeEither.match( + final newState = await authenticateEither.match( (didNotAuth) async => AuthFailure(reason: didNotAuth.reason), (tokens) async { final saveEither = await _authRepository.saveTokens(tokens).run(); diff --git a/lib/login/data/login_repository.dart b/lib/login/data/login_repository.dart index e4d0997..1cdfd42 100644 --- a/lib/login/data/login_repository.dart +++ b/lib/login/data/login_repository.dart @@ -28,8 +28,8 @@ class LoginRepository { .map((_) => unit); } - /// Authorizes the user with the provided magic link token. - TaskEither authorizeWithToken(String token) { + /// Authenticates the user with the provided magic link token. + TaskEither authenticateWithMagicLinkToken(String token) { final request = TokenLoginRequest(token: token); return _executor .execute(() => _apiV2.accountAuthPost(body: request)) diff --git a/lib/login/ui/verify_magic_link_screen.dart b/lib/login/ui/verify_magic_link_screen.dart index 60f8b80..3c45846 100644 --- a/lib/login/ui/verify_magic_link_screen.dart +++ b/lib/login/ui/verify_magic_link_screen.dart @@ -6,7 +6,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; /// Screen displayed when the app is opened via a magic link. /// -/// Blank screen, only responsible for initiating the authorization +/// Blank screen, only responsible for initiating the authentication /// process using the provided magic link token. class VerifyMagicLinkScreen extends StatefulWidget { const VerifyMagicLinkScreen({required this.magicLinkToken, super.key}); @@ -22,9 +22,9 @@ class _VerifyMagicLinkScreenState extends State { void initState() { super.initState(); - // Start authorization process with the provided magic link token + // Start authentication process with the provided magic link token unawaited( - context.read().authorizeWithToken( + context.read().authenticateWithToken( magicLinkToken: widget.magicLinkToken, ), ); diff --git a/test/authentication_cubit_test.dart b/test/authentication_cubit_test.dart index a801b20..e291698 100644 --- a/test/authentication_cubit_test.dart +++ b/test/authentication_cubit_test.dart @@ -65,9 +65,12 @@ void main() { ); blocTest( - 'emits [LoadInProgress, Authenticated] when authorizeWithToken succeeds', + 'emits [LoadInProgress, Authenticated] ' + 'when authenticateWithMagicLinkToken succeeds', setUp: () { - when(() => loginRepository.authorizeWithToken('TOKEN')).thenReturn( + when( + () => loginRepository.authenticateWithMagicLinkToken('TOKEN'), + ).thenReturn( TaskEither.right( const AuthTokens(jwt: 'PROVIDED-JWT', refreshToken: 'REF'), ), @@ -86,9 +89,11 @@ void main() { authRepository: authRepository, loginRepository: loginRepository, ), - act: (cubit) => cubit.authorizeWithToken(magicLinkToken: 'TOKEN'), + act: (cubit) => cubit.authenticateWithToken(magicLinkToken: 'TOKEN'), verify: (_) { - verify(() => loginRepository.authorizeWithToken('TOKEN')).called(1); + verify( + () => loginRepository.authenticateWithMagicLinkToken('TOKEN'), + ).called(1); verify( () => authRepository.saveTokens( const AuthTokens(jwt: 'PROVIDED-JWT', refreshToken: 'REF'), From 3e1e42eef96f97f964af52cbada8ee4f0b3c927c Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:09:45 +0100 Subject: [PATCH 13/23] adjust names and doc comments --- lib/app/dependencies_provider.dart | 55 --------- lib/login/bloc/authentication_cubit.dart | 105 +----------------- lib/login/bloc/authentication_state.dart | 8 ++ lib/login/data/authentication_repository.dart | 64 ----------- lib/login/data/authentication_tokens.dart | 2 +- lib/login/ui/auth_navigator.dart | 72 ------------ test/authentication_cubit_test.dart | 36 +++--- test/router_test.dart | 2 +- 8 files changed, 32 insertions(+), 312 deletions(-) delete mode 100644 lib/login/data/authentication_repository.dart delete mode 100644 lib/login/ui/auth_navigator.dart diff --git a/lib/app/dependencies_provider.dart b/lib/app/dependencies_provider.dart index 42b4713..dd57090 100644 --- a/lib/app/dependencies_provider.dart +++ b/lib/app/dependencies_provider.dart @@ -54,58 +54,3 @@ class DependenciesProvider extends StatelessWidget { ); } } -import 'dart:async'; - -import 'package:cafe_analog_app/core/http_client.dart'; -import 'package:cafe_analog_app/core/network_request_executor.dart'; -import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; -import 'package:cafe_analog_app/login/data/authentication_repository.dart'; -import 'package:cafe_analog_app/login/data/login_repository.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:logger/logger.dart'; - -/// Provides the dependencies required throughout the app. -class DependenciesProvider extends StatelessWidget { - const DependenciesProvider({required this.child, super.key}); - - final MaterialApp child; - - @override - Widget build(BuildContext context) { - return MultiRepositoryProvider( - providers: [ - RepositoryProvider(create: (_) => const FlutterSecureStorage()), - RepositoryProvider.value(value: apiV1), - RepositoryProvider.value(value: apiV2), - RepositoryProvider(create: (_) => Logger()), - RepositoryProvider( - create: (context) => NetworkRequestExecutor(logger: context.read()), - ), - RepositoryProvider( - create: (context) => - LoginRepository(apiV2: context.read(), executor: context.read()), - ), - RepositoryProvider( - create: (context) => AuthRepository(secureStorage: context.read()), - ), - ], - child: MultiBlocProvider( - providers: [ - BlocProvider( - create: (context) { - final authCubit = AuthCubit( - authRepository: context.read(), - loginRepository: context.read(), - ); - unawaited(authCubit.start()); - return authCubit; - }, - ), - ], - child: child, - ), - ); - } -} diff --git a/lib/login/bloc/authentication_cubit.dart b/lib/login/bloc/authentication_cubit.dart index 2f0750a..0067555 100644 --- a/lib/login/bloc/authentication_cubit.dart +++ b/lib/login/bloc/authentication_cubit.dart @@ -16,17 +16,17 @@ class AuthCubit extends Cubit { AuthCubit({ required AuthTokenRepository authTokenRepository, required LoginRepository loginRepository, - }) : _authTokenRepository = authTokenRepository, + }) : _authRepository = authTokenRepository, _loginRepository = loginRepository, super(const AuthInitial()); - final AuthTokenRepository _authTokenRepository; + final AuthTokenRepository _authRepository; final LoginRepository _loginRepository; /// Check current authentication status and emit appropriate state. Future start() async { emit(const AuthLoading()); - final newState = await _authTokenRepository + final newState = await _authRepository .getTokens() .match( (couldNotGetTokens) => AuthFailure(reason: couldNotGetTokens.reason), @@ -42,7 +42,7 @@ class AuthCubit extends Cubit { /// Log the user out and clear stored tokens. Future logOut() async { emit(const AuthLoading()); - final newState = await _authTokenRepository + final newState = await _authRepository .clearTokens() .match( (couldNotClear) => AuthFailure(reason: couldNotClear.reason), @@ -65,103 +65,6 @@ class AuthCubit extends Cubit { emit(newState); } - /// Authenticate the user with the token provided from the magic link. - Future authenticateWithToken({required String magicLinkToken}) async { - emit(const AuthLoading()); - - // Exchange the magic link token for auth tokens. - final authenticateEither = await _loginRepository - .authenticateWithMagicLinkToken(magicLinkToken) - .run(); - - // If authentication failed, emit failure. - // Otherwise save tokens and emit authenticated. - final newState = await authenticateEither.match( - (didNotAuth) async => AuthFailure(reason: didNotAuth.reason), - (tokens) async { - final saveEither = await _authTokenRepository.saveTokens(tokens).run(); - return saveEither.match( - (couldNotSave) => AuthFailure(reason: couldNotSave.reason), - (savedTokens) => AuthAuthenticated(tokens: savedTokens), - ); - }, - ); - - emit(newState); - } - - /// Refresh the JWT token. - Future refreshToken({required AuthTokens tokens}) async { - // FIXME(marfavi): implement token refresh logic - throw UnimplementedError(); - } -} -import 'package:bloc/bloc.dart'; -import 'package:cafe_analog_app/login/data/authentication_repository.dart'; -import 'package:cafe_analog_app/login/data/authentication_tokens.dart'; -import 'package:cafe_analog_app/login/data/login_repository.dart'; -import 'package:equatable/equatable.dart'; - -part 'authentication_state.dart'; - -/// Cubit responsible for managing authentication state. -/// -/// It handles login, logout, token refresh, and emits appropriate -/// states based on the authentication status. These states are used by -/// -class AuthCubit extends Cubit { - AuthCubit({ - required AuthRepository authRepository, - required LoginRepository loginRepository, - }) : _authRepository = authRepository, - _loginRepository = loginRepository, - super(const AuthInitial()); - - final AuthRepository _authRepository; - final LoginRepository _loginRepository; - - /// Check current authentication status and emit appropriate state. - Future start() async { - emit(const AuthLoading()); - final newState = await _authRepository - .getTokens() - .match( - (failure) => AuthFailure(reason: failure.reason), - (maybeTokens) => maybeTokens.match( - AuthUnauthenticated.new, // on none - (tokens) => AuthAuthenticated(tokens: tokens), // on some - ), - ) - .run(); - emit(newState); - } - - /// Log the user out and clear stored tokens. - Future logOut() async { - emit(const AuthLoading()); - final newState = await _authRepository - .clearTokens() - .match( - (failure) => AuthFailure(reason: failure.reason), - (_) => const AuthUnauthenticated(), - ) - .run(); - emit(newState); - } - - /// The user has requested a login magic link to be sent. - Future sendLoginLink({required String email}) async { - emit(const AuthLoading()); - final newState = await _loginRepository - .requestMagicLink(email) - .match( - (failure) => AuthFailure(reason: failure.reason), - (_) => AuthEmailSent(email: email), - ) - .run(); - emit(newState); - } - /// Authenticate the user with the token provided from the magic link. Future authenticateWithToken({required String magicLinkToken}) async { emit(const AuthLoading()); diff --git a/lib/login/bloc/authentication_state.dart b/lib/login/bloc/authentication_state.dart index e49bbd9..8bcb8bc 100644 --- a/lib/login/bloc/authentication_state.dart +++ b/lib/login/bloc/authentication_state.dart @@ -7,14 +7,18 @@ sealed class AuthState extends Equatable { List get props => []; } +/// The cubit was initialized but no action has been taken yet. final class AuthInitial extends AuthState { const AuthInitial(); } +/// Some action is happening (such as requesting a magic link, clearing saved +/// tokens from local storage, etc). final class AuthLoading extends AuthState { const AuthLoading(); } +/// The user is successfully authenticated. final class AuthAuthenticated extends AuthState { const AuthAuthenticated({required this.tokens}); final AuthTokens tokens; @@ -23,6 +27,7 @@ final class AuthAuthenticated extends AuthState { List get props => [tokens]; } +/// A login magic link has been sent to the user's email. final class AuthEmailSent extends AuthState { const AuthEmailSent({required this.email}); final String email; @@ -31,10 +36,13 @@ final class AuthEmailSent extends AuthState { List get props => [email]; } +/// The user is not authenticated. final class AuthUnauthenticated extends AuthState { const AuthUnauthenticated(); } +/// Some failure occurred during an authentication-related operation (such as +/// requesting a magic link, clearing saved tokens from local storage, etc). final class AuthFailure extends AuthState { const AuthFailure({required this.reason}); final String reason; diff --git a/lib/login/data/authentication_repository.dart b/lib/login/data/authentication_repository.dart deleted file mode 100644 index 97d1985..0000000 --- a/lib/login/data/authentication_repository.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:cafe_analog_app/core/failures.dart'; -import 'package:cafe_analog_app/login/data/authentication_tokens.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:fpdart/fpdart.dart'; - -/// Handles storing and retrieving JWT and refresh tokens securely. -class AuthRepository { - AuthRepository({required FlutterSecureStorage secureStorage}) - : _secureStorage = secureStorage; - - final FlutterSecureStorage _secureStorage; - - static const _jwtKey = 'jwt_token'; - static const _refreshTokenKey = 'refresh_token'; - - /// Saves authentication tokens securely. - /// - /// Returns the saved tokens on success. - TaskEither saveTokens(AuthTokens tokens) { - return TaskEither.tryCatch( - () async { - await Future.wait([ - _secureStorage.write(key: _jwtKey, value: tokens.jwt), - _secureStorage.write( - key: _refreshTokenKey, - value: tokens.refreshToken, - ), - ]); - return tokens; - }, - (error, _) => LocalStorageFailure('Failed to save auth tokens: $error'), - ); - } - - /// Retrieves the authentication tokens, if they exist. - TaskEither> getTokens() { - return TaskEither.tryCatch( - () async { - final jwt = await _secureStorage.read(key: _jwtKey); - final refreshToken = await _secureStorage.read(key: _refreshTokenKey); - if (jwt != null && refreshToken != null) { - return some(AuthTokens(jwt: jwt, refreshToken: refreshToken)); - } - return none(); - }, - (error, _) => - LocalStorageFailure('Failed to retrieve auth tokens: $error'), - ); - } - - /// Clears all authentication tokens (logout). - TaskEither clearTokens() { - return TaskEither.tryCatch( - () async { - await Future.wait([ - _secureStorage.delete(key: _jwtKey), - _secureStorage.delete(key: _refreshTokenKey), - ]); - return unit; - }, - (error, _) => LocalStorageFailure('Failed to clear auth tokens: $error'), - ); - } -} diff --git a/lib/login/data/authentication_tokens.dart b/lib/login/data/authentication_tokens.dart index 8d72001..4fd30a9 100644 --- a/lib/login/data/authentication_tokens.dart +++ b/lib/login/data/authentication_tokens.dart @@ -1,6 +1,6 @@ import 'package:equatable/equatable.dart'; -/// Represents authentication tokens received after a successful login. +/// Represents authentication tokens received after a successful authentication. class AuthTokens extends Equatable { const AuthTokens({required this.jwt, required this.refreshToken}); diff --git a/lib/login/ui/auth_navigator.dart b/lib/login/ui/auth_navigator.dart deleted file mode 100644 index 937c8a8..0000000 --- a/lib/login/ui/auth_navigator.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'package:cafe_analog_app/core/loading_overlay.dart'; -import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; - -/// A widget that listens to authentication state changes from [AuthCubit] -/// and performs app-wide navigation and UI side effects: -/// - shows/hides a loading overlay while `AuthLoading` is emitted -/// - navigates to `/tickets`, `/login/email-sent`, or `/login` based on state -/// - displays a `SnackBar` when `AuthFailure` occurs with the failure reason -class AuthNavigator extends StatefulWidget { - const AuthNavigator({required this.child, super.key}); - - final Widget child; - - @override - State createState() => _AuthNavigatorState(); -} - -class _AuthNavigatorState extends State { - var _overlayVisible = false; - - void _showOverlay() { - if (!_overlayVisible) { - _overlayVisible = true; - showLoadingOverlay(context); - } - } - - void _hideOverlay() { - if (_overlayVisible && - Navigator.of(context, rootNavigator: true).canPop()) { - Navigator.of(context, rootNavigator: true).pop(); - _overlayVisible = false; - } - } - - @override - Widget build(BuildContext context) { - return BlocListener( - listener: (context, state) { - // Manage loading overlay reactively - if (state is AuthLoading) { - _showOverlay(); - } else { - _hideOverlay(); - } - - switch (state) { - case AuthAuthenticated(): - context.go('/tickets'); - case AuthEmailSent(): - context.go('/login/email-sent?email=${state.email}'); - case AuthUnauthenticated(): - context.go('/login'); - case AuthFailure(): - context.go('/login'); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Authentication failed: ${state.reason}'), - ), - ); - case AuthLoading() || AuthInitial(): - // Do nothing - return; - } - }, - child: widget.child, - ); - } -} diff --git a/test/authentication_cubit_test.dart b/test/authentication_cubit_test.dart index e291698..933ce38 100644 --- a/test/authentication_cubit_test.dart +++ b/test/authentication_cubit_test.dart @@ -1,37 +1,37 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; -import 'package:cafe_analog_app/login/data/authentication_repository.dart'; +import 'package:cafe_analog_app/login/data/authentication_token_repository.dart'; import 'package:cafe_analog_app/login/data/authentication_tokens.dart'; import 'package:cafe_analog_app/login/data/login_repository.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fpdart/fpdart.dart'; import 'package:mocktail/mocktail.dart'; -class _MockAuthRepository extends Mock implements AuthRepository {} +class _MockAuthRepository extends Mock implements AuthTokenRepository {} class _MockLoginRepository extends Mock implements LoginRepository {} void main() { - late _MockAuthRepository authRepository; + late _MockAuthRepository authTokenRepository; late _MockLoginRepository loginRepository; setUp(() { - authRepository = _MockAuthRepository(); + authTokenRepository = _MockAuthRepository(); loginRepository = _MockLoginRepository(); }); group('AuthCubit', () { blocTest( - 'emits [LoadInProgress, Authenticated] when started ' + 'emits [Loading, Authenticated] when started ' 'and AuthRepository reports logged in', build: () { - when(() => authRepository.getTokens()).thenReturn( + when(() => authTokenRepository.getTokens()).thenReturn( TaskEither.right( some(const AuthTokens(jwt: 'JWT-TOKEN', refreshToken: 'REF')), ), ); return AuthCubit( - authRepository: authRepository, + authTokenRepository: authTokenRepository, loginRepository: loginRepository, ); }, @@ -47,13 +47,13 @@ void main() { ); blocTest( - 'emits [LoadInProgress, Unauthenticated] when started and not logged in', + 'emits [Loading, Unauthenticated] when started and not logged in', build: () { when( - () => authRepository.getTokens(), + () => authTokenRepository.getTokens(), ).thenReturn(TaskEither.right(none())); return AuthCubit( - authRepository: authRepository, + authTokenRepository: authTokenRepository, loginRepository: loginRepository, ); }, @@ -65,7 +65,7 @@ void main() { ); blocTest( - 'emits [LoadInProgress, Authenticated] ' + 'emits [Loading, Authenticated] ' 'when authenticateWithMagicLinkToken succeeds', setUp: () { when( @@ -76,7 +76,7 @@ void main() { ), ); when( - () => authRepository.saveTokens( + () => authTokenRepository.saveTokens( const AuthTokens(jwt: 'PROVIDED-JWT', refreshToken: 'REF'), ), ).thenReturn( @@ -86,7 +86,7 @@ void main() { ); }, build: () => AuthCubit( - authRepository: authRepository, + authTokenRepository: authTokenRepository, loginRepository: loginRepository, ), act: (cubit) => cubit.authenticateWithToken(magicLinkToken: 'TOKEN'), @@ -95,7 +95,7 @@ void main() { () => loginRepository.authenticateWithMagicLinkToken('TOKEN'), ).called(1); verify( - () => authRepository.saveTokens( + () => authTokenRepository.saveTokens( const AuthTokens(jwt: 'PROVIDED-JWT', refreshToken: 'REF'), ), ).called(1); @@ -111,20 +111,20 @@ void main() { ); blocTest( - 'emits [LoadInProgress, Unauthenticated] when logged out ' + 'emits [Loading, Unauthenticated] when logged out ' 'and clears tokens', build: () { when( - () => authRepository.clearTokens(), + () => authTokenRepository.clearTokens(), ).thenReturn(TaskEither.right(unit)); return AuthCubit( - authRepository: authRepository, + authTokenRepository: authTokenRepository, loginRepository: loginRepository, ); }, act: (cubit) => cubit.logOut(), verify: (_) { - verify(() => authRepository.clearTokens()).called(1); + verify(() => authTokenRepository.clearTokens()).called(1); }, expect: () => [ isA(), diff --git a/test/router_test.dart b/test/router_test.dart index 98695d0..5448a44 100644 --- a/test/router_test.dart +++ b/test/router_test.dart @@ -5,7 +5,7 @@ import 'package:cafe_analog_app/app/router.dart'; import 'package:cafe_analog_app/core/widgets/analog_circular_progress_indicator.dart'; import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; import 'package:cafe_analog_app/login/data/authentication_tokens.dart'; -import 'package:cafe_analog_app/login/ui/auth_navigator.dart'; +import 'package:cafe_analog_app/login/ui/authentication_navigator.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; From 4aa96a807f1f8fffaaba0290d83138c717a1750f Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Mon, 26 Jan 2026 16:02:07 +0100 Subject: [PATCH 14/23] a rename From 81f47bbda702dd86844659e17c3612f6ad18aa89 Mon Sep 17 00:00:00 2001 From: MonirMooghen Date: Fri, 23 Jan 2026 14:27:31 +0100 Subject: [PATCH 15/23] feat(assets): add new background images From 7c24bd8ec20657e51576c6d3c5b771434de3747a Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Fri, 23 Jan 2026 14:48:02 +0100 Subject: [PATCH 16/23] fix: adjust colour blending options & add half-height version of beans_full.png From 521b39aa14c982380e636d853bb8005b04e4398b Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Fri, 23 Jan 2026 14:52:49 +0100 Subject: [PATCH 17/23] fix: render ink splash above ticket card background From 10eb070e8cc130fdd3e31832087736271c6439bd Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Fri, 23 Jan 2026 20:21:26 +0100 Subject: [PATCH 18/23] fix(use_ticket): put content over splash effect on TicketCardBase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fixes a regression where you couldn’t tap on content in ticket cards From 73f37f6ca38882c843bc5e23d3708ce10a12bbff Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:03:03 +0100 Subject: [PATCH 19/23] refactor(http): simplify NetworkRequestExecutor API (#26) * refactor(http): simplify NetworkRequestExecutor API * move my tickets to ui subfolder (#27) --- lib/app/dependencies_provider.dart | 9 ++++-- lib/app/router.dart | 2 +- lib/core/network_request_executor.dart | 30 +++++++++++++++++-- lib/login/data/login_repository.dart | 10 ++----- .../{ => ui}/depleted_ticket_card.dart | 2 +- .../{ => ui}/my_tickets_section.dart | 6 ++-- .../{ => ui}/no_tickets_placeholder.dart | 0 .../{ => ui}/owned_ticket_card.dart | 2 +- .../my_tickets/{ => ui}/ticket_card_base.dart | 0 .../my_tickets/{ => ui}/tickets_screen.dart | 2 +- lib/tickets/use_ticket/use_ticket_card.dart | 2 +- 11 files changed, 45 insertions(+), 20 deletions(-) rename lib/tickets/my_tickets/{ => ui}/depleted_ticket_card.dart (96%) rename lib/tickets/my_tickets/{ => ui}/my_tickets_section.dart (90%) rename lib/tickets/my_tickets/{ => ui}/no_tickets_placeholder.dart (100%) rename lib/tickets/my_tickets/{ => ui}/owned_ticket_card.dart (94%) rename lib/tickets/my_tickets/{ => ui}/ticket_card_base.dart (100%) rename lib/tickets/my_tickets/{ => ui}/tickets_screen.dart (93%) diff --git a/lib/app/dependencies_provider.dart b/lib/app/dependencies_provider.dart index dd57090..c85b696 100644 --- a/lib/app/dependencies_provider.dart +++ b/lib/app/dependencies_provider.dart @@ -25,11 +25,14 @@ class DependenciesProvider extends StatelessWidget { RepositoryProvider.value(value: apiV2), RepositoryProvider(create: (_) => Logger()), RepositoryProvider( - create: (context) => NetworkRequestExecutor(logger: context.read()), + create: (context) => NetworkRequestExecutor( + logger: context.read(), + apiV1: context.read(), + apiV2: context.read(), + ), ), RepositoryProvider( - create: (context) => - LoginRepository(apiV2: context.read(), executor: context.read()), + create: (context) => LoginRepository(executor: context.read()), ), RepositoryProvider( create: (context) => diff --git a/lib/app/router.dart b/lib/app/router.dart index 6ca869c..426a1b4 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -15,7 +15,7 @@ import 'package:cafe_analog_app/stats/view/stats_screen.dart'; import 'package:cafe_analog_app/tickets/buy_tickets/buy_tickets_screen.dart'; import 'package:cafe_analog_app/tickets/buy_tickets/product.dart'; import 'package:cafe_analog_app/tickets/buy_tickets/ticket_detail_screen.dart'; -import 'package:cafe_analog_app/tickets/my_tickets/tickets_screen.dart'; +import 'package:cafe_analog_app/tickets/my_tickets/ui/tickets_screen.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; diff --git a/lib/core/network_request_executor.dart b/lib/core/network_request_executor.dart index 3ef951d..d8ecb34 100644 --- a/lib/core/network_request_executor.dart +++ b/lib/core/network_request_executor.dart @@ -1,19 +1,37 @@ import 'package:cafe_analog_app/core/failures.dart'; +import 'package:cafe_analog_app/generated/api/client_index.dart'; import 'package:chopper/chopper.dart' show Response; import 'package:fpdart/fpdart.dart'; import 'package:logger/logger.dart'; class NetworkRequestExecutor { - const NetworkRequestExecutor({required this.logger}); + const NetworkRequestExecutor({ + required this.logger, + required this.apiV1, + required this.apiV2, + }); final Logger logger; + final CoffeecardApiV1 apiV1; + final CoffeecardApiV2 apiV2; /// Executes a network request and returns a [TaskEither]. /// /// If the request fails, a [NetworkFailure] is returned in a [Left]. /// If the request succeeds, the response body of type /// [BodyType] is returned in a [Right]. - TaskEither execute( + /// + /// ```dart + /// executor.run((api) => api.v2.accountAuthPost(...)); + /// ``` + TaskEither run( + Future> Function(ApiClients api) request, + ) { + final clients = ApiClients(v1: apiV1, v2: apiV2); + return _execute(() => request(clients)); + } + + TaskEither _execute( Future> Function() request, ) { return TaskEither>.tryCatch( @@ -34,3 +52,11 @@ class NetworkRequestExecutor { ); } } + +/// Small helper that groups generated API clients. +class ApiClients { + const ApiClients({required this.v1, required this.v2}); + + final CoffeecardApiV1 v1; + final CoffeecardApiV2 v2; +} diff --git a/lib/login/data/login_repository.dart b/lib/login/data/login_repository.dart index 1cdfd42..7165687 100644 --- a/lib/login/data/login_repository.dart +++ b/lib/login/data/login_repository.dart @@ -1,6 +1,5 @@ import 'package:cafe_analog_app/core/failures.dart'; import 'package:cafe_analog_app/core/network_request_executor.dart'; -import 'package:cafe_analog_app/generated/api/client_index.dart'; import 'package:cafe_analog_app/generated/api/coffeecard_api_v2.enums.swagger.dart'; import 'package:cafe_analog_app/generated/api/coffeecard_api_v2.models.swagger.dart'; import 'package:cafe_analog_app/login/data/authentication_tokens.dart'; @@ -9,12 +8,9 @@ import 'package:fpdart/fpdart.dart'; /// Handles data operations related to user login. class LoginRepository { const LoginRepository({ - required CoffeecardApiV2 apiV2, required NetworkRequestExecutor executor, - }) : _apiV2 = apiV2, - _executor = executor; + }) : _executor = executor; - final CoffeecardApiV2 _apiV2; final NetworkRequestExecutor _executor; /// Requests a magic link to be sent to the provided email. @@ -24,7 +20,7 @@ class LoginRepository { loginType: LoginType.app.value, ); return _executor - .execute(() => _apiV2.accountLoginPost(body: request)) + .run((api) => api.v2.accountLoginPost(body: request)) .map((_) => unit); } @@ -32,7 +28,7 @@ class LoginRepository { TaskEither authenticateWithMagicLinkToken(String token) { final request = TokenLoginRequest(token: token); return _executor - .execute(() => _apiV2.accountAuthPost(body: request)) + .run((api) => api.v2.accountAuthPost(body: request)) .map( (response) => AuthTokens( jwt: response.jwt, diff --git a/lib/tickets/my_tickets/depleted_ticket_card.dart b/lib/tickets/my_tickets/ui/depleted_ticket_card.dart similarity index 96% rename from lib/tickets/my_tickets/depleted_ticket_card.dart rename to lib/tickets/my_tickets/ui/depleted_ticket_card.dart index b42c953..a3095ff 100644 --- a/lib/tickets/my_tickets/depleted_ticket_card.dart +++ b/lib/tickets/my_tickets/ui/depleted_ticket_card.dart @@ -1,4 +1,4 @@ -import 'package:cafe_analog_app/tickets/my_tickets/ticket_card_base.dart'; +import 'package:cafe_analog_app/tickets/my_tickets/ui/ticket_card_base.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; diff --git a/lib/tickets/my_tickets/my_tickets_section.dart b/lib/tickets/my_tickets/ui/my_tickets_section.dart similarity index 90% rename from lib/tickets/my_tickets/my_tickets_section.dart rename to lib/tickets/my_tickets/ui/my_tickets_section.dart index d7908c6..cef21db 100644 --- a/lib/tickets/my_tickets/my_tickets_section.dart +++ b/lib/tickets/my_tickets/ui/my_tickets_section.dart @@ -1,9 +1,9 @@ import 'dart:async'; import 'package:cafe_analog_app/tickets/buy_tickets/products.dart'; -import 'package:cafe_analog_app/tickets/my_tickets/depleted_ticket_card.dart'; -import 'package:cafe_analog_app/tickets/my_tickets/no_tickets_placeholder.dart'; -import 'package:cafe_analog_app/tickets/my_tickets/owned_ticket_card.dart'; +import 'package:cafe_analog_app/tickets/my_tickets/ui/depleted_ticket_card.dart'; +import 'package:cafe_analog_app/tickets/my_tickets/ui/no_tickets_placeholder.dart'; +import 'package:cafe_analog_app/tickets/my_tickets/ui/owned_ticket_card.dart'; import 'package:cafe_analog_app/tickets/use_ticket/use_ticket_modal.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; diff --git a/lib/tickets/my_tickets/no_tickets_placeholder.dart b/lib/tickets/my_tickets/ui/no_tickets_placeholder.dart similarity index 100% rename from lib/tickets/my_tickets/no_tickets_placeholder.dart rename to lib/tickets/my_tickets/ui/no_tickets_placeholder.dart diff --git a/lib/tickets/my_tickets/owned_ticket_card.dart b/lib/tickets/my_tickets/ui/owned_ticket_card.dart similarity index 94% rename from lib/tickets/my_tickets/owned_ticket_card.dart rename to lib/tickets/my_tickets/ui/owned_ticket_card.dart index dbe5bea..be9f110 100644 --- a/lib/tickets/my_tickets/owned_ticket_card.dart +++ b/lib/tickets/my_tickets/ui/owned_ticket_card.dart @@ -1,4 +1,4 @@ -import 'package:cafe_analog_app/tickets/my_tickets/ticket_card_base.dart'; +import 'package:cafe_analog_app/tickets/my_tickets/ui/ticket_card_base.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; diff --git a/lib/tickets/my_tickets/ticket_card_base.dart b/lib/tickets/my_tickets/ui/ticket_card_base.dart similarity index 100% rename from lib/tickets/my_tickets/ticket_card_base.dart rename to lib/tickets/my_tickets/ui/ticket_card_base.dart diff --git a/lib/tickets/my_tickets/tickets_screen.dart b/lib/tickets/my_tickets/ui/tickets_screen.dart similarity index 93% rename from lib/tickets/my_tickets/tickets_screen.dart rename to lib/tickets/my_tickets/ui/tickets_screen.dart index 4035409..c530681 100644 --- a/lib/tickets/my_tickets/tickets_screen.dart +++ b/lib/tickets/my_tickets/ui/tickets_screen.dart @@ -1,5 +1,5 @@ import 'package:cafe_analog_app/core/widgets/screen.dart'; -import 'package:cafe_analog_app/tickets/my_tickets/my_tickets_section.dart'; +import 'package:cafe_analog_app/tickets/my_tickets/ui/my_tickets_section.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; diff --git a/lib/tickets/use_ticket/use_ticket_card.dart b/lib/tickets/use_ticket/use_ticket_card.dart index 006aa7e..beb3614 100644 --- a/lib/tickets/use_ticket/use_ticket_card.dart +++ b/lib/tickets/use_ticket/use_ticket_card.dart @@ -1,4 +1,4 @@ -import 'package:cafe_analog_app/tickets/my_tickets/ticket_card_base.dart'; +import 'package:cafe_analog_app/tickets/my_tickets/ui/ticket_card_base.dart'; import 'package:cafe_analog_app/tickets/use_ticket/animated_fade_switcher_sized.dart'; import 'package:cafe_analog_app/tickets/use_ticket/next_button.dart'; import 'package:cafe_analog_app/tickets/use_ticket/slide_action.dart'; From 870295d192c3fca42645cd5db85c4c116c935801 Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:22:27 +0100 Subject: [PATCH 20/23] chore: push progress --- Makefile | 6 +- ios/Podfile.lock | 7 + lib/app/app.dart | 23 +- lib/app/bootstrap.dart | 35 --- lib/app/dependencies_provider.dart | 56 +++- lib/app/router.dart | 280 +----------------- lib/app/splash_screen.dart | 2 +- lib/core/http_client.dart | 15 - lib/core/loading_overlay.dart | 2 +- lib/core/network_request_executor.dart | 34 ++- lib/core/network_request_interceptor.dart | 29 ++ lib/core/widgets/app_bar.dart | 1 + lib/core/widgets/delayed_fade_in.dart | 39 +++ lib/login/bloc/authentication_cubit.dart | 2 +- .../data/authentication_token_repository.dart | 13 +- lib/login/data/authentication_tokens.dart | 12 - lib/login/data/login_repository.dart | 56 +--- lib/login/ui/login_screen.dart | 69 ----- lib/login/ui/verify_magic_link_screen.dart | 41 --- lib/main.dart | 37 ++- lib/settings/settings_screen.dart | 28 -- lib/tickets/my_tickets/README | 13 + .../my_tickets/bloc/owned_tickets_cubit.dart | 113 +++++++ .../my_tickets/bloc/owned_tickets_state.dart | 30 ++ lib/tickets/my_tickets/data/owned_ticket.dart | 42 +++ .../owned_tickets_local_data_provider.dart | 44 +++ .../owned_tickets_remote_data_provider.dart | 17 ++ .../data/owned_tickets_repository.dart | 64 ++++ .../data/ticket_order_data_provider.dart | 40 +++ .../my_tickets/my_tickets_section.dart | 74 ----- lib/tickets/my_tickets/tickets_screen.dart | 36 --- .../{ => ui}/depleted_ticket_card.dart | 2 +- .../my_tickets/ui/my_tickets_section.dart | 75 +++++ .../{ => ui}/no_tickets_placeholder.dart | 0 .../{ => ui}/owned_ticket_card.dart | 17 +- .../my_tickets/{ => ui}/ticket_card_base.dart | 0 lib/tickets/my_tickets/ui/tickets_screen.dart | 143 +++++++++ .../ui/animated_fade_switcher_sized.dart | 132 +++++++++ lib/tickets/use_ticket/ui/next_button.dart | 25 ++ .../ui/select_menu_item_content.dart | 58 ++++ lib/tickets/use_ticket/ui/slide_action.dart | 224 ++++++++++++++ .../use_ticket/ui/swipe_ticket_content.dart | 34 +++ .../use_ticket/ui/use_ticket_card.dart | 70 +++++ .../use_ticket/ui/use_ticket_modal.dart | 71 +++++ lib/tickets/use_ticket/use_ticket_card.dart | 2 +- pubspec.lock | 58 +++- pubspec.yaml | 4 +- test/app_test.dart | 9 +- test/authentication_cubit_test.dart | 135 --------- test/router_test.dart | 273 ----------------- 50 files changed, 1507 insertions(+), 1085 deletions(-) delete mode 100644 lib/app/bootstrap.dart delete mode 100644 lib/core/http_client.dart create mode 100644 lib/core/network_request_interceptor.dart create mode 100644 lib/core/widgets/delayed_fade_in.dart create mode 100644 lib/tickets/my_tickets/README create mode 100644 lib/tickets/my_tickets/bloc/owned_tickets_cubit.dart create mode 100644 lib/tickets/my_tickets/bloc/owned_tickets_state.dart create mode 100644 lib/tickets/my_tickets/data/owned_ticket.dart create mode 100644 lib/tickets/my_tickets/data/owned_tickets_local_data_provider.dart create mode 100644 lib/tickets/my_tickets/data/owned_tickets_remote_data_provider.dart create mode 100644 lib/tickets/my_tickets/data/owned_tickets_repository.dart create mode 100644 lib/tickets/my_tickets/data/ticket_order_data_provider.dart delete mode 100644 lib/tickets/my_tickets/my_tickets_section.dart delete mode 100644 lib/tickets/my_tickets/tickets_screen.dart rename lib/tickets/my_tickets/{ => ui}/depleted_ticket_card.dart (96%) create mode 100644 lib/tickets/my_tickets/ui/my_tickets_section.dart rename lib/tickets/my_tickets/{ => ui}/no_tickets_placeholder.dart (100%) rename lib/tickets/my_tickets/{ => ui}/owned_ticket_card.dart (70%) rename lib/tickets/my_tickets/{ => ui}/ticket_card_base.dart (100%) create mode 100644 lib/tickets/my_tickets/ui/tickets_screen.dart create mode 100644 lib/tickets/use_ticket/ui/animated_fade_switcher_sized.dart create mode 100644 lib/tickets/use_ticket/ui/next_button.dart create mode 100644 lib/tickets/use_ticket/ui/select_menu_item_content.dart create mode 100644 lib/tickets/use_ticket/ui/slide_action.dart create mode 100644 lib/tickets/use_ticket/ui/swipe_ticket_content.dart create mode 100644 lib/tickets/use_ticket/ui/use_ticket_card.dart create mode 100644 lib/tickets/use_ticket/ui/use_ticket_modal.dart diff --git a/Makefile b/Makefile index 54ad4cb..e2aa5d2 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ else SED_INPLACE := sed -i -E endif -.PHONY: help upgrade generate clean get swagger +.PHONY: help upgrade generate clean get swagger coverage fix .DEFAULT_GOAL := help @@ -37,3 +37,7 @@ get: ## Get dependencies swagger: ## Fetch latest Swagger API specs curl -o swagger/coffeecard_api_v1.json https://core.dev.analogio.dk/swagger/v1/swagger.json curl -o swagger/coffeecard_api_v2.json https://core.dev.analogio.dk/swagger/v2/swagger.json + +fix: ## Format code and fix issues that can be fixed automatically + dart format . + dart fix --apply diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 37cd4a7..6159edd 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -3,20 +3,27 @@ PODS: - flutter_secure_storage_darwin (10.0.0): - Flutter - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS DEPENDENCIES: - Flutter (from `Flutter`) - flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) EXTERNAL SOURCES: Flutter: :path: Flutter flutter_secure_storage_darwin: :path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23 + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e diff --git a/lib/app/app.dart b/lib/app/app.dart index 7747e1f..e842e79 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -2,31 +2,30 @@ import 'package:cafe_analog_app/app/app_brightness_provider.dart'; import 'package:cafe_analog_app/app/dependencies_provider.dart'; import 'package:cafe_analog_app/app/router.dart'; import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; class App extends StatefulWidget { - const App({super.key}); + const App({required this.localStorage, super.key}); + + final SharedPreferencesWithCache localStorage; @override State createState() => _AppState(); } class _AppState extends State { - // TODO(marfavi): Remove theme switching when all widgets support system theme - Brightness _brightness = Brightness.light; - - void _setBrightness(Brightness brightness) { - setState(() => _brightness = brightness); - } + Brightness appBrightness = .light; @override Widget build(BuildContext context) { return DependenciesProvider( + localStorage: widget.localStorage, child: MaterialApp.router( routerConfig: AnalogGoRouter.instance.goRouter, theme: ThemeData( - brightness: _brightness, - colorScheme: ColorScheme.fromSeed( - brightness: _brightness, + brightness: appBrightness, + colorScheme: .fromSeed( + brightness: appBrightness, seedColor: const Color(0xFF785B38), ), ), @@ -39,4 +38,8 @@ class _AppState extends State { ), ); } + + void _setBrightness(Brightness newBrightness) { + setState(() => appBrightness = newBrightness); + } } diff --git a/lib/app/bootstrap.dart b/lib/app/bootstrap.dart deleted file mode 100644 index ed46c28..0000000 --- a/lib/app/bootstrap.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'dart:async'; -import 'dart:developer'; - -import 'package:bloc/bloc.dart'; -import 'package:flutter/widgets.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); - } -} - -Future bootstrap(FutureOr Function() builder) async { - FlutterError.onError = (details) { - log(details.exceptionAsString(), stackTrace: details.stack); - }; - - Bloc.observer = const AppBlocObserver(); - - WidgetsFlutterBinding.ensureInitialized(); - - // Add cross-flavor configuration here - - runApp(await builder()); -} diff --git a/lib/app/dependencies_provider.dart b/lib/app/dependencies_provider.dart index dd57090..6c61dc3 100644 --- a/lib/app/dependencies_provider.dart +++ b/lib/app/dependencies_provider.dart @@ -1,39 +1,79 @@ import 'dart:async'; -import 'package:cafe_analog_app/core/http_client.dart'; import 'package:cafe_analog_app/core/network_request_executor.dart'; +import 'package:cafe_analog_app/core/network_request_interceptor.dart'; +import 'package:cafe_analog_app/generated/api/coffeecard_api_v1.swagger.dart' + hide $JsonSerializableConverter; +import 'package:cafe_analog_app/generated/api/coffeecard_api_v2.swagger.dart'; import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; import 'package:cafe_analog_app/login/data/authentication_token_repository.dart'; import 'package:cafe_analog_app/login/data/login_repository.dart'; +import 'package:chopper/chopper.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:logger/logger.dart'; +import 'package:shared_preferences/shared_preferences.dart'; /// Provides the dependencies required throughout the app. class DependenciesProvider extends StatelessWidget { - const DependenciesProvider({required this.child, super.key}); + const DependenciesProvider({ + required this.localStorage, + required this.child, + super.key, + }); + final SharedPreferencesWithCache localStorage; final MaterialApp child; @override Widget build(BuildContext context) { return MultiRepositoryProvider( providers: [ + // Persistence + RepositoryProvider.value(value: localStorage), RepositoryProvider(create: (_) => const FlutterSecureStorage()), - RepositoryProvider.value(value: apiV1), - RepositoryProvider.value(value: apiV2), - RepositoryProvider(create: (_) => Logger()), + RepositoryProvider(create: (_) => AuthTokenStore()), + + // Http RepositoryProvider( - create: (context) => NetworkRequestExecutor(logger: context.read()), + create: (context) => ChopperClient( + baseUrl: Uri.parse('https://core.dev.analogio.dk'), + interceptors: [ + NetworkRequestInterceptor(authTokenStore: context.read()), + ], + converter: $JsonSerializableConverter(), + services: [CoffeecardApiV1.create(), CoffeecardApiV2.create()], + // FIXME(marfavi): Add authenticator to redirect on 401 responses + // authenticator: sl.get(), + ), ), RepositoryProvider( create: (context) => - LoginRepository(apiV2: context.read(), executor: context.read()), + context.read().getService(), ), RepositoryProvider( create: (context) => - AuthTokenRepository(secureStorage: context.read()), + context.read().getService(), + ), + RepositoryProvider(create: (_) => Logger()), + RepositoryProvider( + create: (context) => NetworkRequestExecutor( + logger: context.read(), + apiV1: context.read(), + apiV2: context.read(), + ), + ), + + // Auth/login repositories + RepositoryProvider( + create: (context) => LoginRepository(executor: context.read()), + ), + RepositoryProvider( + create: (context) => AuthTokenRepository( + secureStorage: context.read(), + authTokenStore: context.read(), + ), ), ], child: MultiBlocProvider( diff --git a/lib/app/router.dart b/lib/app/router.dart index 6ca869c..3f82d85 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -15,285 +15,7 @@ import 'package:cafe_analog_app/stats/view/stats_screen.dart'; import 'package:cafe_analog_app/tickets/buy_tickets/buy_tickets_screen.dart'; import 'package:cafe_analog_app/tickets/buy_tickets/product.dart'; import 'package:cafe_analog_app/tickets/buy_tickets/ticket_detail_screen.dart'; -import 'package:cafe_analog_app/tickets/my_tickets/tickets_screen.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; - -class AnalogGoRouter { - AnalogGoRouter._internal(); - - static final AnalogGoRouter instance = AnalogGoRouter._internal(); - - late final goRouter = GoRouter( - initialLocation: '/', - debugLogDiagnostics: kDebugMode, - routes: routes, - onEnter: onEnter, - redirect: redirect, - ); - - late final routes = [ - // Root shell that listens to auth state changes - ShellRoute( - builder: (_, _, child) => AuthNavigator(child: child), - routes: [ - // Splash screen shown at app start - GoRoute( - path: '/', - pageBuilder: (_, _) => const NoTransitionPage(child: SplashScreen()), - ), - GoRoute( - path: '/login', - pageBuilder: (_, _) => CustomTransitionPage( - child: const LoginScreen(), - transitionsBuilder: (_, animation, _, child) { - return FadeTransition(opacity: animation, child: child); - }, - ), - routes: [ - GoRoute( - path: 'email-sent', - pageBuilder: (_, state) { - final email = state.uri.queryParameters['email'] ?? ''; - return MaterialPage(child: EmailSentScreen(email: email)); - }, - ), - GoRoute( - path: 'auth/:token', - pageBuilder: (_, state) => CustomTransitionPage( - child: VerifyMagicLinkScreen( - magicLinkToken: state.pathParameters['token']!, - ), - transitionsBuilder: (_, animation, _, child) { - return FadeTransition(opacity: animation, child: child); - }, - ), - ), - ], - ), - GoRoute( - path: '/verify-mobilepay/:id', - pageBuilder: (_, state) => MaterialPage( - // TODO(marfavi): Implement MobilePay verification screen - child: Container(), - ), - ), - StatefulShellRoute.indexedStack( - // fade in the main scaffold (doesn't affect branch transitions) - pageBuilder: (_, _, shell) => CustomTransitionPage( - child: ScaffoldWithNestedNavigation(navigationShell: shell), - transitionsBuilder: (_, animation, _, child) { - return FadeTransition(opacity: animation, child: child); - }, - ), - branches: [ - StatefulShellBranch( - routes: [ - GoRoute( - path: '/tickets', - pageBuilder: (context, state) => const NoTransitionPage( - child: TicketsScreen(), - ), - routes: [ - GoRoute( - path: 'buy', - builder: (_, _) => const BuyTicketsScreen(), - routes: [ - GoRoute( - path: 'ticket/:id', - pageBuilder: (context, state) { - // we don't use id here, but in a real app you might - // fetch the product details based on the id - // we pass the whole product via extra - // - // cast state.extra to Product - final product = state.extra! as Product; - return MaterialPage( - fullscreenDialog: true, - child: TicketDetailScreen(product: product), - ); - }, - ), - ], - ), - GoRoute( - path: 'redeem_voucher', - pageBuilder: (context, state) => const MaterialPage( - child: RedeemVoucherScreen(), - ), - ), - ], - ), - ], - ), - StatefulShellBranch( - routes: [ - GoRoute( - path: '/receipts', - pageBuilder: (context, state) => const NoTransitionPage( - child: ReceiptsScreen(), - ), - routes: [ - GoRoute( - path: 'purchase_receipt/:id', - // TODO(marfavi): Implement receipt screen - builder: (context, state) => Container(), - ), - GoRoute( - path: 'swipe_receipt/:id', - // TODO(marfavi): Implement receipt screen - builder: (context, state) => Container(), - ), - ], - ), - ], - ), - StatefulShellBranch( - routes: [ - GoRoute( - path: '/stats', - pageBuilder: (context, state) => - const NoTransitionPage(child: StatsScreen()), - ), - ], - ), - StatefulShellBranch( - routes: [ - GoRoute( - path: '/settings', - pageBuilder: (context, state) => - const NoTransitionPage(child: SettingsScreen()), - routes: [ - GoRoute( - path: 'your-profile', - pageBuilder: (context, state) => - const MaterialPage(child: YourProfileScreen()), - ), - ], - ), - ], - ), - ], - ), - ], - ), - ]; - - FutureOr redirect(BuildContext context, GoRouterState state) { - final loc = state.matchedLocation; - final isLoggedIn = context.read().state is AuthAuthenticated; - - // User is going anywhere within [/login, /login/email-sent, /login/auth/] - final goingToLoginFlow = loc.startsWith('/login'); - - // User is specifically accessing the app via a magic link (/login/auth/) - final goingToAuthenticate = loc.startsWith('/login/auth/'); - - // User is starting the app - final isStartingApp = loc == '/'; - - // If not logged in, always go to login unless already going there - // (or starting the app, which will handle redirection itself) - if (!isLoggedIn && - !goingToLoginFlow && - !goingToAuthenticate && - !isStartingApp) { - if (kDebugMode) { - print('Redirecting to /login'); - } - return '/login'; - } - - // If logged in and accessing app via login deep link, redirect to main app - if (isLoggedIn && goingToAuthenticate) { - // Show a snackbar after the frame is rendered - WidgetsBinding.instance.addPostFrameCallback((_) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('You are already logged in.')), - ); - }); - return '/tickets'; - } - - // If logged in and going to login, redirect to main app - if (isLoggedIn && goingToLoginFlow) { - return '/tickets'; - } - - // No need to redirect at all - return null; - } - - FutureOr onEnter( - BuildContext context, - GoRouterState currentState, - GoRouterState nextState, - GoRouter goRouter, - ) { - final currentLoc = currentState.matchedLocation; - final nextLoc = nextState.matchedLocation; - final isLoggedIn = context.read().state is AuthAuthenticated; - - // User is going anywhere within [/login, /login/email-sent, /login/auth/] - final goingToLoginFlow = nextLoc.startsWith('/login'); - - // User is starting the app - final isStartingApp = nextLoc == '/'; - - // We consider the 'main' app sections to be the branches under the shell. - final isInMainArea = - currentLoc.startsWith('/tickets') || - currentLoc.startsWith('/receipts') || - currentLoc.startsWith('/stats') || - currentLoc.startsWith('/settings'); - - // If the user is in the main app area and trying to go to the login flow - // while already logged in, block the navigation and show a snackbar. - if (isLoggedIn && goingToLoginFlow && isInMainArea) { - if (kDebugMode) { - print('Navigation to $nextLoc blocked: already logged in.'); - } - return Block.then( - () => ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('You are already logged in.')), - ), - ); - } - // If the user is not logged in and trying to go to the main app area, - // block the navigation and show a snackbar. - if (!isLoggedIn && !goingToLoginFlow && !isStartingApp) { - if (kDebugMode) { - print('Navigation to $nextLoc blocked: not logged in.'); - } - return Block.then( - () => ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Please log in to continue.')), - ), - ); - } - return const Allow(); - } -} -import 'dart:async'; - -import 'package:cafe_analog_app/app/navigation_scaffolds.dart'; -import 'package:cafe_analog_app/app/splash_screen.dart'; -import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; -import 'package:cafe_analog_app/login/ui/verify_magic_link_screen.dart'; -import 'package:cafe_analog_app/login/ui/auth_navigator.dart'; -import 'package:cafe_analog_app/login/ui/email_sent_screen.dart'; -import 'package:cafe_analog_app/login/ui/login_screen.dart'; -import 'package:cafe_analog_app/receipts/receipts_screen.dart'; -import 'package:cafe_analog_app/redeem_voucher/redeem_voucher_screen.dart'; -import 'package:cafe_analog_app/settings/settings_screen.dart'; -import 'package:cafe_analog_app/settings/your_profile_screen.dart'; -import 'package:cafe_analog_app/stats/view/stats_screen.dart'; -import 'package:cafe_analog_app/tickets/buy_tickets/buy_tickets_screen.dart'; -import 'package:cafe_analog_app/tickets/buy_tickets/product.dart'; -import 'package:cafe_analog_app/tickets/buy_tickets/ticket_detail_screen.dart'; -import 'package:cafe_analog_app/tickets/my_tickets/tickets_screen.dart'; +import 'package:cafe_analog_app/tickets/my_tickets/ui/tickets_screen.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; diff --git a/lib/app/splash_screen.dart b/lib/app/splash_screen.dart index e5a70ae..d2a2885 100644 --- a/lib/app/splash_screen.dart +++ b/lib/app/splash_screen.dart @@ -1,5 +1,5 @@ import 'package:cafe_analog_app/core/widgets/analog_circular_progress_indicator.dart'; -import 'package:cafe_analog_app/tickets/use_ticket/delayed_fade_in.dart'; +import 'package:cafe_analog_app/core/widgets/delayed_fade_in.dart'; import 'package:flutter/material.dart'; class SplashScreen extends StatelessWidget { diff --git a/lib/core/http_client.dart b/lib/core/http_client.dart deleted file mode 100644 index 69a9175..0000000 --- a/lib/core/http_client.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:cafe_analog_app/generated/api/coffeecard_api_v1.swagger.dart' - hide $JsonSerializableConverter; -import 'package:cafe_analog_app/generated/api/coffeecard_api_v2.swagger.dart'; -import 'package:chopper/chopper.dart'; - -final _httpClient = ChopperClient( - baseUrl: Uri.parse('https://core.dev.analogio.dk'), - interceptors: [], - converter: $JsonSerializableConverter(), - services: [CoffeecardApiV1.create(), CoffeecardApiV2.create()], - // authenticator: sl.get(), -); - -final CoffeecardApiV1 apiV1 = CoffeecardApiV1.create(client: _httpClient); -final CoffeecardApiV2 apiV2 = CoffeecardApiV2.create(client: _httpClient); diff --git a/lib/core/loading_overlay.dart b/lib/core/loading_overlay.dart index 398f79f..777a7e6 100644 --- a/lib/core/loading_overlay.dart +++ b/lib/core/loading_overlay.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:cafe_analog_app/core/widgets/analog_circular_progress_indicator.dart'; -import 'package:cafe_analog_app/tickets/use_ticket/delayed_fade_in.dart'; +import 'package:cafe_analog_app/core/widgets/delayed_fade_in.dart'; import 'package:flutter/material.dart'; /// Shows a loading overlay dialog. diff --git a/lib/core/network_request_executor.dart b/lib/core/network_request_executor.dart index 3ef951d..2e9c919 100644 --- a/lib/core/network_request_executor.dart +++ b/lib/core/network_request_executor.dart @@ -1,22 +1,40 @@ import 'package:cafe_analog_app/core/failures.dart'; +import 'package:cafe_analog_app/generated/api/client_index.dart'; import 'package:chopper/chopper.dart' show Response; import 'package:fpdart/fpdart.dart'; import 'package:logger/logger.dart'; class NetworkRequestExecutor { - const NetworkRequestExecutor({required this.logger}); + const NetworkRequestExecutor({ + required this.logger, + required this.apiV1, + required this.apiV2, + }); final Logger logger; + final CoffeecardApiV1 apiV1; + final CoffeecardApiV2 apiV2; /// Executes a network request and returns a [TaskEither]. /// - /// If the request fails, a [NetworkFailure] is returned in a [Left]. + /// If the request fails, a [Failure] is returned in a [Left]. /// If the request succeeds, the response body of type /// [BodyType] is returned in a [Right]. - TaskEither execute( + /// + /// ```dart + /// executor.run((api) => api.v2.accountAuthPost(...)); + /// ``` + TaskEither run( + Future> Function(ApiClients api) request, + ) { + final clients = ApiClients(v1: apiV1, v2: apiV2); + return _execute(() => request(clients)); + } + + TaskEither _execute( Future> Function() request, ) { - return TaskEither>.tryCatch( + return TaskEither>.tryCatch( request, (error, stackTrace) { logger.e(error.toString()); @@ -34,3 +52,11 @@ class NetworkRequestExecutor { ); } } + +/// Small helper that groups generated API clients. +class ApiClients { + const ApiClients({required this.v1, required this.v2}); + + final CoffeecardApiV1 v1; + final CoffeecardApiV2 v2; +} diff --git a/lib/core/network_request_interceptor.dart b/lib/core/network_request_interceptor.dart new file mode 100644 index 0000000..8df2804 --- /dev/null +++ b/lib/core/network_request_interceptor.dart @@ -0,0 +1,29 @@ +import 'dart:async'; + +import 'package:chopper/chopper.dart'; + +class AuthTokenStore { + String? token; +} + +class NetworkRequestInterceptor implements Interceptor { + NetworkRequestInterceptor({ + required AuthTokenStore authTokenStore, + }) : _authTokenStore = authTokenStore; + + final AuthTokenStore _authTokenStore; + + @override + FutureOr> intercept(Chain chain) { + return chain.proceed( + switch (_authTokenStore.token) { + null => chain.request, + final token => applyHeader( + chain.request, + 'Authorization', + 'Bearer $token', + ), + }, + ); + } +} diff --git a/lib/core/widgets/app_bar.dart b/lib/core/widgets/app_bar.dart index 2a4a988..ba286a3 100644 --- a/lib/core/widgets/app_bar.dart +++ b/lib/core/widgets/app_bar.dart @@ -7,6 +7,7 @@ class AnalogAppBar extends StatelessWidget implements PreferredSizeWidget { this.onBrightnessChanged, super.key, }); + final String title; final void Function(Brightness)? onBrightnessChanged; diff --git a/lib/core/widgets/delayed_fade_in.dart b/lib/core/widgets/delayed_fade_in.dart new file mode 100644 index 0000000..f57b1ae --- /dev/null +++ b/lib/core/widgets/delayed_fade_in.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +/// A widget that fades in its child after a specified delay. +class DelayedFadeIn extends StatefulWidget { + const DelayedFadeIn({ + required this.child, + this.delay = const Duration(milliseconds: 250), + super.key, + }); + + final Widget child; + final Duration delay; + + @override + State createState() => _DelayedFadeInState(); +} + +class _DelayedFadeInState extends State { + bool _visible = false; + + @override + void initState() { + super.initState(); + Future.delayed(widget.delay, () { + if (mounted) { + setState(() => _visible = true); + } + }); + } + + @override + Widget build(BuildContext context) { + return AnimatedOpacity( + opacity: _visible ? 1.0 : 0.0, + duration: const Duration(milliseconds: 250), + child: widget.child, + ); + } +} diff --git a/lib/login/bloc/authentication_cubit.dart b/lib/login/bloc/authentication_cubit.dart index 0067555..dd31863 100644 --- a/lib/login/bloc/authentication_cubit.dart +++ b/lib/login/bloc/authentication_cubit.dart @@ -1,9 +1,9 @@ -import 'package:bloc/bloc.dart'; import 'package:cafe_analog_app/login/data/authentication_token_repository.dart'; import 'package:cafe_analog_app/login/data/authentication_tokens.dart'; import 'package:cafe_analog_app/login/data/login_repository.dart'; import 'package:cafe_analog_app/login/ui/authentication_navigator.dart'; import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; part 'authentication_state.dart'; diff --git a/lib/login/data/authentication_token_repository.dart b/lib/login/data/authentication_token_repository.dart index e2272b1..51804aa 100644 --- a/lib/login/data/authentication_token_repository.dart +++ b/lib/login/data/authentication_token_repository.dart @@ -1,14 +1,19 @@ import 'package:cafe_analog_app/core/failures.dart'; +import 'package:cafe_analog_app/core/network_request_interceptor.dart'; import 'package:cafe_analog_app/login/data/authentication_tokens.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:fpdart/fpdart.dart'; /// Handles storing and retrieving JWT and refresh tokens securely. class AuthTokenRepository { - AuthTokenRepository({required FlutterSecureStorage secureStorage}) - : _secureStorage = secureStorage; + AuthTokenRepository({ + required FlutterSecureStorage secureStorage, + required AuthTokenStore authTokenStore, + }) : _secureStorage = secureStorage, + _authTokenStore = authTokenStore; final FlutterSecureStorage _secureStorage; + final AuthTokenStore _authTokenStore; static const _jwtKey = 'jwt_token'; static const _refreshTokenKey = 'refresh_token'; @@ -26,6 +31,7 @@ class AuthTokenRepository { value: tokens.refreshToken, ), ]); + _authTokenStore.token = tokens.jwt; return tokens; }, (error, _) => LocalStorageFailure('Failed to save auth tokens: $error'), @@ -39,8 +45,10 @@ class AuthTokenRepository { final jwt = await _secureStorage.read(key: _jwtKey); final refreshToken = await _secureStorage.read(key: _refreshTokenKey); if (jwt != null && refreshToken != null) { + _authTokenStore.token = jwt; return some(AuthTokens(jwt: jwt, refreshToken: refreshToken)); } + _authTokenStore.token = null; return none(); }, (error, _) => @@ -56,6 +64,7 @@ class AuthTokenRepository { _secureStorage.delete(key: _jwtKey), _secureStorage.delete(key: _refreshTokenKey), ]); + _authTokenStore.token = null; return unit; }, (error, _) => LocalStorageFailure('Failed to clear auth tokens: $error'), diff --git a/lib/login/data/authentication_tokens.dart b/lib/login/data/authentication_tokens.dart index 4fd30a9..58262ca 100644 --- a/lib/login/data/authentication_tokens.dart +++ b/lib/login/data/authentication_tokens.dart @@ -10,15 +10,3 @@ class AuthTokens extends Equatable { @override List get props => [jwt, refreshToken]; } -import 'package:equatable/equatable.dart'; - -/// Represents authentication tokens received after a successful authentication. -class AuthTokens extends Equatable { - const AuthTokens({required this.jwt, required this.refreshToken}); - - final String jwt; - final String refreshToken; - - @override - List get props => [jwt, refreshToken]; -} diff --git a/lib/login/data/login_repository.dart b/lib/login/data/login_repository.dart index 1cdfd42..8d28672 100644 --- a/lib/login/data/login_repository.dart +++ b/lib/login/data/login_repository.dart @@ -1,6 +1,5 @@ import 'package:cafe_analog_app/core/failures.dart'; import 'package:cafe_analog_app/core/network_request_executor.dart'; -import 'package:cafe_analog_app/generated/api/client_index.dart'; import 'package:cafe_analog_app/generated/api/coffeecard_api_v2.enums.swagger.dart'; import 'package:cafe_analog_app/generated/api/coffeecard_api_v2.models.swagger.dart'; import 'package:cafe_analog_app/login/data/authentication_tokens.dart'; @@ -8,13 +7,9 @@ import 'package:fpdart/fpdart.dart'; /// Handles data operations related to user login. class LoginRepository { - const LoginRepository({ - required CoffeecardApiV2 apiV2, - required NetworkRequestExecutor executor, - }) : _apiV2 = apiV2, - _executor = executor; + const LoginRepository({required NetworkRequestExecutor executor}) + : _executor = executor; - final CoffeecardApiV2 _apiV2; final NetworkRequestExecutor _executor; /// Requests a magic link to be sent to the provided email. @@ -24,7 +19,7 @@ class LoginRepository { loginType: LoginType.app.value, ); return _executor - .execute(() => _apiV2.accountLoginPost(body: request)) + .run((api) => api.v2.accountLoginPost(body: request)) .map((_) => unit); } @@ -32,50 +27,7 @@ class LoginRepository { TaskEither authenticateWithMagicLinkToken(String token) { final request = TokenLoginRequest(token: token); return _executor - .execute(() => _apiV2.accountAuthPost(body: request)) - .map( - (response) => AuthTokens( - jwt: response.jwt, - refreshToken: response.refreshToken, - ), - ); - } -} -import 'package:cafe_analog_app/core/failures.dart'; -import 'package:cafe_analog_app/core/network_request_executor.dart'; -import 'package:cafe_analog_app/generated/api/client_index.dart'; -import 'package:cafe_analog_app/generated/api/coffeecard_api_v2.enums.swagger.dart'; -import 'package:cafe_analog_app/generated/api/coffeecard_api_v2.models.swagger.dart'; -import 'package:cafe_analog_app/login/data/authentication_tokens.dart'; -import 'package:fpdart/fpdart.dart'; - -/// Handles data operations related to user login. -class LoginRepository { - const LoginRepository({ - required CoffeecardApiV2 apiV2, - required NetworkRequestExecutor executor, - }) : _apiV2 = apiV2, - _executor = executor; - - final CoffeecardApiV2 _apiV2; - final NetworkRequestExecutor _executor; - - /// Requests a magic link to be sent to the provided email. - TaskEither requestMagicLink(String email) { - final request = UserLoginRequest( - email: email, - loginType: LoginType.app.value, - ); - return _executor - .execute(() => _apiV2.accountLoginPost(body: request)) - .map((_) => unit); - } - - /// Authenticates the user with the provided magic link token. - TaskEither authenticateWithMagicLinkToken(String token) { - final request = TokenLoginRequest(token: token); - return _executor - .execute(() => _apiV2.accountAuthPost(body: request)) + .run((api) => api.v2.accountAuthPost(body: request)) .map( (response) => AuthTokens( jwt: response.jwt, diff --git a/lib/login/ui/login_screen.dart b/lib/login/ui/login_screen.dart index 37e47b2..f2e6100 100644 --- a/lib/login/ui/login_screen.dart +++ b/lib/login/ui/login_screen.dart @@ -68,72 +68,3 @@ class _LoginScreenState extends State { ); } } -import 'package:cafe_analog_app/core/widgets/form.dart'; -import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class LoginScreen extends StatefulWidget { - const LoginScreen({super.key}); - - @override - State createState() => _LoginScreenState(); -} - -class _LoginScreenState extends State { - @override - Widget build(BuildContext context) { - final textTheme = Theme.of(context).textTheme; - - return Scaffold( - body: Stack( - children: [ - // Subtle background graphic - Positioned.fill( - child: Opacity( - opacity: 0.075, - child: Image.asset('assets/images/beans_half.png', fit: .cover), - ), - ), - // Main content - SafeArea( - child: Column( - children: [ - Flexible( - fit: .tight, - child: FittedBox( - fit: .scaleDown, - child: Column( - mainAxisSize: .min, - children: [ - Text( - 'Café Analog', - style: textTheme.headlineLarge?.copyWith( - fontWeight: .w900, - ), - ), - Text( - 'Enter your email to continue', - style: textTheme.bodyMedium, - ), - ], - ), - ), - ), - AnalogForm( - inputType: .email, - labelText: 'Your email', - submitText: 'Continue', - errorMessage: 'Enter a valid email', - onSubmit: (email) async { - await context.read().sendLoginLink(email: email); - }, - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/lib/login/ui/verify_magic_link_screen.dart b/lib/login/ui/verify_magic_link_screen.dart index 3c45846..294f172 100644 --- a/lib/login/ui/verify_magic_link_screen.dart +++ b/lib/login/ui/verify_magic_link_screen.dart @@ -39,44 +39,3 @@ class _VerifyMagicLinkScreenState extends State { ); } } -import 'dart:async'; - -import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -/// Screen displayed when the app is opened via a magic link. -/// -/// Blank screen, only responsible for initiating the authentication -/// process using the provided magic link token. -class VerifyMagicLinkScreen extends StatefulWidget { - const VerifyMagicLinkScreen({required this.magicLinkToken, super.key}); - - final String magicLinkToken; - - @override - State createState() => _VerifyMagicLinkScreenState(); -} - -class _VerifyMagicLinkScreenState extends State { - @override - void initState() { - super.initState(); - - // Start authentication process with the provided magic link token - unawaited( - context.read().authenticateWithToken( - magicLinkToken: widget.magicLinkToken, - ), - ); - } - - @override - Widget build(BuildContext context) { - // Prevent back navigation during the authentication process - return const PopScope( - canPop: false, - child: Scaffold(), - ); - } -} diff --git a/lib/main.dart b/lib/main.dart index b73c221..d8b3619 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,39 @@ +import 'dart:async'; +import 'dart:developer'; + import 'package:cafe_analog_app/app/app.dart'; -import 'package:cafe_analog_app/app/bootstrap.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:shared_preferences/shared_preferences.dart'; Future main() async { - await bootstrap(() => const App()); + FlutterError.onError = (details) { + log(details.exceptionAsString(), stackTrace: details.stack); + }; + + Bloc.observer = const AppBlocObserver(); + + WidgetsFlutterBinding.ensureInitialized(); + + final localStorage = await SharedPreferencesWithCache.create( + cacheOptions: const SharedPreferencesWithCacheOptions(), + ); + + runApp(App(localStorage: localStorage)); +} + +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/settings/settings_screen.dart b/lib/settings/settings_screen.dart index 30ab9b8..dca6344 100644 --- a/lib/settings/settings_screen.dart +++ b/lib/settings/settings_screen.dart @@ -3,11 +3,9 @@ import 'dart:async'; import 'package:cafe_analog_app/core/widgets/screen.dart'; import 'package:cafe_analog_app/core/widgets/section_title.dart'; import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; -import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; @@ -118,30 +116,4 @@ class SettingsScreen extends StatelessWidget { if (!context.mounted) return; await context.read().logOut(); } - - Future _onLogOutTap(BuildContext context) async { - final confirm = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text( - 'Log out', - style: TextStyle(fontWeight: .bold), - ), - content: const Text('Are you sure you want to log out?'), - actions: [ - TextButton( - onPressed: () => context.pop(false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => context.pop(true), - child: const Text('Log out'), - ), - ], - ), - ); - if (confirm != true) return; - if (!context.mounted) return; - await context.read().logOut(); - } } diff --git a/lib/tickets/my_tickets/README b/lib/tickets/my_tickets/README new file mode 100644 index 0000000..dffbe4f --- /dev/null +++ b/lib/tickets/my_tickets/README @@ -0,0 +1,13 @@ +# TODO for my_tickets feature + +- [X] Separate data sources away from repositories +- [x] Move away from hydrated cubit and use some data source instead +- [ ] Floating purchase status indicator below app bar - avoid double-purchase mistakes - clearly comminicate if a purchase is stil pending or has truly been cancelled +- [ ] Link to products feature (Get products from backend and persist them) +- [ ] Get menu items from backend and persist them +- [ ] Implement choosing a menu item and swiping a ticket +- [ ] Implement showing a DepletedTicketCard when last ticket has been used +- [ ] Think a bit harder over feature structure and folder structure - right now, we have three subfolders/"subfeatures" inside the "tickets" folder, which is otherwise empty. +- [ ] More background graphics? +- [ ] Consider how Barista perks should be implemented - new navigation target or new section below My Tickets (like how it works in app v3)? +- [ ] Snappier item reordering (requires custom code - make sepearate package?) \ No newline at end of file diff --git a/lib/tickets/my_tickets/bloc/owned_tickets_cubit.dart b/lib/tickets/my_tickets/bloc/owned_tickets_cubit.dart new file mode 100644 index 0000000..35decb6 --- /dev/null +++ b/lib/tickets/my_tickets/bloc/owned_tickets_cubit.dart @@ -0,0 +1,113 @@ +import 'dart:async'; + +import 'package:cafe_analog_app/tickets/my_tickets/data/owned_ticket.dart'; +import 'package:cafe_analog_app/tickets/my_tickets/data/owned_tickets_repository.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:fpdart/fpdart.dart'; + +part 'owned_tickets_state.dart'; + +class OwnedTicketsCubit extends Cubit { + OwnedTicketsCubit({ + required OwnedTicketsRepository ownedTicketsRepository, + }) : _ownedTicketsRepository = ownedTicketsRepository, + super(OwnedTicketsInitial()); + + final OwnedTicketsRepository _ownedTicketsRepository; + + /// Fetches owned tickets, first from cache, then from API. + /// + /// When fetching from API, also updates the cache. + Future getOwnedTickets() async { + // Don't emit loading if we are already loaded to avoid showing a spinner + // on top of existing data. We continue to fetch in the background. + if (state is! OwnedTicketsLoaded) { + emit(OwnedTicketsLoading()); + } + + // FIXME(marfavi): Implement logic to apply preferred order to tickets + // Attempt to get preferred order from storage + + // ignore: unused_local_variable + final preferredOrder = await _ownedTicketsRepository + .getTicketOrder() + .getOrElse((_) => const []) + .run(); + + // First try to get cached tickets to show immediately + await _ownedTicketsRepository + .getTicketsFromCache() + .chainFirst( + (cachedTickets) => TaskEither.of( + emit(OwnedTicketsLoaded(ownedTickets: cachedTickets)), + ), + ) + .run(); + + // Then fetch fresh tickets from API + final fetchResult = await _ownedTicketsRepository + .fetchTicketsFromApi() + // Sort tickets according to preferred order + // .flatMap( + // (ownedTickets) => _ownedTicketsRepository.getTicketOrder() + // ) + // On success, update the cache with the fetched tickets + .flatMap( + (ownedTickets) => _ownedTicketsRepository + .cacheTickets(ownedTickets) + .map((_) => ownedTickets), + ) + .match( + (didNotFetchOrCache) => + OwnedTicketsFailure(reason: didNotFetchOrCache.reason), + (ownedTickets) => OwnedTicketsLoaded(ownedTickets: ownedTickets), + ) + .run(); + + emit(fetchResult); + } + + /// Moves a ticket from [oldIndex] to [newIndex] in the preferred order. + /// + /// Accounts for the framework behaviour where `newIndex` is adjusted when + /// moving downwards. + Future reorderTickets(int oldIndex, int newIndex) async { + final currentState = state; + if (currentState is! OwnedTicketsLoaded) { + return; + // return emit( + // const OwnedTicketsFailure( + // reason: 'Cannot reorder tickets when tickets are not loaded', + // ), + // ); + } + + final insertAtIndex = newIndex > oldIndex ? newIndex - 1 : newIndex; + final updatedTickets = List.of(currentState.ownedTickets); + final ticket = updatedTickets.removeAt(oldIndex); + updatedTickets.insert(insertAtIndex, ticket); + + // Optimistically emit the updated order to avoid UI jank + emit(OwnedTicketsLoaded(ownedTickets: updatedTickets)); + + return _ownedTicketsRepository + .cacheTicketOrder(updatedTickets.map((x) => x.productId).toList()) + // .cacheTickets(updatedTickets) + .match( + (didNotUpdate) => + emit(OwnedTicketsFailure(reason: didNotUpdate.reason)), + // We already optimistically updated the order above, + // so don't need to do anything on success + (_) => null, + ) + .run(); + } + + /// Removes a depleted entry from the preferred order and the displayed list. + Future dismissDepletedTicket(int productId) async { + throw UnimplementedError(); + } + + // FIXME(marfavi): Get loaded tickets from storage on startup +} diff --git a/lib/tickets/my_tickets/bloc/owned_tickets_state.dart b/lib/tickets/my_tickets/bloc/owned_tickets_state.dart new file mode 100644 index 0000000..912949c --- /dev/null +++ b/lib/tickets/my_tickets/bloc/owned_tickets_state.dart @@ -0,0 +1,30 @@ +part of 'owned_tickets_cubit.dart'; + +sealed class OwnedTicketsState extends Equatable { + const OwnedTicketsState(); + + @override + List get props => []; +} + +final class OwnedTicketsInitial extends OwnedTicketsState {} + +final class OwnedTicketsLoading extends OwnedTicketsState {} + +final class OwnedTicketsLoaded extends OwnedTicketsState { + const OwnedTicketsLoaded({required this.ownedTickets}); + + final List ownedTickets; + + @override + List get props => [ownedTickets]; +} + +final class OwnedTicketsFailure extends OwnedTicketsState { + const OwnedTicketsFailure({required this.reason}); + + final String reason; + + @override + List get props => [reason]; +} diff --git a/lib/tickets/my_tickets/data/owned_ticket.dart b/lib/tickets/my_tickets/data/owned_ticket.dart new file mode 100644 index 0000000..1cb5e8f --- /dev/null +++ b/lib/tickets/my_tickets/data/owned_ticket.dart @@ -0,0 +1,42 @@ +import 'package:equatable/equatable.dart'; + +class OwnedTicket extends Equatable { + const OwnedTicket({ + required this.productId, + required this.ticketName, + required this.ticketsLeft, + required this.backgroundImagePath, + }); + + final int productId; + final String ticketName; + final int ticketsLeft; + final String backgroundImagePath; + + bool get isDepleted => ticketsLeft <= 0; + + @override + List get props => [ + productId, + ticketName, + ticketsLeft, + backgroundImagePath, + ]; + + Map toJson() => { + 'productId': productId, + 'ticketName': ticketName, + 'ticketsLeft': ticketsLeft, + 'backgroundImagePath': backgroundImagePath, + }; + + // FIXME(marfavi): Use json_serializable instead? + static OwnedTicket fromJson(Map json) { + return OwnedTicket( + productId: json['productId'] as int, + ticketName: json['ticketName'] as String, + ticketsLeft: json['ticketsLeft'] as int, + backgroundImagePath: json['backgroundImagePath'] as String, + ); + } +} diff --git a/lib/tickets/my_tickets/data/owned_tickets_local_data_provider.dart b/lib/tickets/my_tickets/data/owned_tickets_local_data_provider.dart new file mode 100644 index 0000000..f4413c0 --- /dev/null +++ b/lib/tickets/my_tickets/data/owned_tickets_local_data_provider.dart @@ -0,0 +1,44 @@ +import 'dart:convert'; + +import 'package:cafe_analog_app/core/failures.dart'; +import 'package:cafe_analog_app/tickets/my_tickets/data/owned_ticket.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class OwnedTicketsLocalDataProvider { + const OwnedTicketsLocalDataProvider({ + required SharedPreferencesWithCache localStorage, + }) : _localStorage = localStorage; + + final SharedPreferencesWithCache _localStorage; + + static const storageKey = 'tickets'; + + TaskEither set(List tickets) { + return TaskEither.tryCatch( + () async { + final rawList = tickets.map((ticket) => ticket.toJson()).toList(); + final raw = json.encode(rawList); + await _localStorage.setString(storageKey, raw); + return unit; + }, + (error, _) => LocalStorageFailure(error.toString()), + ); + } + + TaskEither> get() { + return TaskEither.tryCatch( + () async { + final raw = _localStorage.getString(storageKey); + if (raw == null || raw.isEmpty) { + throw Exception('No cached tickets found'); + } + final jsonList = json.decode(raw) as List; + return jsonList + .map((e) => OwnedTicket.fromJson(e as Map)) + .toList(growable: false); + }, + (error, _) => LocalStorageFailure(error.toString()), + ); + } +} diff --git a/lib/tickets/my_tickets/data/owned_tickets_remote_data_provider.dart b/lib/tickets/my_tickets/data/owned_tickets_remote_data_provider.dart new file mode 100644 index 0000000..f08d78d --- /dev/null +++ b/lib/tickets/my_tickets/data/owned_tickets_remote_data_provider.dart @@ -0,0 +1,17 @@ +import 'package:cafe_analog_app/core/failures.dart'; +import 'package:cafe_analog_app/core/network_request_executor.dart'; +import 'package:cafe_analog_app/generated/api/coffeecard_api_v2.models.swagger.dart'; +import 'package:fpdart/fpdart.dart'; + +class OwnedTicketsRemoteDataProvider { + const OwnedTicketsRemoteDataProvider({ + required NetworkRequestExecutor executor, + }) : _executor = executor; + + final NetworkRequestExecutor _executor; + + /// Fetches the list of owned tickets for the current user. + TaskEither> get() { + return _executor.run((api) => api.v2.ticketsGet(includeUsed: false)); + } +} diff --git a/lib/tickets/my_tickets/data/owned_tickets_repository.dart b/lib/tickets/my_tickets/data/owned_tickets_repository.dart new file mode 100644 index 0000000..eaf8ef7 --- /dev/null +++ b/lib/tickets/my_tickets/data/owned_tickets_repository.dart @@ -0,0 +1,64 @@ +import 'package:cafe_analog_app/core/failures.dart'; +import 'package:cafe_analog_app/tickets/my_tickets/data/owned_ticket.dart'; +import 'package:cafe_analog_app/tickets/my_tickets/data/owned_tickets_local_data_provider.dart'; +import 'package:cafe_analog_app/tickets/my_tickets/data/owned_tickets_remote_data_provider.dart'; +import 'package:cafe_analog_app/tickets/my_tickets/data/ticket_order_data_provider.dart'; +import 'package:collection/collection.dart'; +import 'package:fpdart/fpdart.dart'; + +class OwnedTicketsRepository { + const OwnedTicketsRepository({ + required OwnedTicketsRemoteDataProvider ticketsRemoteDataProvider, + required OwnedTicketsLocalDataProvider ticketsLocalDataProvider, + required TicketOrderDataProvider ticketsOrderDataProvider, + }) : _ticketsRemoteDataProvider = ticketsRemoteDataProvider, + _ticketsLocalDataProvider = ticketsLocalDataProvider, + _ticketsOrderDataProvider = ticketsOrderDataProvider; + + final OwnedTicketsRemoteDataProvider _ticketsRemoteDataProvider; + final OwnedTicketsLocalDataProvider _ticketsLocalDataProvider; + final TicketOrderDataProvider _ticketsOrderDataProvider; + + TaskEither> fetchTicketsFromApi() { + return _ticketsRemoteDataProvider + .get() + // Each dto represent one "clip" left on a ticket, so we need to + // create groups of ticket reponse objects grouped by product id, + // creating a list of key-value pairs (product id, list of dtos with id) + .map((dtos) => dtos.groupListsBy((dto) => dto.productId).entries) + // map each key-value pair to an OwnedTicket + .map( + (entries) => entries.map((entry) { + final tickets = entry.value; + return OwnedTicket( + productId: entry.key, + ticketName: tickets.first.productName, + ticketsLeft: tickets.length, + backgroundImagePath: + // choose background based on some rudimentary logic + tickets.first.productName.toLowerCase().contains('filter') + ? 'assets/images/beans_cropped.png' + : 'assets/images/latteart_cropped.png', + ); + }), + ) + // Convert Iterable to List + .map(List.of); + } + + TaskEither> getTicketsFromCache() { + return _ticketsLocalDataProvider.get(); + } + + TaskEither cacheTickets(List tickets) { + return _ticketsLocalDataProvider.set(tickets); + } + + TaskEither> getTicketOrder() { + return _ticketsOrderDataProvider.get(); + } + + TaskEither cacheTicketOrder(List order) { + return _ticketsOrderDataProvider.set(order); + } +} diff --git a/lib/tickets/my_tickets/data/ticket_order_data_provider.dart b/lib/tickets/my_tickets/data/ticket_order_data_provider.dart new file mode 100644 index 0000000..f6be2c7 --- /dev/null +++ b/lib/tickets/my_tickets/data/ticket_order_data_provider.dart @@ -0,0 +1,40 @@ +import 'dart:convert'; + +import 'package:cafe_analog_app/core/failures.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class TicketOrderDataProvider { + const TicketOrderDataProvider({ + required SharedPreferencesWithCache localStorage, + }) : _localStorage = localStorage; + + final SharedPreferencesWithCache _localStorage; + + static const String storageKey = 'tickets_preferred_order'; + + TaskEither set(List order) { + return TaskEither.tryCatch( + () async { + final raw = json.encode(order); + await _localStorage.setString(storageKey, raw); + return unit; + }, + (error, _) => LocalStorageFailure(error.toString()), + ); + } + + TaskEither> get() { + return TaskEither.tryCatch( + () async { + final raw = _localStorage.getString(storageKey); + if (raw == null || raw.isEmpty) { + throw Exception('No cached ticket order found'); + } + final jsonList = json.decode(raw) as List; + return jsonList.map((e) => e as int).toList(growable: false); + }, + (error, _) => LocalStorageFailure(error.toString()), + ); + } +} diff --git a/lib/tickets/my_tickets/my_tickets_section.dart b/lib/tickets/my_tickets/my_tickets_section.dart deleted file mode 100644 index d7908c6..0000000 --- a/lib/tickets/my_tickets/my_tickets_section.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'dart:async'; - -import 'package:cafe_analog_app/tickets/buy_tickets/products.dart'; -import 'package:cafe_analog_app/tickets/my_tickets/depleted_ticket_card.dart'; -import 'package:cafe_analog_app/tickets/my_tickets/no_tickets_placeholder.dart'; -import 'package:cafe_analog_app/tickets/my_tickets/owned_ticket_card.dart'; -import 'package:cafe_analog_app/tickets/use_ticket/use_ticket_modal.dart'; -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; - -// TODO(monir): add placeholder when user doesn't have any tickets. -class MyTicketsSection extends StatelessWidget { - const MyTicketsSection({super.key}); - - // TODO(marfavi): hent data fra backend - @override - Widget build(BuildContext context) { - final dummyEmptyProduct = products[1]; - - return Padding( - padding: const EdgeInsets.all(16), - child: Column( - spacing: 16, - children: [ - OwnedTicketCard( - id: 0, - ticketName: 'Fancy', - icon: Icons.local_cafe, - ticketsLeft: 4, - backgroundImagePath: 'assets/images/latteart_cropped.png', - onTap: () => UseTicketModal.show( - context: context, - ticketId: 0, - ticketName: 'Fancy', - backgroundImagePath: 'assets/images/latteart_cropped.png', - ), - ), - OwnedTicketCard( - id: 1, - ticketName: 'Filter', - icon: Icons.coffee_maker, - ticketsLeft: 1, - backgroundImagePath: 'assets/images/beans_cropped.png', - onTap: () => UseTicketModal.show( - context: context, - ticketId: 1, - ticketName: 'Filter', - backgroundImagePath: 'assets/images/beans_cropped.png', - ), - ), - // Sample NoTicketsLeftCard widget - DepletedTicketCard( - id: 2, - ticketName: dummyEmptyProduct.title, - backgroundImagePath: 'assets/images/latteart_cropped.png', - onBuyMore: () { - unawaited( - context.push( - '/tickets/buy/ticket/${dummyEmptyProduct.title}', - extra: dummyEmptyProduct, - ), - ); - }, - onDismiss: () { - // TODO(marfavi): Remove this card from the list - }, - ), - // Shown when user has no tickets at all - const NoTicketsPlaceholder(), - ], - ), - ); - } -} diff --git a/lib/tickets/my_tickets/tickets_screen.dart b/lib/tickets/my_tickets/tickets_screen.dart deleted file mode 100644 index 4035409..0000000 --- a/lib/tickets/my_tickets/tickets_screen.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:cafe_analog_app/core/widgets/screen.dart'; -import 'package:cafe_analog_app/tickets/my_tickets/my_tickets_section.dart'; -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; - -class TicketsScreen extends StatelessWidget { - const TicketsScreen({super.key}); - - Future _refreshTickets() async { - // TODO(marfavi): Add actual refresh logic - await Future.delayed(const Duration(seconds: 2)); - } - - @override - Widget build(BuildContext context) { - return Screen.listView( - name: 'Tickets', - onRefresh: _refreshTickets, - children: [ - const MyTicketsSection(), - ListTile( - leading: const Icon(Icons.local_cafe), - title: const Text('Buy drink tickets'), - trailing: const Icon(Icons.chevron_right), - onTap: () => context.push('/tickets/buy'), - ), - ListTile( - leading: const Icon(Icons.card_giftcard), - title: const Text('Redeem a code'), - trailing: const Icon(Icons.chevron_right), - onTap: () => context.push('/tickets/redeem_voucher'), - ), - ], - ); - } -} diff --git a/lib/tickets/my_tickets/depleted_ticket_card.dart b/lib/tickets/my_tickets/ui/depleted_ticket_card.dart similarity index 96% rename from lib/tickets/my_tickets/depleted_ticket_card.dart rename to lib/tickets/my_tickets/ui/depleted_ticket_card.dart index b42c953..a3095ff 100644 --- a/lib/tickets/my_tickets/depleted_ticket_card.dart +++ b/lib/tickets/my_tickets/ui/depleted_ticket_card.dart @@ -1,4 +1,4 @@ -import 'package:cafe_analog_app/tickets/my_tickets/ticket_card_base.dart'; +import 'package:cafe_analog_app/tickets/my_tickets/ui/ticket_card_base.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; diff --git a/lib/tickets/my_tickets/ui/my_tickets_section.dart b/lib/tickets/my_tickets/ui/my_tickets_section.dart new file mode 100644 index 0000000..c7b02d2 --- /dev/null +++ b/lib/tickets/my_tickets/ui/my_tickets_section.dart @@ -0,0 +1,75 @@ +import 'package:cafe_analog_app/tickets/my_tickets/bloc/owned_tickets_cubit.dart'; +import 'package:cafe_analog_app/tickets/my_tickets/data/owned_ticket.dart'; +import 'package:cafe_analog_app/tickets/my_tickets/ui/depleted_ticket_card.dart'; +import 'package:cafe_analog_app/tickets/my_tickets/ui/no_tickets_placeholder.dart'; +import 'package:cafe_analog_app/tickets/my_tickets/ui/owned_ticket_card.dart'; +import 'package:cafe_analog_app/tickets/use_ticket/ui/use_ticket_modal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +class MyTicketsSection extends StatelessWidget { + const MyTicketsSection({required this.ownedTickets, super.key}); + + final List ownedTickets; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final cubit = context.read(); + + return Padding( + padding: const EdgeInsets.all(16), + child: ownedTickets.isEmpty + ? const NoTicketsPlaceholder() + : ReorderableListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + onReorderStart: (_) => HapticFeedback.mediumImpact(), + onReorder: cubit.reorderTickets, + // By default the ProxyDecorator adds a drop shadow to the + // item being dragged, which we don't want because it exposes + // the card's rounded corners and bottom padding poorly + // against the background. We override it to display no shadow + proxyDecorator: (child, index, animation) => child, + itemCount: ownedTickets.length, + itemBuilder: (context, index) { + // A ticket that a user owns or has owned in the past. + final ticket = ownedTickets[index]; + + return Container( + key: ValueKey(ticket.productId), + margin: const EdgeInsets.only(bottom: 16), + child: !ticket.isDepleted + ? OwnedTicketCard.fromOwnedTicket( + ticket: ticket, + onTap: () => UseTicketModal.show( + context: context, + ticketId: ticket.productId, + ticketName: ticket.ticketName, + backgroundImagePath: ticket.backgroundImagePath, + ), + ) + : DepletedTicketCard( + id: ticket.productId, + ticketName: ticket.ticketName, + backgroundImagePath: + 'assets/images/beans_cropped.png', + onBuyMore: () => context.push( + '/tickets/buy/ticket/${ticket.ticketName}', + extra: ticket, + ), + onDismiss: () => cubit.dismissDepletedTicket( + ticket.productId, + ), + ), + ); + }, + ), + ); + }, + ); + } +} diff --git a/lib/tickets/my_tickets/no_tickets_placeholder.dart b/lib/tickets/my_tickets/ui/no_tickets_placeholder.dart similarity index 100% rename from lib/tickets/my_tickets/no_tickets_placeholder.dart rename to lib/tickets/my_tickets/ui/no_tickets_placeholder.dart diff --git a/lib/tickets/my_tickets/owned_ticket_card.dart b/lib/tickets/my_tickets/ui/owned_ticket_card.dart similarity index 70% rename from lib/tickets/my_tickets/owned_ticket_card.dart rename to lib/tickets/my_tickets/ui/owned_ticket_card.dart index dbe5bea..643b6ea 100644 --- a/lib/tickets/my_tickets/owned_ticket_card.dart +++ b/lib/tickets/my_tickets/ui/owned_ticket_card.dart @@ -1,4 +1,5 @@ -import 'package:cafe_analog_app/tickets/my_tickets/ticket_card_base.dart'; +import 'package:cafe_analog_app/tickets/my_tickets/data/owned_ticket.dart'; +import 'package:cafe_analog_app/tickets/my_tickets/ui/ticket_card_base.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; @@ -13,6 +14,20 @@ class OwnedTicketCard extends StatelessWidget { super.key, }); + factory OwnedTicketCard.fromOwnedTicket({ + required OwnedTicket ticket, + required void Function() onTap, + }) { + return OwnedTicketCard( + id: ticket.productId, + ticketName: ticket.ticketName, + ticketsLeft: ticket.ticketsLeft, + backgroundImagePath: ticket.backgroundImagePath, + icon: Icons.coffee, + onTap: onTap, + ); + } + final int id; final String ticketName; final int ticketsLeft; diff --git a/lib/tickets/my_tickets/ticket_card_base.dart b/lib/tickets/my_tickets/ui/ticket_card_base.dart similarity index 100% rename from lib/tickets/my_tickets/ticket_card_base.dart rename to lib/tickets/my_tickets/ui/ticket_card_base.dart diff --git a/lib/tickets/my_tickets/ui/tickets_screen.dart b/lib/tickets/my_tickets/ui/tickets_screen.dart new file mode 100644 index 0000000..9e86ac8 --- /dev/null +++ b/lib/tickets/my_tickets/ui/tickets_screen.dart @@ -0,0 +1,143 @@ +import 'dart:async'; + +import 'package:cafe_analog_app/core/widgets/analog_circular_progress_indicator.dart'; +import 'package:cafe_analog_app/core/widgets/screen.dart'; +import 'package:cafe_analog_app/tickets/my_tickets/bloc/owned_tickets_cubit.dart'; +import 'package:cafe_analog_app/tickets/my_tickets/data/owned_ticket.dart'; +import 'package:cafe_analog_app/tickets/my_tickets/data/owned_tickets_local_data_provider.dart'; +import 'package:cafe_analog_app/tickets/my_tickets/data/owned_tickets_remote_data_provider.dart'; +import 'package:cafe_analog_app/tickets/my_tickets/data/owned_tickets_repository.dart'; +import 'package:cafe_analog_app/tickets/my_tickets/data/ticket_order_data_provider.dart'; +import 'package:cafe_analog_app/tickets/my_tickets/ui/my_tickets_section.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +class TicketsScreen extends StatelessWidget { + const TicketsScreen({super.key}); + + @override + Widget build(BuildContext context) { + return _OwnedTicketsCubitProvider( + child: BlocBuilder( + builder: (context, state) { + return switch (state) { + OwnedTicketsInitial() => const Scaffold(), + OwnedTicketsLoading() => const _LoadingScreen(), + OwnedTicketsFailure(:final reason) => _FailureScreen(reason), + OwnedTicketsLoaded(:final ownedTickets) => _SuccessScreen( + ownedTickets, + ), + }; + }, + ), + ); + } +} + +class _SuccessScreen extends StatelessWidget { + const _SuccessScreen(this.ownedTickets); + + final List ownedTickets; + + Future _refreshTickets(BuildContext context) { + return context.read().getOwnedTickets(); + } + + @override + Widget build(BuildContext context) { + return Screen.listView( + name: 'Tickets', + onRefresh: () => _refreshTickets(context), + children: [ + MyTicketsSection(ownedTickets: ownedTickets), + const _BuyDrinkTicketsTile(), + const _RedeemCodeTile(), + ], + ); + } +} + +class _LoadingScreen extends StatelessWidget { + const _LoadingScreen(); + + @override + Widget build(BuildContext context) { + return const Screen.withBody( + name: 'Tickets', + body: Center(child: AnalogCircularProgressIndicator(spinnerColor: .dark)), + ); + } +} + +class _FailureScreen extends StatelessWidget { + const _FailureScreen(this.reason); + + final String reason; + + @override + Widget build(BuildContext context) { + return Screen.withBody( + name: 'Tickets', + body: Center(child: Text('Error loading tickets: $reason')), + ); + } +} + +class _OwnedTicketsCubitProvider extends StatelessWidget { + const _OwnedTicketsCubitProvider({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) { + final cubit = OwnedTicketsCubit( + ownedTicketsRepository: OwnedTicketsRepository( + ticketsRemoteDataProvider: OwnedTicketsRemoteDataProvider( + executor: context.read(), + ), + ticketsLocalDataProvider: OwnedTicketsLocalDataProvider( + localStorage: context.read(), + ), + ticketsOrderDataProvider: TicketOrderDataProvider( + localStorage: context.read(), + ), + ), + ); + unawaited(cubit.getOwnedTickets()); + return cubit; + }, + child: child, + ); + } +} + +class _BuyDrinkTicketsTile extends StatelessWidget { + const _BuyDrinkTicketsTile(); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: const Icon(Icons.local_cafe), + title: const Text('Buy drink tickets'), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push('/tickets/buy'), + ); + } +} + +class _RedeemCodeTile extends StatelessWidget { + const _RedeemCodeTile(); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: const Icon(Icons.card_giftcard), + title: const Text('Redeem a code'), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push('/tickets/redeem_voucher'), + ); + } +} diff --git a/lib/tickets/use_ticket/ui/animated_fade_switcher_sized.dart b/lib/tickets/use_ticket/ui/animated_fade_switcher_sized.dart new file mode 100644 index 0000000..ead6dd1 --- /dev/null +++ b/lib/tickets/use_ticket/ui/animated_fade_switcher_sized.dart @@ -0,0 +1,132 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +/// A widget that fades between two children while maintaining +/// the height of the tallest child. +/// +/// Unlike [AnimatedCrossFade], this widget doesn't animate size changes - +/// it always uses the maximum height of both children. +/// +/// Set [showSecond] to control which child is visible: +/// - false = [firstChild] fully visible +/// - true = [secondChild] fully visible +class AnimatedFadeSwitcherSized extends StatefulWidget { + const AnimatedFadeSwitcherSized({ + required this.showSecond, + required this.firstChild, + required this.secondChild, + super.key, + }); + + /// Whether to show the second child. + /// When false, [firstChild] is shown. When true, [secondChild] is shown. + final bool showSecond; + + /// The widget shown when [showSecond] is false. + final Widget firstChild; + + /// The widget shown when [showSecond] is true. + final Widget secondChild; + + @override + State createState() => + _AnimatedFadeSwitcherSizedState(); +} + +class _AnimatedFadeSwitcherSizedState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + static const _duration = Duration(milliseconds: 400); + static const _fadeOutInterval = Interval(0, 0.4, curve: Curves.easeOut); + static const _fadeInInterval = Interval(0.6, 1, curve: Curves.easeIn); + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: _duration, + value: widget.showSecond ? 1.0 : 0.0, + ); + } + + @override + void didUpdateWidget(AnimatedFadeSwitcherSized oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.showSecond != oldWidget.showSecond) { + if (widget.showSecond) { + unawaited(_controller.forward()); + } else { + unawaited(_controller.reverse()); + } + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return IntrinsicHeight( + child: Stack( + children: [ + // Second child (fades in) + _AnimatedFadeChild( + animation: _controller, + interval: _fadeInInterval, + child: widget.secondChild, + ), + // First child (fades out) + _AnimatedFadeChild( + animation: _controller, + interval: _fadeOutInterval, + invert: true, + child: widget.firstChild, + ), + ], + ), + ); + } +} + +class _AnimatedFadeChild extends StatelessWidget { + const _AnimatedFadeChild({ + required this.animation, + required this.interval, + required this.child, + this.invert = false, + }); + + final Animation animation; + final Interval interval; + final bool invert; + final Widget child; + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: animation, + builder: (context, child) { + final opacity = interval.transform(animation.value); + final effectiveOpacity = switch (invert) { + true => 1.0 - opacity, + false => opacity, + }; + + return IgnorePointer( + ignoring: effectiveOpacity < 0.5, + child: Opacity( + opacity: effectiveOpacity, + child: child, + ), + ); + }, + child: child, + ); + } +} diff --git a/lib/tickets/use_ticket/ui/next_button.dart b/lib/tickets/use_ticket/ui/next_button.dart new file mode 100644 index 0000000..2cab5c4 --- /dev/null +++ b/lib/tickets/use_ticket/ui/next_button.dart @@ -0,0 +1,25 @@ +// Modified version of https://github.com/imtoori/flutter-slide-to-act + +import 'package:flutter/material.dart'; + +class NextButton extends StatelessWidget { + const NextButton({this.onPressed, super.key}); + + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return IconButton.filledTonal( + style: ElevatedButton.styleFrom( + splashFactory: NoSplash.splashFactory, + foregroundColor: theme.colorScheme.onSurface, + backgroundColor: theme.colorScheme.surfaceContainerHighest, + ), + iconSize: 36, + onPressed: onPressed, + icon: const Icon(Icons.chevron_right), + ); + } +} diff --git a/lib/tickets/use_ticket/ui/select_menu_item_content.dart b/lib/tickets/use_ticket/ui/select_menu_item_content.dart new file mode 100644 index 0000000..35b13f9 --- /dev/null +++ b/lib/tickets/use_ticket/ui/select_menu_item_content.dart @@ -0,0 +1,58 @@ +part of 'use_ticket_card.dart'; + +/// Content shown when the user is selecting a menu item. +class _SelectMenuItemContent extends StatelessWidget { + const _SelectMenuItemContent({ + required this.menuItems, + required this.selectedMenuItem, + required this.onMenuItemSelected, + required this.onNextPressed, + }); + + final List menuItems; + final String? selectedMenuItem; + final ValueChanged onMenuItemSelected; + final VoidCallback? onNextPressed; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Gap(4), + Text( + 'Select a drink to spend your ticket on', + style: TextStyle(color: colorScheme.onSecondary), + ), + const Gap(24), + Row( + spacing: 16, + children: [ + Expanded( + child: DropdownMenu( + expandedInsets: EdgeInsets.zero, + enableSearch: false, + hintText: 'Select drink', + initialSelection: selectedMenuItem, + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: colorScheme.surfaceContainerHighest, + ), + dropdownMenuEntries: menuItems + .map( + (item) => DropdownMenuEntry(value: item, label: item), + ) + .toList(), + onSelected: onMenuItemSelected, + ), + ), + NextButton(onPressed: onNextPressed), + ], + ), + ], + ); + } +} diff --git a/lib/tickets/use_ticket/ui/slide_action.dart b/lib/tickets/use_ticket/ui/slide_action.dart new file mode 100644 index 0000000..74476cc --- /dev/null +++ b/lib/tickets/use_ticket/ui/slide_action.dart @@ -0,0 +1,224 @@ +// Modified version of https://github.com/imtoori/flutter-slide-to-act + +// Copyright (c) 2020 Salvatore Giordano +// Copyright (c) 2022 Omid Marfavi +// Copyright (c) 2026 AnalogIO + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import 'package:cafe_analog_app/tickets/use_ticket/ui/next_button.dart'; +import 'package:flutter/material.dart'; + +/// Slider call to action component +class SlideAction extends StatefulWidget { + /// Create a new instance of the widget + const SlideAction({ + required this.text, + super.key, + this.sliderButtonIconSize = 24, + this.sliderButtonIconPadding = 16, + this.horizontalPadding = 4, + this.height = 64, + this.outerColor, + this.borderRadius = 52, + this.animationDuration = const Duration(milliseconds: 300), + this.onSubmit, + this.child, + this.innerColor, + this.textStyle, + }); + + /// The size of the sliding icon + final double sliderButtonIconSize; + + /// Tha padding of the sliding icon + final double sliderButtonIconPadding; + + /// The horizontal gap between the inner circular button and external area. + final double horizontalPadding; + + /// The child that is rendered instead of the default Text widget + final Widget? child; + + /// The height of the component + final double height; + + /// The color of the inner circular button, of the tick icon of the text. + /// If not set, this attribute defaults to primaryIconTheme. + final Color? innerColor; + + /// The color of the external area and of the arrow icon. + /// If not set, this attribute defaults to accentColor from your theme. + final Color? outerColor; + + /// The text showed in the default Text widget + final String text; + + /// Text style which is applied on the Text widget. + /// + /// By default, the text is colored using [innerColor]. + final TextStyle? textStyle; + + /// The borderRadius of the sliding icon and of the background + final double borderRadius; + + /// Callback called on submit + /// If this is null the component will not animate to complete + final VoidCallback? onSubmit; + + /// The duration of the animations + final Duration animationDuration; + @override + SlideActionState createState() => SlideActionState(); +} + +/// Use a GlobalKey to access the state. +/// This is the only way to call [SlideActionState.reset] +class SlideActionState extends State + with TickerProviderStateMixin { + final GlobalKey _containerKey = GlobalKey(); + final GlobalKey _sliderKey = GlobalKey(); + double _dx = 0; + double _maxDx = 0; + double get _progress => _dx == 0 ? 0 : _dx / _maxDx; + double _endDx = 0; + double? _containerWidth; + late AnimationController _cancelAnimationController; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + key: _containerKey, + height: widget.height, + width: _containerWidth, + constraints: _containerWidth != null + ? null + : BoxConstraints.expand(height: widget.height), + child: Material( + color: widget.outerColor ?? theme.colorScheme.secondary, + borderRadius: BorderRadius.circular(widget.borderRadius), + child: Stack( + alignment: Alignment.center, + clipBehavior: Clip.none, + children: [ + Opacity( + opacity: _progress > 1 / 3 ? 0 : 1 - 3 * _progress, + child: + widget.child ?? + Text( + widget.text, + textAlign: TextAlign.center, + style: TextStyle( + color: widget.innerColor ?? theme.primaryIconTheme.color, + fontSize: 24, + ).merge(widget.textStyle), + ), + ), + Positioned( + left: widget.horizontalPadding, + child: Transform.translate( + offset: Offset(_dx, 0), + child: Container( + key: _sliderKey, + child: GestureDetector( + onHorizontalDragUpdate: onHorizontalDragUpdate, + onHorizontalDragEnd: (details) { + _endDx = _dx; + if (_progress <= 0.8 || widget.onSubmit == null) { + final _ = _cancelAnimation(); + } else { + widget.onSubmit!(); + } + }, + child: NextButton( + onPressed: () {}, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } + + void onHorizontalDragUpdate(DragUpdateDetails details) { + setState(() { + _dx = (_dx + details.delta.dx).clamp(0.0, _maxDx); + }); + } + + /// Call this method to revert the animations + Future reset() async { + final _ = await _cancelAnimation(); + } + + Future _cancelAnimation() async { + _cancelAnimationController.reset(); + final animation = + Tween( + begin: 0, + end: 1, + ).animate( + CurvedAnimation( + parent: _cancelAnimationController, + curve: Curves.fastOutSlowIn, + ), + ); + + animation.addListener(() { + if (mounted) { + setState(() { + _dx = _endDx - (_endDx * animation.value); + }); + } + }); + await _cancelAnimationController.forward(); + } + + @override + void initState() { + super.initState(); + + _cancelAnimationController = AnimationController( + vsync: this, + duration: widget.animationDuration, + ); + + WidgetsBinding.instance.addPostFrameCallback((_) { + final containerBox = + _containerKey.currentContext!.findRenderObject()! as RenderBox; + _containerWidth = containerBox.size.width; + + final sliderBox = + _sliderKey.currentContext!.findRenderObject()! as RenderBox; + final sliderWidth = sliderBox.size.width; + + _maxDx = _containerWidth! - sliderWidth - (widget.horizontalPadding * 2); + }); + } + + @override + void dispose() { + _cancelAnimationController.dispose(); + super.dispose(); + } +} diff --git a/lib/tickets/use_ticket/ui/swipe_ticket_content.dart b/lib/tickets/use_ticket/ui/swipe_ticket_content.dart new file mode 100644 index 0000000..1e29798 --- /dev/null +++ b/lib/tickets/use_ticket/ui/swipe_ticket_content.dart @@ -0,0 +1,34 @@ +part of 'use_ticket_card.dart'; + +/// Content shown when the user can swipe to use the ticket. +class _SwipeTicketContent extends StatelessWidget { + const _SwipeTicketContent({ + required this.ticketName, + }); + + final String ticketName; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Gap(4), + Text( + 'Claiming via ticket: $ticketName', + style: TextStyle(color: colorScheme.onSecondary), + ), + const Gap(24), + SlideAction( + text: 'Use ticket', + textStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + outerColor: colorScheme.onSurface, + innerColor: colorScheme.surfaceContainer, + ), + ], + ); + } +} diff --git a/lib/tickets/use_ticket/ui/use_ticket_card.dart b/lib/tickets/use_ticket/ui/use_ticket_card.dart new file mode 100644 index 0000000..79663d2 --- /dev/null +++ b/lib/tickets/use_ticket/ui/use_ticket_card.dart @@ -0,0 +1,70 @@ +import 'package:cafe_analog_app/tickets/my_tickets/ui/ticket_card_base.dart'; +import 'package:cafe_analog_app/tickets/use_ticket/ui/animated_fade_switcher_sized.dart'; +import 'package:cafe_analog_app/tickets/use_ticket/ui/next_button.dart'; +import 'package:cafe_analog_app/tickets/use_ticket/ui/slide_action.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; + +part 'select_menu_item_content.dart'; +part 'swipe_ticket_content.dart'; + +/// A ticket card that allows the user to select a menu item, +/// then swipe to use the ticket. +/// +/// Transitions between states with a fade animation. +class UseTicketCard extends StatefulWidget { + const UseTicketCard({ + required this.ticketId, + required this.ticketName, + required this.menuItems, + required this.backgroundImagePath, + super.key, + }); + + final int ticketId; + final String ticketName; + final List menuItems; + final String backgroundImagePath; + + @override + State createState() => _UseTicketCardState(); +} + +class _UseTicketCardState extends State { + bool _isSwiping = false; + String? _selectedMenuItem; + + @override + Widget build(BuildContext context) { + // FIXME(marfavi): When title goes from 2 lines to 1 line (or vice versa), + // the space between the title and the content is wrong when showing the + // title with fewer lines. Might be fixed by wrapping the entire + // card in AnimatedFadeSwitcherSized instead of the + // title/children separately. + return TicketCardBase( + id: widget.ticketId, + backgroundImagePath: widget.backgroundImagePath, + title: AnimatedFadeSwitcherSized( + showSecond: _isSwiping, + firstChild: Text(widget.ticketName), + secondChild: Text(_selectedMenuItem ?? ''), + ), + children: [ + AnimatedFadeSwitcherSized( + showSecond: _isSwiping, + firstChild: _SelectMenuItemContent( + menuItems: widget.menuItems, + selectedMenuItem: _selectedMenuItem, + onMenuItemSelected: (item) { + setState(() => _selectedMenuItem = item); + }, + onNextPressed: _selectedMenuItem != null + ? () => setState(() => _isSwiping = true) + : null, + ), + secondChild: _SwipeTicketContent(ticketName: widget.ticketName), + ), + ], + ); + } +} diff --git a/lib/tickets/use_ticket/ui/use_ticket_modal.dart b/lib/tickets/use_ticket/ui/use_ticket_modal.dart new file mode 100644 index 0000000..3ae30c3 --- /dev/null +++ b/lib/tickets/use_ticket/ui/use_ticket_modal.dart @@ -0,0 +1,71 @@ +import 'package:cafe_analog_app/core/widgets/delayed_fade_in.dart'; +import 'package:cafe_analog_app/tickets/use_ticket/ui/use_ticket_card.dart'; +import 'package:flutter/material.dart'; + +class UseTicketModal extends StatelessWidget { + const UseTicketModal({ + required this.ticketId, + required this.ticketName, + required this.backgroundImagePath, + super.key, + }); + + final int ticketId; + final String ticketName; + final String backgroundImagePath; + + static Future show({ + required BuildContext context, + required int ticketId, + required String ticketName, + required String backgroundImagePath, + }) async { + await Navigator.of(context, rootNavigator: true).push( + PageRouteBuilder( + barrierDismissible: true, + barrierLabel: 'Dismiss use ticket dialog', + opaque: false, + barrierColor: Theme.of(context).colorScheme.scrim.withAlpha(225), + pageBuilder: (context, animation, secondaryAnimation) => UseTicketModal( + ticketId: ticketId, + ticketName: ticketName, + backgroundImagePath: backgroundImagePath, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + DelayedFadeIn( + child: Text( + 'Confirm use of ticket\nTap outside this card to cancel', + semanticsLabel: + 'Confirm use of ticket. ' + 'Tap outside this card to cancel.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Colors.white, + ), + ), + ), + Padding( + padding: const EdgeInsets.all(16), + // Absorb taps on the card so they don't close the modal + child: UseTicketCard( + ticketId: ticketId, + ticketName: ticketName, + backgroundImagePath: backgroundImagePath, + // TODO(marfavi): get menu items from backend + menuItems: const ['Espresso', 'Latte', 'Cappuccino'], + ), + ), + ], + ), + ); + } +} diff --git a/lib/tickets/use_ticket/use_ticket_card.dart b/lib/tickets/use_ticket/use_ticket_card.dart index 006aa7e..beb3614 100644 --- a/lib/tickets/use_ticket/use_ticket_card.dart +++ b/lib/tickets/use_ticket/use_ticket_card.dart @@ -1,4 +1,4 @@ -import 'package:cafe_analog_app/tickets/my_tickets/ticket_card_base.dart'; +import 'package:cafe_analog_app/tickets/my_tickets/ui/ticket_card_base.dart'; import 'package:cafe_analog_app/tickets/use_ticket/animated_fade_switcher_sized.dart'; import 'package:cafe_analog_app/tickets/use_ticket/next_button.dart'; import 'package:cafe_analog_app/tickets/use_ticket/slide_action.dart'; diff --git a/pubspec.lock b/pubspec.lock index c72ba0e..e0b6790 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -178,7 +178,7 @@ packages: source: hosted version: "4.11.1" collection: - dependency: transitive + dependency: "direct main" description: name: collection sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" @@ -717,6 +717,62 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f + url: "https://pub.dev" + source: hosted + version: "2.4.20" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" shelf: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 9a20d95..f36a889 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -24,6 +24,8 @@ dependencies: gap: ^3.0.1 dotted_border: ^3.1.0 flutter_secure_storage: ^10.0.0 + collection: ^1.19.1 + shared_preferences: ^2.5.4 dev_dependencies: bloc_test: ^10.0.0 @@ -42,4 +44,4 @@ flutter: uses-material-design: true assets: - - assets/images/ \ No newline at end of file + - assets/images/ diff --git a/test/app_test.dart b/test/app_test.dart index 5fde341..74562d2 100644 --- a/test/app_test.dart +++ b/test/app_test.dart @@ -1,9 +1,16 @@ import 'package:cafe_analog_app/app/app.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class MockSharedPreferencesWithCache extends Mock + implements SharedPreferencesWithCache {} void main() { testWidgets('App can be instantiated', (tester) async { - await tester.pumpWidget(const App()); + await tester.pumpWidget( + App(localStorage: MockSharedPreferencesWithCache()), + ); expect(find.byType(App), findsOneWidget); }); } diff --git a/test/authentication_cubit_test.dart b/test/authentication_cubit_test.dart index 933ce38..26c66f2 100644 --- a/test/authentication_cubit_test.dart +++ b/test/authentication_cubit_test.dart @@ -133,138 +133,3 @@ void main() { ); }); } -import 'package:bloc_test/bloc_test.dart'; -import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; -import 'package:cafe_analog_app/login/data/authentication_token_repository.dart'; -import 'package:cafe_analog_app/login/data/authentication_tokens.dart'; -import 'package:cafe_analog_app/login/data/login_repository.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:fpdart/fpdart.dart'; -import 'package:mocktail/mocktail.dart'; - -class _MockAuthRepository extends Mock implements AuthTokenRepository {} - -class _MockLoginRepository extends Mock implements LoginRepository {} - -void main() { - late _MockAuthRepository authTokenRepository; - late _MockLoginRepository loginRepository; - - setUp(() { - authTokenRepository = _MockAuthRepository(); - loginRepository = _MockLoginRepository(); - }); - - group('AuthCubit', () { - blocTest( - 'emits [Loading, Authenticated] when started ' - 'and AuthRepository reports logged in', - build: () { - when(() => authTokenRepository.getTokens()).thenReturn( - TaskEither.right( - some(const AuthTokens(jwt: 'JWT-TOKEN', refreshToken: 'REF')), - ), - ); - return AuthCubit( - authTokenRepository: authTokenRepository, - loginRepository: loginRepository, - ); - }, - act: (cubit) => cubit.start(), - expect: () => [ - isA(), - isA().having( - (s) => s.tokens.jwt, - 'jwt', - 'JWT-TOKEN', - ), - ], - ); - - blocTest( - 'emits [Loading, Unauthenticated] when started and not logged in', - build: () { - when( - () => authTokenRepository.getTokens(), - ).thenReturn(TaskEither.right(none())); - return AuthCubit( - authTokenRepository: authTokenRepository, - loginRepository: loginRepository, - ); - }, - act: (cubit) => cubit.start(), - expect: () => [ - isA(), - isA(), - ], - ); - - blocTest( - 'emits [Loading, Authenticated] ' - 'when authenticateWithMagicLinkToken succeeds', - setUp: () { - when( - () => loginRepository.authenticateWithMagicLinkToken('TOKEN'), - ).thenReturn( - TaskEither.right( - const AuthTokens(jwt: 'PROVIDED-JWT', refreshToken: 'REF'), - ), - ); - when( - () => authTokenRepository.saveTokens( - const AuthTokens(jwt: 'PROVIDED-JWT', refreshToken: 'REF'), - ), - ).thenReturn( - TaskEither.right( - const AuthTokens(jwt: 'PROVIDED-JWT', refreshToken: 'REF'), - ), - ); - }, - build: () => AuthCubit( - authTokenRepository: authTokenRepository, - loginRepository: loginRepository, - ), - act: (cubit) => cubit.authenticateWithToken(magicLinkToken: 'TOKEN'), - verify: (_) { - verify( - () => loginRepository.authenticateWithMagicLinkToken('TOKEN'), - ).called(1); - verify( - () => authTokenRepository.saveTokens( - const AuthTokens(jwt: 'PROVIDED-JWT', refreshToken: 'REF'), - ), - ).called(1); - }, - expect: () => [ - isA(), - isA().having( - (s) => s.tokens.jwt, - 'jwt', - 'PROVIDED-JWT', - ), - ], - ); - - blocTest( - 'emits [Loading, Unauthenticated] when logged out ' - 'and clears tokens', - build: () { - when( - () => authTokenRepository.clearTokens(), - ).thenReturn(TaskEither.right(unit)); - return AuthCubit( - authTokenRepository: authTokenRepository, - loginRepository: loginRepository, - ); - }, - act: (cubit) => cubit.logOut(), - verify: (_) { - verify(() => authTokenRepository.clearTokens()).called(1); - }, - expect: () => [ - isA(), - isA(), - ], - ); - }); -} diff --git a/test/router_test.dart b/test/router_test.dart index 5448a44..bfe2053 100644 --- a/test/router_test.dart +++ b/test/router_test.dart @@ -271,276 +271,3 @@ void main() { }, ); } -import 'dart:async'; - -import 'package:bloc_test/bloc_test.dart'; -import 'package:cafe_analog_app/app/router.dart'; -import 'package:cafe_analog_app/core/widgets/analog_circular_progress_indicator.dart'; -import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; -import 'package:cafe_analog_app/login/data/authentication_tokens.dart'; -import 'package:cafe_analog_app/login/ui/authentication_navigator.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:go_router/go_router.dart'; -import 'package:mocktail/mocktail.dart'; - -class _MockAuthCubit extends Mock implements AuthCubit {} - -void main() { - late _MockAuthCubit mockAuth; - late final goRouter = AnalogGoRouter.instance.goRouter; - - setUpAll(() { - registerFallbackValue(const AuthInitial()); - }); - - setUp(() { - mockAuth = _MockAuthCubit(); - }); - - tearDown(() { - // reset router back to root to avoid leaking state between tests - goRouter.go('/'); - }); - - testWidgets( - 'redirects to /login when not logged in', - (tester) async { - when(() => mockAuth.state).thenReturn(const AuthUnauthenticated()); - whenListen( - mockAuth, - Stream.value(const AuthUnauthenticated()), - initialState: const AuthUnauthenticated(), - ); - - await tester.pumpWidget( - BlocProvider.value( - value: mockAuth, - child: MaterialApp.router( - routerConfig: goRouter, - builder: (context, child) => - Scaffold(body: child ?? const SizedBox()), - ), - ), - ); - - // Try to navigate to a protected route - goRouter.go('/tickets'); - // Allow one frame for onEnter to run and show SnackBar without waiting - // for its duration - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - - // Navigation is blocked by onEnter; ensure snackbar is shown - expect(find.text('Please log in to continue.'), findsOneWidget); - - // allow any pending timers to clean up - await tester.pump(const Duration(seconds: 1)); - }, - ); - - testWidgets( - 'blocks navigation to /login when already logged in and in main area', - (tester) async { - when(() => mockAuth.state).thenReturn( - const AuthAuthenticated( - tokens: AuthTokens(jwt: 'j', refreshToken: 'r'), - ), - ); - whenListen( - mockAuth, - Stream.value(mockAuth.state), - initialState: mockAuth.state, - ); - - await tester.pumpWidget( - BlocProvider.value( - value: mockAuth, - child: MaterialApp.router( - routerConfig: goRouter, - builder: (context, child) => - Scaffold(body: child ?? const SizedBox()), - ), - ), - ); - - // start at /tickets - goRouter.go('/tickets'); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - - // attempt to navigate into login - goRouter.go('/login'); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - - // Tickets screen should still be visible and SnackBar shown - expect(find.text('Tickets'), findsWidgets); - expect(find.text('You are already logged in.'), findsOneWidget); - - // allow any pending timers to clean up - await tester.pump(const Duration(seconds: 1)); - }, - ); - - testWidgets( - 'shows and hides loading overlay based on AuthLoading', - (tester) async { - when(() => mockAuth.state).thenReturn(const AuthInitial()); - - // Stream controller lets us emit states with precise timing. - final ctl = StreamController(); - whenListen( - mockAuth, - ctl.stream, - initialState: const AuthInitial(), - ); - - // Use a fresh local GoRouter so AuthNavigator can call context.go safely - final testRouter = GoRouter( - routes: [ - GoRoute( - path: '/', - builder: (context, state) => const AuthNavigator(child: SizedBox()), - ), - GoRoute( - path: '/login', - builder: (context, state) => const SizedBox(), - ), - GoRoute( - path: '/tickets', - builder: (context, state) => const SizedBox(), - ), - ], - ); - - await tester.pumpWidget( - BlocProvider.value( - value: mockAuth, - child: MaterialApp.router( - routerConfig: testRouter, - builder: (context, child) => - Scaffold(body: child ?? const SizedBox()), - ), - ), - ); - - // Let initial frame build - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - - // Count existing modal barriers and indicators - final preModalCount = tester.widgetList(find.byType(ModalBarrier)).length; - final preIndicatorCount = tester - .widgetList(find.byType(AnalogCircularProgressIndicator)) - .length; - - // Emit loading, wait a frame for the dialog to be shown - ctl.add(const AuthLoading()); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - - // Loading overlay should have added at least one modal barrier/indicator - final postModalCount = tester - .widgetList(find.byType(ModalBarrier)) - .length; - final postIndicatorCount = tester - .widgetList(find.byType(AnalogCircularProgressIndicator)) - .length; - - expect(postModalCount, greaterThan(preModalCount)); - expect(postIndicatorCount, greaterThan(preIndicatorCount)); - - // Emit a non-loading state to dismiss the overlay - ctl.add(const AuthUnauthenticated()); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - - final finalModalCount = tester - .widgetList(find.byType(ModalBarrier)) - .length; - final finalIndicatorCount = tester - .widgetList(find.byType(AnalogCircularProgressIndicator)) - .length; - - // The modal count might not return exactly to preModalCount due to other - // modals, but the number of indicators should drop compared to when - // loading was shown. - expect(finalIndicatorCount, lessThan(postIndicatorCount)); - expect(finalModalCount, greaterThanOrEqualTo(preModalCount)); - await ctl.close(); - - // allow any pending timers (e.g., DelayedFadeIn) to finish - await tester.pump(const Duration(seconds: 1)); - }, - ); - - testWidgets( - 'shows snackbar on AuthFailure', - (tester) async { - when(() => mockAuth.state).thenReturn(const AuthInitial()); - whenListen( - mockAuth, - Stream.fromIterable([ - const AuthInitial(), - const AuthFailure(reason: 'bad'), - ]), - initialState: const AuthInitial(), - ); - - await tester.pumpWidget( - BlocProvider.value( - value: mockAuth, - child: MaterialApp.router( - routerConfig: goRouter, - builder: (context, child) => - Scaffold(body: child ?? const SizedBox()), - ), - ), - ); - - await tester.pumpAndSettle(); - expect(find.text('Authentication failed: bad'), findsOneWidget); - - // Allow any delayed timers (e.g., DelayedFadeIn) to finish - await tester.pump(const Duration(seconds: 1)); - }, - ); - - testWidgets( - 'navigates to email-sent when AuthEmailSent is emitted', - (tester) async { - when(() => mockAuth.state).thenReturn(const AuthInitial()); - whenListen( - mockAuth, - Stream.fromIterable([ - const AuthInitial(), - const AuthEmailSent(email: 'user@example.com'), - ]), - initialState: const AuthInitial(), - ); - - await tester.pumpWidget( - BlocProvider.value( - value: mockAuth, - child: MaterialApp.router( - routerConfig: goRouter, - builder: (context, child) => - Scaffold(body: child ?? const SizedBox()), - ), - ), - ); - - // Wait for navigation and the screen to settle - await tester.pumpAndSettle(); - - // Verify the EmailSentScreen content is shown - expect(find.text('Check your email'), findsOneWidget); - expect(find.text('user@example.com'), findsOneWidget); - - // Allow any timers (cooldown) to start/finish - await tester.pump(const Duration(seconds: 1)); - }, - ); -} From 9f19ddcaf47bbfa678831b2c18e843ae6dfb24f1 Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Mon, 9 Feb 2026 19:17:26 +0100 Subject: [PATCH 21/23] fix analyse errors --- .../my_tickets/bloc/owned_tickets_cubit.dart | 3 +-- lib/tickets/my_tickets/data/owned_ticket.dart | 20 +++++++++---------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/lib/tickets/my_tickets/bloc/owned_tickets_cubit.dart b/lib/tickets/my_tickets/bloc/owned_tickets_cubit.dart index 35decb6..dde2b67 100644 --- a/lib/tickets/my_tickets/bloc/owned_tickets_cubit.dart +++ b/lib/tickets/my_tickets/bloc/owned_tickets_cubit.dart @@ -27,10 +27,9 @@ class OwnedTicketsCubit extends Cubit { } // FIXME(marfavi): Implement logic to apply preferred order to tickets - // Attempt to get preferred order from storage - // ignore: unused_local_variable final preferredOrder = await _ownedTicketsRepository + // Attempt to get preferred order from storage .getTicketOrder() .getOrElse((_) => const []) .run(); diff --git a/lib/tickets/my_tickets/data/owned_ticket.dart b/lib/tickets/my_tickets/data/owned_ticket.dart index 1cb5e8f..5b8415d 100644 --- a/lib/tickets/my_tickets/data/owned_ticket.dart +++ b/lib/tickets/my_tickets/data/owned_ticket.dart @@ -8,6 +8,16 @@ class OwnedTicket extends Equatable { required this.backgroundImagePath, }); + // FIXME(marfavi): Use json_serializable instead? + factory OwnedTicket.fromJson(Map json) { + return OwnedTicket( + productId: json['productId'] as int, + ticketName: json['ticketName'] as String, + ticketsLeft: json['ticketsLeft'] as int, + backgroundImagePath: json['backgroundImagePath'] as String, + ); + } + final int productId; final String ticketName; final int ticketsLeft; @@ -29,14 +39,4 @@ class OwnedTicket extends Equatable { 'ticketsLeft': ticketsLeft, 'backgroundImagePath': backgroundImagePath, }; - - // FIXME(marfavi): Use json_serializable instead? - static OwnedTicket fromJson(Map json) { - return OwnedTicket( - productId: json['productId'] as int, - ticketName: json['ticketName'] as String, - ticketsLeft: json['ticketsLeft'] as int, - backgroundImagePath: json['backgroundImagePath'] as String, - ); - } } From a3f7fde0e846b120e2f4323bf6e9ada237936992 Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Mon, 9 Feb 2026 19:43:43 +0100 Subject: [PATCH 22/23] Fix tests failing in router_test.dart --- test/router_test.dart | 218 ++++++++++++++++-------------------------- 1 file changed, 85 insertions(+), 133 deletions(-) diff --git a/test/router_test.dart b/test/router_test.dart index bfe2053..e7907c2 100644 --- a/test/router_test.dart +++ b/test/router_test.dart @@ -2,20 +2,36 @@ import 'dart:async'; import 'package:bloc_test/bloc_test.dart'; import 'package:cafe_analog_app/app/router.dart'; +import 'package:cafe_analog_app/core/failures.dart'; +import 'package:cafe_analog_app/core/network_request_executor.dart'; import 'package:cafe_analog_app/core/widgets/analog_circular_progress_indicator.dart'; import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; import 'package:cafe_analog_app/login/data/authentication_tokens.dart'; import 'package:cafe_analog_app/login/ui/authentication_navigator.dart'; +import 'package:chopper/chopper.dart' show Response; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:fpdart/fpdart.dart'; import 'package:go_router/go_router.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; class _MockAuthCubit extends Mock implements AuthCubit {} +class _MockExecutor extends Mock implements NetworkRequestExecutor { + @override + TaskEither run(Future> Function(ApiClients _) _) => + TaskEither.left(const ConnectionFailure()); +} + +class _MockSharedPreferencesWithCache extends Mock + implements SharedPreferencesWithCache {} + void main() { - late _MockAuthCubit mockAuth; + late _MockAuthCubit mockAuthCubit; + late _MockExecutor mockExecutor; + late _MockSharedPreferencesWithCache mockLocalStorage; late final goRouter = AnalogGoRouter.instance.goRouter; setUpAll(() { @@ -23,90 +39,82 @@ void main() { }); setUp(() { - mockAuth = _MockAuthCubit(); + mockAuthCubit = _MockAuthCubit(); + mockExecutor = _MockExecutor(); + mockLocalStorage = _MockSharedPreferencesWithCache(); }); tearDown(() { - // reset router back to root to avoid leaking state between tests goRouter.go('/'); }); - testWidgets( - 'redirects to /login when not logged in', - (tester) async { - when(() => mockAuth.state).thenReturn(const AuthUnauthenticated()); - whenListen( - mockAuth, - Stream.value(const AuthUnauthenticated()), - initialState: const AuthUnauthenticated(), - ); - - await tester.pumpWidget( - BlocProvider.value( - value: mockAuth, - child: MaterialApp.router( - routerConfig: goRouter, - builder: (context, child) => - Scaffold(body: child ?? const SizedBox()), - ), + /// Wraps [child] with the repository and bloc providers needed by the + /// singleton [goRouter] (whose indexed-stack may materialise TicketsScreen). + Widget buildApp({required GoRouter router, Widget? child}) { + return MultiRepositoryProvider( + providers: [ + RepositoryProvider.value(value: mockExecutor), + RepositoryProvider.value( + value: mockLocalStorage, ), - ); + ], + child: BlocProvider.value( + value: mockAuthCubit, + child: MaterialApp.router( + routerConfig: router, + builder: (context, child) => + Scaffold(body: child ?? const SizedBox()), + ), + ), + ); + } - // Try to navigate to a protected route - goRouter.go('/tickets'); - // Allow one frame for onEnter to run and show SnackBar without waiting - // for its duration - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); + testWidgets('redirects to /login when not logged in', (tester) async { + when(() => mockAuthCubit.state).thenReturn(const AuthUnauthenticated()); + whenListen( + mockAuthCubit, + Stream.value(const AuthUnauthenticated()), + initialState: const AuthUnauthenticated(), + ); - // Navigation is blocked by onEnter; ensure snackbar is shown - expect(find.text('Please log in to continue.'), findsOneWidget); + await tester.pumpWidget(buildApp(router: goRouter)); - // allow any pending timers to clean up - await tester.pump(const Duration(seconds: 1)); - }, - ); + goRouter.go('/tickets'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text('Please log in to continue.'), findsOneWidget); + + await tester.pump(const Duration(seconds: 1)); + }); testWidgets( 'blocks navigation to /login when already logged in and in main area', (tester) async { - when(() => mockAuth.state).thenReturn( + when(() => mockAuthCubit.state).thenReturn( const AuthAuthenticated( tokens: AuthTokens(jwt: 'j', refreshToken: 'r'), ), ); whenListen( - mockAuth, - Stream.value(mockAuth.state), - initialState: mockAuth.state, + mockAuthCubit, + Stream.value(mockAuthCubit.state), + initialState: mockAuthCubit.state, ); - await tester.pumpWidget( - BlocProvider.value( - value: mockAuth, - child: MaterialApp.router( - routerConfig: goRouter, - builder: (context, child) => - Scaffold(body: child ?? const SizedBox()), - ), - ), - ); + await tester.pumpWidget(buildApp(router: goRouter)); - // start at /tickets goRouter.go('/tickets'); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); - // attempt to navigate into login goRouter.go('/login'); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); - // Tickets screen should still be visible and SnackBar shown expect(find.text('Tickets'), findsWidgets); expect(find.text('You are already logged in.'), findsOneWidget); - // allow any pending timers to clean up await tester.pump(const Duration(seconds: 1)); }, ); @@ -114,61 +122,36 @@ void main() { testWidgets( 'shows and hides loading overlay based on AuthLoading', (tester) async { - when(() => mockAuth.state).thenReturn(const AuthInitial()); + when(() => mockAuthCubit.state).thenReturn(const AuthInitial()); - // Stream controller lets us emit states with precise timing. final ctl = StreamController(); - whenListen( - mockAuth, - ctl.stream, - initialState: const AuthInitial(), - ); + whenListen(mockAuthCubit, ctl.stream, initialState: const AuthInitial()); // Use a fresh local GoRouter so AuthNavigator can call context.go safely final testRouter = GoRouter( routes: [ GoRoute( path: '/', - builder: (context, state) => const AuthNavigator(child: SizedBox()), - ), - GoRoute( - path: '/login', - builder: (context, state) => const SizedBox(), - ), - GoRoute( - path: '/tickets', - builder: (context, state) => const SizedBox(), + builder: (_, _) => const AuthNavigator(child: SizedBox()), ), + GoRoute(path: '/login', builder: (_, _) => const SizedBox()), + GoRoute(path: '/tickets', builder: (_, _) => const SizedBox()), ], ); - await tester.pumpWidget( - BlocProvider.value( - value: mockAuth, - child: MaterialApp.router( - routerConfig: testRouter, - builder: (context, child) => - Scaffold(body: child ?? const SizedBox()), - ), - ), - ); - - // Let initial frame build + await tester.pumpWidget(buildApp(router: testRouter)); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); - // Count existing modal barriers and indicators final preModalCount = tester.widgetList(find.byType(ModalBarrier)).length; final preIndicatorCount = tester .widgetList(find.byType(AnalogCircularProgressIndicator)) .length; - // Emit loading, wait a frame for the dialog to be shown ctl.add(const AuthLoading()); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); - // Loading overlay should have added at least one modal barrier/indicator final postModalCount = tester .widgetList(find.byType(ModalBarrier)) .length; @@ -179,7 +162,6 @@ void main() { expect(postModalCount, greaterThan(preModalCount)); expect(postIndicatorCount, greaterThan(preIndicatorCount)); - // Emit a non-loading state to dismiss the overlay ctl.add(const AuthUnauthenticated()); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); @@ -191,56 +173,39 @@ void main() { .widgetList(find.byType(AnalogCircularProgressIndicator)) .length; - // The modal count might not return exactly to preModalCount due to other - // modals, but the number of indicators should drop compared to when - // loading was shown. expect(finalIndicatorCount, lessThan(postIndicatorCount)); expect(finalModalCount, greaterThanOrEqualTo(preModalCount)); - await ctl.close(); - // allow any pending timers (e.g., DelayedFadeIn) to finish + await ctl.close(); await tester.pump(const Duration(seconds: 1)); }, ); - testWidgets( - 'shows snackbar on AuthFailure', - (tester) async { - when(() => mockAuth.state).thenReturn(const AuthInitial()); - whenListen( - mockAuth, - Stream.fromIterable([ - const AuthInitial(), - const AuthFailure(reason: 'bad'), - ]), - initialState: const AuthInitial(), - ); + testWidgets('shows snackbar on AuthFailure', (tester) async { + when(() => mockAuthCubit.state).thenReturn(const AuthInitial()); + whenListen( + mockAuthCubit, + Stream.fromIterable([ + const AuthInitial(), + const AuthFailure(reason: 'bad'), + ]), + initialState: const AuthInitial(), + ); - await tester.pumpWidget( - BlocProvider.value( - value: mockAuth, - child: MaterialApp.router( - routerConfig: goRouter, - builder: (context, child) => - Scaffold(body: child ?? const SizedBox()), - ), - ), - ); + await tester.pumpWidget(buildApp(router: goRouter)); + await tester.pumpAndSettle(); - await tester.pumpAndSettle(); - expect(find.text('Authentication failed: bad'), findsOneWidget); + expect(find.text('Authentication failed: bad'), findsOneWidget); - // Allow any delayed timers (e.g., DelayedFadeIn) to finish - await tester.pump(const Duration(seconds: 1)); - }, - ); + await tester.pump(const Duration(seconds: 1)); + }); testWidgets( 'navigates to email-sent when AuthEmailSent is emitted', (tester) async { - when(() => mockAuth.state).thenReturn(const AuthInitial()); + when(() => mockAuthCubit.state).thenReturn(const AuthInitial()); whenListen( - mockAuth, + mockAuthCubit, Stream.fromIterable([ const AuthInitial(), const AuthEmailSent(email: 'user@example.com'), @@ -248,25 +213,12 @@ void main() { initialState: const AuthInitial(), ); - await tester.pumpWidget( - BlocProvider.value( - value: mockAuth, - child: MaterialApp.router( - routerConfig: goRouter, - builder: (context, child) => - Scaffold(body: child ?? const SizedBox()), - ), - ), - ); - - // Wait for navigation and the screen to settle + await tester.pumpWidget(buildApp(router: goRouter)); await tester.pumpAndSettle(); - // Verify the EmailSentScreen content is shown expect(find.text('Check your email'), findsOneWidget); expect(find.text('user@example.com'), findsOneWidget); - // Allow any timers (cooldown) to start/finish await tester.pump(const Duration(seconds: 1)); }, ); From a86148520dcf46a59699c5e53d29ebb41155a9d7 Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Mon, 9 Feb 2026 19:45:36 +0100 Subject: [PATCH 23/23] add coverage target in Makefile --- Makefile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Makefile b/Makefile index e2aa5d2..0e3b798 100644 --- a/Makefile +++ b/Makefile @@ -38,6 +38,10 @@ swagger: ## Fetch latest Swagger API specs curl -o swagger/coffeecard_api_v1.json https://core.dev.analogio.dk/swagger/v1/swagger.json curl -o swagger/coffeecard_api_v2.json https://core.dev.analogio.dk/swagger/v2/swagger.json +coverage: ## Run tests with coverage and generate report + flutter test --coverage + genhtml coverage/lcov.info -o coverage/html + fix: ## Format code and fix issues that can be fixed automatically dart format . dart fix --apply