diff --git a/Makefile b/Makefile index 54ad4cb..0e3b798 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,11 @@ 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 + +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 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 ac5dbe7..3f82d85 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/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 f89bc82..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'; @@ -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), @@ -79,7 +79,7 @@ class AuthCubit extends Cubit { final newState = await authenticateEither.match( (didNotAuth) async => AuthFailure(reason: didNotAuth.reason), (tokens) async { - final saveEither = await _authTokenRepository.saveTokens(tokens).run(); + final saveEither = await _authRepository.saveTokens(tokens).run(); return saveEither.match( (couldNotSave) => AuthFailure(reason: couldNotSave.reason), (savedTokens) => AuthAuthenticated(tokens: savedTokens), 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/login_repository.dart b/lib/login/data/login_repository.dart index 35e0347..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,7 +27,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/login/ui/login_screen.dart b/lib/login/ui/login_screen.dart index e056820..f2e6100 100644 --- a/lib/login/ui/login_screen.dart +++ b/lib/login/ui/login_screen.dart @@ -30,23 +30,24 @@ class _LoginScreenState extends State { child: Column( children: [ Flexible( - fit: .tight, - child: FittedBox( - fit: .scaleDown, - child: Column( - mainAxisSize: .min, - children: [ - Text( - 'Café Analog', - style: textTheme.headlineLarge?.copyWith( - fontWeight: .w900, + 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, - ), - ], + Text( + 'Enter your email to continue', + style: textTheme.bodyMedium, + ), + ], + ), ), ), ), 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/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..dde2b67 --- /dev/null +++ b/lib/tickets/my_tickets/bloc/owned_tickets_cubit.dart @@ -0,0 +1,112 @@ +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 + // ignore: unused_local_variable + final preferredOrder = await _ownedTicketsRepository + // Attempt to get preferred order from storage + .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..5b8415d --- /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, + }); + + // 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; + 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, + }; +} 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/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)); }, );