From e17f42b630ebf4426084dbfbb366c8d0936be17f Mon Sep 17 00:00:00 2001 From: Omid Mirzaei Date: Mon, 20 Jul 2026 17:40:00 +0400 Subject: [PATCH] restructure mobile into feature shell architecture --- docs/architecture.md | 17 +- mobile/lib/app.dart | 35 ++- mobile/lib/core/di/injection.dart | 28 ++- mobile/lib/core/error/app_exception.dart | 10 + mobile/lib/core/error/error_mapper.dart | 52 +++++ mobile/lib/core/network/request_options.dart | 3 + mobile/lib/core/router/app_router.dart | 50 ++-- mobile/lib/core/widgets/app_bottom_nav.dart | 39 ---- ...ository.dart => auth_repository_impl.dart} | 45 ++-- .../features/auth/domain/auth_repository.dart | 19 ++ .../auth/presentation/cubit/auth_cubit.dart | 22 +- .../auth/presentation/login_page.dart | 76 +++++- .../features/events/data/event_models.dart | 36 +++ .../events/data/events_repository.dart | 12 - .../events/data/events_repository_impl.dart | 17 ++ .../events/domain/events_repository.dart | 7 + .../cubit/event_detail_cubit.dart | 6 +- .../presentation/cubit/events_cubit.dart | 21 +- .../presentation/event_detail_page.dart | 220 ++++++++++++++++-- .../events/presentation/events_page.dart | 13 +- .../explain/data/explain_repository.dart | 11 - .../explain/data/explain_repository_impl.dart | 14 ++ .../explain/domain/explain_repository.dart | 5 + .../presentation/cubit/explain_cubit.dart | 20 +- mobile/lib/features/ops/data/health_api.dart | 6 +- .../ops/data/ops_repository_impl.dart | 13 ++ .../features/ops/domain/ops_repository.dart | 5 + .../presentation/cubit/ops_health_cubit.dart | 56 +++++ .../presentation/cubit/ops_health_state.dart | 35 +++ .../ops/presentation/ops_status_bar.dart | 151 ------------ .../presentation/widgets/ops_status_bar.dart | 117 ++++++++++ ...sitory.dart => rules_repository_impl.dart} | 32 ++- .../rules/domain/rules_repository.dart | 23 ++ .../rules/presentation/cubit/rules_cubit.dart | 26 +-- .../rules/presentation/rules_page.dart | 18 +- .../settings/presentation/settings_page.dart | 89 +++++-- .../shell/presentation/app_shell.dart | 45 ++++ mobile/lib/main.dart | 5 + mobile/test/auth_gate_test.dart | 12 +- mobile/test/auth_login_integration_test.dart | 4 +- mobile/test/events_list_test.dart | 5 +- .../events_rules_api_integration_test.dart | 14 +- mobile/test/explain_api_integration_test.dart | 8 +- 43 files changed, 1037 insertions(+), 405 deletions(-) create mode 100644 mobile/lib/core/error/app_exception.dart create mode 100644 mobile/lib/core/error/error_mapper.dart create mode 100644 mobile/lib/core/network/request_options.dart delete mode 100644 mobile/lib/core/widgets/app_bottom_nav.dart rename mobile/lib/features/auth/data/{auth_repository.dart => auth_repository_impl.dart} (50%) create mode 100644 mobile/lib/features/auth/domain/auth_repository.dart delete mode 100644 mobile/lib/features/events/data/events_repository.dart create mode 100644 mobile/lib/features/events/data/events_repository_impl.dart create mode 100644 mobile/lib/features/events/domain/events_repository.dart delete mode 100644 mobile/lib/features/explain/data/explain_repository.dart create mode 100644 mobile/lib/features/explain/data/explain_repository_impl.dart create mode 100644 mobile/lib/features/explain/domain/explain_repository.dart create mode 100644 mobile/lib/features/ops/data/ops_repository_impl.dart create mode 100644 mobile/lib/features/ops/domain/ops_repository.dart create mode 100644 mobile/lib/features/ops/presentation/cubit/ops_health_cubit.dart create mode 100644 mobile/lib/features/ops/presentation/cubit/ops_health_state.dart delete mode 100644 mobile/lib/features/ops/presentation/ops_status_bar.dart create mode 100644 mobile/lib/features/ops/presentation/widgets/ops_status_bar.dart rename mobile/lib/features/rules/data/{rules_repository.dart => rules_repository_impl.dart} (56%) create mode 100644 mobile/lib/features/rules/domain/rules_repository.dart create mode 100644 mobile/lib/features/shell/presentation/app_shell.dart diff --git a/docs/architecture.md b/docs/architecture.md index 2b73041..954e267 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -68,10 +68,17 @@ Default lease: 45s (`events.ClaimLease`). Health exposes queue counts via `GET / ## Packages (mobile) +Feature-first layout (`data` / `domain` / `presentation`): + | Path | Role | |------|------| -| `features/auth` | Session, login/register, secure tokens | -| `features/events` | List/detail, status chips | -| `features/rules` | List/create/edit | -| `features/explain` | Call summarize, render schema UI | -| `core/` | Dio, GetIt, go_router, theme | +| `features/*/domain` | Repository contracts | +| `features/*/data` | Dio APIs + `*RepositoryImpl` (`guardApi` maps errors) | +| `features/*/presentation` | Cubits + pages (no Dio / no getIt in widgets) | +| `features/shell` | Authenticated chrome + indexed tab shell | +| `features/ops` | `OpsHealthCubit` polls `/v1/health` with `skipAuth` | +| `core/error` | `AppException` + Dio → domain mapping | +| `core/network` | Shared Dio client + auth interceptor | +| `core/di` | GetIt composition root | + +State scope: app (`AuthCubit`, `OpsHealthCubit`) → shell (`EventsCubit`, `RulesCubit`, survive tabs) → route (detail / explain). diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index cf448cf..68aaa61 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -2,8 +2,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'core/di/injection.dart'; import 'core/theme/app_theme.dart'; import 'features/auth/presentation/cubit/auth_cubit.dart'; +import 'features/auth/presentation/cubit/auth_state.dart'; +import 'features/ops/presentation/cubit/ops_health_cubit.dart'; class WalletOpsApp extends StatelessWidget { const WalletOpsApp({ @@ -17,15 +20,29 @@ class WalletOpsApp extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocProvider.value( - value: authCubit, - child: MaterialApp.router( - title: 'WalletOps', - theme: buildAppTheme(), - darkTheme: buildAppDarkTheme(), - themeMode: ThemeMode.system, - routerConfig: router, - debugShowCheckedModeBanner: false, + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: authCubit), + BlocProvider(create: (_) => getIt()), + ], + child: BlocListener( + listenWhen: (prev, next) => prev.status != next.status, + listener: (context, state) { + final ops = context.read(); + if (state.status == AuthStatus.authenticated) { + ops.start(); + } else { + ops.stop(); + } + }, + child: MaterialApp.router( + title: 'WalletOps', + theme: buildAppTheme(), + darkTheme: buildAppDarkTheme(), + themeMode: ThemeMode.system, + routerConfig: router, + debugShowCheckedModeBanner: false, + ), ), ); } diff --git a/mobile/lib/core/di/injection.dart b/mobile/lib/core/di/injection.dart index 48c6d3c..bfc03a3 100644 --- a/mobile/lib/core/di/injection.dart +++ b/mobile/lib/core/di/injection.dart @@ -1,16 +1,24 @@ import 'package:get_it/get_it.dart'; -import '../../features/auth/data/auth_repository.dart'; +import '../../features/auth/data/auth_repository_impl.dart'; +import '../../features/auth/domain/auth_repository.dart'; import '../../features/auth/presentation/cubit/auth_cubit.dart'; import '../../features/events/data/events_api.dart'; -import '../../features/events/data/events_repository.dart'; +import '../../features/events/data/events_repository_impl.dart'; +import '../../features/events/domain/events_repository.dart'; import '../../features/events/presentation/cubit/event_detail_cubit.dart'; import '../../features/events/presentation/cubit/events_cubit.dart'; import '../../features/explain/data/ai_api.dart'; -import '../../features/explain/data/explain_repository.dart'; +import '../../features/explain/data/explain_repository_impl.dart'; +import '../../features/explain/domain/explain_repository.dart'; import '../../features/explain/presentation/cubit/explain_cubit.dart'; +import '../../features/ops/data/health_api.dart'; +import '../../features/ops/data/ops_repository_impl.dart'; +import '../../features/ops/domain/ops_repository.dart'; +import '../../features/ops/presentation/cubit/ops_health_cubit.dart'; import '../../features/rules/data/rules_api.dart'; -import '../../features/rules/data/rules_repository.dart'; +import '../../features/rules/data/rules_repository_impl.dart'; +import '../../features/rules/domain/rules_repository.dart'; import '../../features/rules/presentation/cubit/rules_cubit.dart'; import '../network/api_client.dart'; import '../storage/token_storage.dart'; @@ -36,22 +44,26 @@ Future configureDependencies({ ); getIt.registerSingleton(api); - final authRepo = AuthRepository(api: api.authApi, storage: storage); + final authRepo = AuthRepositoryImpl(api: api.authApi, storage: storage); getIt.registerSingleton(authRepo); authCubit = AuthCubit(authRepo); getIt.registerSingleton(authCubit); - final eventsRepo = EventsRepository(EventsApi(api.dio)); + final eventsRepo = EventsRepositoryImpl(EventsApi(api.dio)); getIt.registerSingleton(eventsRepo); getIt.registerFactory(() => EventsCubit(eventsRepo)); getIt.registerFactory(() => EventDetailCubit(eventsRepo)); - final rulesRepo = RulesRepository(RulesApi(api.dio)); + final rulesRepo = RulesRepositoryImpl(RulesApi(api.dio)); getIt.registerSingleton(rulesRepo); getIt.registerFactory(() => RulesCubit(rulesRepo)); - final explainRepo = ExplainRepository(AiApi(api.dio)); + final explainRepo = ExplainRepositoryImpl(AiApi(api.dio)); getIt.registerSingleton(explainRepo); getIt.registerFactory(() => ExplainCubit(explainRepo)); + + final opsRepo = OpsRepositoryImpl(HealthApi(api.dio)); + getIt.registerSingleton(opsRepo); + getIt.registerLazySingleton(() => OpsHealthCubit(opsRepo)); } diff --git a/mobile/lib/core/error/app_exception.dart b/mobile/lib/core/error/app_exception.dart new file mode 100644 index 0000000..13295a2 --- /dev/null +++ b/mobile/lib/core/error/app_exception.dart @@ -0,0 +1,10 @@ +class AppException implements Exception { + const AppException(this.message, {this.code, this.statusCode}); + + final String message; + final String? code; + final int? statusCode; + + @override + String toString() => message; +} diff --git a/mobile/lib/core/error/error_mapper.dart b/mobile/lib/core/error/error_mapper.dart new file mode 100644 index 0000000..a84a942 --- /dev/null +++ b/mobile/lib/core/error/error_mapper.dart @@ -0,0 +1,52 @@ +import 'package:dio/dio.dart'; + +import 'app_exception.dart'; + +AppException mapError(Object error) { + if (error is AppException) { + return error; + } + if (error is DioException) { + return mapDioError(error); + } + return const AppException('Something went wrong'); +} + +AppException mapDioError(DioException error) { + final data = error.response?.data; + if (data is Map) { + final nested = data['error']; + if (nested is Map) { + final message = nested['message']; + final code = nested['code']; + if (message is String && message.isNotEmpty) { + return AppException( + message, + code: code is String ? code : null, + statusCode: error.response?.statusCode, + ); + } + } + } + + return switch (error.type) { + DioExceptionType.connectionTimeout || + DioExceptionType.sendTimeout || + DioExceptionType.receiveTimeout => + const AppException('Request timed out'), + DioExceptionType.connectionError => + const AppException('Cannot reach API'), + _ => AppException( + 'Request failed', + statusCode: error.response?.statusCode, + ), + }; +} + +Future guardApi(Future Function() run) async { + try { + return await run(); + } on DioException catch (e) { + throw mapDioError(e); + } +} diff --git a/mobile/lib/core/network/request_options.dart b/mobile/lib/core/network/request_options.dart new file mode 100644 index 0000000..1ffdc44 --- /dev/null +++ b/mobile/lib/core/network/request_options.dart @@ -0,0 +1,3 @@ +import 'package:dio/dio.dart'; + +Options skipAuthOptions() => Options(extra: const {'skipAuth': true}); diff --git a/mobile/lib/core/router/app_router.dart b/mobile/lib/core/router/app_router.dart index 38848ed..37cf2ab 100644 --- a/mobile/lib/core/router/app_router.dart +++ b/mobile/lib/core/router/app_router.dart @@ -17,6 +17,7 @@ import '../../features/explain/presentation/explain_page.dart'; import '../../features/rules/presentation/cubit/rules_cubit.dart'; import '../../features/rules/presentation/rules_page.dart'; import '../../features/settings/presentation/settings_page.dart'; +import '../../features/shell/presentation/app_shell.dart'; import '../di/injection.dart'; GoRouter createAppRouter(AuthCubit authCubit) { @@ -48,12 +49,42 @@ GoRouter createAppRouter(AuthCubit authCubit) { path: '/register', builder: (context, state) => const RegisterPage(), ), - GoRoute( - path: '/events', - builder: (context, state) => BlocProvider( - create: (_) => getIt()..load(), - child: const EventsPage(), - ), + StatefulShellRoute.indexedStack( + builder: (context, state, navigationShell) { + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => getIt()..load(), + ), + BlocProvider( + create: (_) => getIt()..load(), + ), + ], + child: AppShell(navigationShell: navigationShell), + ); + }, + branches: [ + StatefulShellBranch( + routes: [ + GoRoute( + path: '/events', + pageBuilder: (context, state) => const NoTransitionPage( + child: EventsPage(), + ), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/rules', + pageBuilder: (context, state) => const NoTransitionPage( + child: RulesPage(), + ), + ), + ], + ), + ], ), GoRoute( path: '/events/:id', @@ -80,13 +111,6 @@ GoRouter createAppRouter(AuthCubit authCubit) { ); }, ), - GoRoute( - path: '/rules', - builder: (context, state) => BlocProvider( - create: (_) => getIt()..load(), - child: const RulesPage(), - ), - ), GoRoute( path: '/settings', builder: (context, state) => const SettingsPage(), diff --git a/mobile/lib/core/widgets/app_bottom_nav.dart b/mobile/lib/core/widgets/app_bottom_nav.dart deleted file mode 100644 index a9e5ea2..0000000 --- a/mobile/lib/core/widgets/app_bottom_nav.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; - -/// Shared shell chrome for primary tabs. Route jobs unchanged. -class AppBottomNav extends StatelessWidget { - const AppBottomNav({super.key, required this.selectedIndex}); - - /// 0 = Events, 1 = Rules - final int selectedIndex; - - @override - Widget build(BuildContext context) { - return NavigationBar( - selectedIndex: selectedIndex, - onDestinationSelected: (i) { - if (i == selectedIndex) { - return; - } - if (i == 0) { - context.go('/events'); - } else if (i == 1) { - context.go('/rules'); - } - }, - destinations: const [ - NavigationDestination( - icon: Icon(Icons.inbox_outlined), - selectedIcon: Icon(Icons.inbox), - label: 'Events', - ), - NavigationDestination( - icon: Icon(Icons.rule_outlined), - selectedIcon: Icon(Icons.rule), - label: 'Rules', - ), - ], - ); - } -} diff --git a/mobile/lib/features/auth/data/auth_repository.dart b/mobile/lib/features/auth/data/auth_repository_impl.dart similarity index 50% rename from mobile/lib/features/auth/data/auth_repository.dart rename to mobile/lib/features/auth/data/auth_repository_impl.dart index 13a825d..e31e6cb 100644 --- a/mobile/lib/features/auth/data/auth_repository.dart +++ b/mobile/lib/features/auth/data/auth_repository_impl.dart @@ -1,9 +1,11 @@ +import '../../../core/error/error_mapper.dart'; import '../../../core/storage/token_storage.dart'; +import '../domain/auth_repository.dart'; import 'auth_api.dart'; import 'auth_models.dart'; -class AuthRepository { - AuthRepository({ +class AuthRepositoryImpl implements AuthRepository { + AuthRepositoryImpl({ required AuthApi api, required TokenStorage storage, }) : _api = api, @@ -12,46 +14,55 @@ class AuthRepository { final AuthApi _api; final TokenStorage _storage; + @override Future hasSession() async { final access = await _storage.readAccessToken(); return access != null && access.isNotEmpty; } + @override Future register({ required String email, required String password, - }) async { - final tokens = await _api.register(email: email, password: password); - await _storage.saveTokens( - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken, - ); - return _api.me(); + }) { + return guardApi(() async { + final tokens = await _api.register(email: email, password: password); + await _storage.saveTokens( + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + ); + return _api.me(); + }); } + @override Future login({ required String email, required String password, - }) async { - final tokens = await _api.login(email: email, password: password); - await _storage.saveTokens( - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken, - ); - return _api.me(); + }) { + return guardApi(() async { + final tokens = await _api.login(email: email, password: password); + await _storage.saveTokens( + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + ); + return _api.me(); + }); } + @override Future restore() async { if (!await hasSession()) { return null; } try { - return await _api.me(); + return await guardApi(_api.me); } catch (_) { await _storage.clear(); return null; } } + @override Future logout() => _storage.clear(); } diff --git a/mobile/lib/features/auth/domain/auth_repository.dart b/mobile/lib/features/auth/domain/auth_repository.dart new file mode 100644 index 0000000..f305570 --- /dev/null +++ b/mobile/lib/features/auth/domain/auth_repository.dart @@ -0,0 +1,19 @@ +import '../data/auth_models.dart'; + +abstract class AuthRepository { + Future hasSession(); + + Future register({ + required String email, + required String password, + }); + + Future login({ + required String email, + required String password, + }); + + Future restore(); + + Future logout(); +} diff --git a/mobile/lib/features/auth/presentation/cubit/auth_cubit.dart b/mobile/lib/features/auth/presentation/cubit/auth_cubit.dart index 4930d9c..1fb9f9a 100644 --- a/mobile/lib/features/auth/presentation/cubit/auth_cubit.dart +++ b/mobile/lib/features/auth/presentation/cubit/auth_cubit.dart @@ -1,7 +1,7 @@ -import 'package:dio/dio.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../data/auth_repository.dart'; +import '../../../../core/error/error_mapper.dart'; +import '../../domain/auth_repository.dart'; import 'auth_state.dart'; class AuthCubit extends Cubit { @@ -32,7 +32,7 @@ class AuthCubit extends Cubit { state.copyWith( busy: false, status: AuthStatus.unauthenticated, - errorMessage: _message(e), + errorMessage: mapError(e).message, ), ); } @@ -51,7 +51,7 @@ class AuthCubit extends Cubit { state.copyWith( busy: false, status: AuthStatus.unauthenticated, - errorMessage: _message(e), + errorMessage: mapError(e).message, ), ); } @@ -67,18 +67,4 @@ class AuthCubit extends Cubit { emit(const AuthState(status: AuthStatus.unauthenticated)); } } - - String _message(Object e) { - if (e is DioException) { - final data = e.response?.data; - if (data is Map && data['error'] is Map) { - final msg = data['error']['message']; - if (msg is String && msg.isNotEmpty) { - return msg; - } - } - return 'Request failed'; - } - return 'Something went wrong'; - } } diff --git a/mobile/lib/features/auth/presentation/login_page.dart b/mobile/lib/features/auth/presentation/login_page.dart index 14801cd..c11b282 100644 --- a/mobile/lib/features/auth/presentation/login_page.dart +++ b/mobile/lib/features/auth/presentation/login_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import '../../../core/demo_credentials.dart'; import '../../../core/theme/app_spacing.dart'; import '../../../core/widgets/brand_mark.dart'; import 'cubit/auth_cubit.dart'; @@ -15,8 +16,8 @@ class LoginPage extends StatefulWidget { } class _LoginPageState extends State { - final _email = TextEditingController(); - final _password = TextEditingController(); + final _email = TextEditingController(text: kDemoEmail); + final _password = TextEditingController(text: kDemoPassword); final _formKey = GlobalKey(); @override @@ -29,6 +30,7 @@ class _LoginPageState extends State { @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; + final theme = Theme.of(context); return Scaffold( body: SafeArea( @@ -50,8 +52,68 @@ class _LoginPageState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const SizedBox(height: AppSpacing.xxl), - const BrandMark(subtitle: 'Sign in to your ops console'), - const SizedBox(height: AppSpacing.xl), + const BrandMark( + subtitle: 'Simulated wallet ops console', + ), + const SizedBox(height: AppSpacing.md), + Text( + 'Sign in to watch HMAC webhooks land, the worker claim ' + 'them, and rules match — all against a local API.', + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.lg), + Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: scheme.surface, + borderRadius: + BorderRadius.circular(AppSpacing.radiusMd), + border: Border.all( + color: + scheme.outlineVariant.withValues(alpha: 0.7), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Demo account (prefilled)', + style: theme.textTheme.titleSmall, + ), + const SizedBox(height: AppSpacing.xxs), + Text( + '$kDemoEmail\npassword: $kDemoPassword\n' + 'webhook user_ref: $kDemoUserRef', + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + height: 1.45, + ), + ), + const SizedBox(height: AppSpacing.sm), + Text( + 'Run ./scripts/seed_webhooks.sh once after ' + 'docker compose up.', + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.sm), + Align( + alignment: Alignment.centerLeft, + child: TextButton( + onPressed: () { + _email.text = kDemoEmail; + _password.text = kDemoPassword; + }, + child: const Text('Reset to demo credentials'), + ), + ), + ], + ), + ), + const SizedBox(height: AppSpacing.lg), TextFormField( controller: _email, keyboardType: TextInputType.emailAddress, @@ -79,9 +141,9 @@ class _LoginPageState extends State { const SizedBox(height: AppSpacing.sm), Text( state.errorMessage!, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: scheme.error, - ), + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.error, + ), ), ], const SizedBox(height: AppSpacing.lg), diff --git a/mobile/lib/features/events/data/event_models.dart b/mobile/lib/features/events/data/event_models.dart index be1758e..5d38012 100644 --- a/mobile/lib/features/events/data/event_models.dart +++ b/mobile/lib/features/events/data/event_models.dart @@ -50,6 +50,42 @@ class OpsEvent extends Equatable { String get prettyPayload => const JsonEncoder.withIndent(' ').convert(payload); + String? get asset => payload['asset']?.toString(); + + double? get amount { + final raw = payload['amount']; + if (raw is num) { + return raw.toDouble(); + } + return null; + } + + String? get addressLabel => payload['address_label']?.toString(); + + String get listSubtitle { + final parts = []; + if (amount != null && asset != null) { + parts.add('$amount $asset'); + } else if (asset != null) { + parts.add(asset!); + } + if (addressLabel != null) { + parts.add(addressLabel!); + } + parts.add(pipelineHint); + return parts.join(' · '); + } + + String get pipelineHint => switch (status) { + 'pending' => 'queued for worker', + 'processing' => 'claimed by worker', + 'processed' => matchedRuleId != null + ? 'done · rule matched' + : 'done · no rule match', + 'failed' => 'failed · attempt $attemptCount', + _ => status, + }; + @override List get props => [id, status, type, receivedAt]; } diff --git a/mobile/lib/features/events/data/events_repository.dart b/mobile/lib/features/events/data/events_repository.dart deleted file mode 100644 index 330cd79..0000000 --- a/mobile/lib/features/events/data/events_repository.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'event_models.dart'; -import 'events_api.dart'; - -class EventsRepository { - EventsRepository(this._api); - - final EventsApi _api; - - Future> list({String? status}) => _api.list(status: status); - - Future getById(String id) => _api.getById(id); -} diff --git a/mobile/lib/features/events/data/events_repository_impl.dart b/mobile/lib/features/events/data/events_repository_impl.dart new file mode 100644 index 0000000..122dc93 --- /dev/null +++ b/mobile/lib/features/events/data/events_repository_impl.dart @@ -0,0 +1,17 @@ +import '../../../core/error/error_mapper.dart'; +import '../domain/events_repository.dart'; +import 'event_models.dart'; +import 'events_api.dart'; + +class EventsRepositoryImpl implements EventsRepository { + EventsRepositoryImpl(this._api); + + final EventsApi _api; + + @override + Future> list({String? status}) => + guardApi(() => _api.list(status: status)); + + @override + Future getById(String id) => guardApi(() => _api.getById(id)); +} diff --git a/mobile/lib/features/events/domain/events_repository.dart b/mobile/lib/features/events/domain/events_repository.dart new file mode 100644 index 0000000..c5b805e --- /dev/null +++ b/mobile/lib/features/events/domain/events_repository.dart @@ -0,0 +1,7 @@ +import '../data/event_models.dart'; + +abstract class EventsRepository { + Future> list({String? status}); + + Future getById(String id); +} diff --git a/mobile/lib/features/events/presentation/cubit/event_detail_cubit.dart b/mobile/lib/features/events/presentation/cubit/event_detail_cubit.dart index d8a578f..a1f863a 100644 --- a/mobile/lib/features/events/presentation/cubit/event_detail_cubit.dart +++ b/mobile/lib/features/events/presentation/cubit/event_detail_cubit.dart @@ -1,7 +1,7 @@ -import 'package:dio/dio.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../data/events_repository.dart'; +import '../../../../core/error/error_mapper.dart'; +import '../../domain/events_repository.dart'; import 'event_detail_state.dart'; class EventDetailCubit extends Cubit { @@ -18,7 +18,7 @@ class EventDetailCubit extends Cubit { emit( state.copyWith( status: EventDetailStatus.error, - errorMessage: e is DioException ? 'Event not found' : 'Load failed', + errorMessage: mapError(e).message, ), ); } diff --git a/mobile/lib/features/events/presentation/cubit/events_cubit.dart b/mobile/lib/features/events/presentation/cubit/events_cubit.dart index b69b86d..c165b85 100644 --- a/mobile/lib/features/events/presentation/cubit/events_cubit.dart +++ b/mobile/lib/features/events/presentation/cubit/events_cubit.dart @@ -1,7 +1,7 @@ -import 'package:dio/dio.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../data/events_repository.dart'; +import '../../../../core/error/error_mapper.dart'; +import '../../domain/events_repository.dart'; import 'events_state.dart'; class EventsCubit extends Cubit { @@ -24,13 +24,19 @@ class EventsCubit extends Cubit { emit(EventsState(status: EventsStatus.empty, filter: filter)); return; } - emit(EventsState(status: EventsStatus.ready, items: items, filter: filter)); + emit( + EventsState( + status: EventsStatus.ready, + items: items, + filter: filter, + ), + ); } catch (e) { emit( EventsState( status: EventsStatus.error, filter: filter, - errorMessage: _message(e), + errorMessage: mapError(e).message, ), ); } @@ -40,11 +46,4 @@ class EventsCubit extends Cubit { Future setFilter(String? status) => load(status: status, updateFilter: true); - - String _message(Object e) { - if (e is DioException) { - return 'Failed to load events'; - } - return 'Something went wrong'; - } } diff --git a/mobile/lib/features/events/presentation/event_detail_page.dart b/mobile/lib/features/events/presentation/event_detail_page.dart index 15006e3..d956fd1 100644 --- a/mobile/lib/features/events/presentation/event_detail_page.dart +++ b/mobile/lib/features/events/presentation/event_detail_page.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import '../../../core/theme/app_spacing.dart'; import '../../../core/widgets/error_state.dart'; import '../../../core/widgets/loading_state.dart'; +import '../data/event_models.dart'; import 'cubit/event_detail_cubit.dart'; import 'cubit/event_detail_state.dart'; import 'widgets/status_chip.dart'; @@ -62,21 +63,44 @@ class _DetailBody extends StatelessWidget { StatusChip(status: event.status), ], ), - const SizedBox(height: AppSpacing.md), + if (event.amount != null || event.asset != null) ...[ + const SizedBox(height: AppSpacing.xs), + Text( + [ + if (event.amount != null) '${event.amount}', + if (event.asset != null) event.asset!, + if (event.addressLabel != null) '· ${event.addressLabel}', + ].join(' '), + style: theme.textTheme.titleMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + const SizedBox(height: AppSpacing.lg), + Text('Backend pipeline', style: theme.textTheme.titleMedium), + const SizedBox(height: AppSpacing.xs), + Text( + 'What the Go API and worker did with this webhook.', + style: theme.textTheme.bodySmall, + ), + const SizedBox(height: AppSpacing.sm), + _PipelineCard(event: event), + const SizedBox(height: AppSpacing.lg), + Text('Identifiers', style: theme.textTheme.titleMedium), + const SizedBox(height: AppSpacing.sm), _MetaBlock( children: [ - _MetaLine(label: 'Key', value: event.idempotencyKey), - _MetaLine( - label: 'Received', - value: event.receivedAt.toLocal().toString(), - ), + _MetaLine(label: 'Event id', value: event.id), + _MetaLine(label: 'Idempotency key', value: event.idempotencyKey), + _MetaLine(label: 'Attempt count', value: '${event.attemptCount}'), + _MetaLine(label: 'Received', value: _fmt(event.receivedAt)), if (event.processedAt != null) _MetaLine( - label: 'Processed', - value: event.processedAt!.toLocal().toString(), + label: 'Processed / last transition', + value: _fmt(event.processedAt!), ), if (event.matchedRuleId != null) - _MetaLine(label: 'Matched rule', value: event.matchedRuleId!), + _MetaLine(label: 'Matched rule id', value: event.matchedRuleId!), ], ), if (event.lastError != null) ...[ @@ -92,7 +116,7 @@ class _DetailBody extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Last error', + 'Last worker error', style: theme.textTheme.titleSmall?.copyWith( color: scheme.error, ), @@ -109,8 +133,14 @@ class _DetailBody extends StatelessWidget { ), ], const SizedBox(height: AppSpacing.lg), - Text('Payload', style: theme.textTheme.titleMedium), + Text('Webhook payload', style: theme.textTheme.titleMedium), const SizedBox(height: AppSpacing.xs), + Text( + 'Stored as jsonb after HMAC verification. Explain uses allowlisted ' + 'fields only (type, amount, status, rule name).', + style: theme.textTheme.bodySmall, + ), + const SizedBox(height: AppSpacing.sm), Container( width: double.infinity, padding: const EdgeInsets.all(AppSpacing.md), @@ -134,11 +164,175 @@ class _DetailBody extends StatelessWidget { const SizedBox(height: AppSpacing.lg), FilledButton.tonal( onPressed: () => context.push('/explain?ids=${event.id}'), - child: const Text('Explain'), + child: const Text('Explain with schema summary'), ), ], ); } + + String _fmt(DateTime value) { + final local = value.toLocal(); + return '${local.toIso8601String().replaceFirst('T', ' ').split('.').first} ' + '(local)'; + } +} + +class _PipelineCard extends StatelessWidget { + const _PipelineCard({required this.event}); + + final OpsEvent event; + + @override + Widget build(BuildContext context) { + final steps = _steps(event); + final scheme = Theme.of(context).colorScheme; + final theme = Theme.of(context); + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: scheme.surface, + borderRadius: BorderRadius.circular(AppSpacing.radiusMd), + border: Border.all( + color: scheme.outlineVariant.withValues(alpha: 0.6), + ), + ), + child: Column( + children: [ + for (var i = 0; i < steps.length; i++) ...[ + if (i > 0) + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only(left: 11), + child: Container( + width: 2, + height: 12, + color: scheme.outlineVariant.withValues(alpha: 0.8), + ), + ), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 24, + height: 24, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: steps[i].done + ? scheme.primary + : scheme.surfaceContainerHighest, + ), + child: Icon( + steps[i].done ? Icons.check : Icons.circle, + size: steps[i].done ? 14 : 8, + color: steps[i].done + ? scheme.onPrimary + : scheme.onSurfaceVariant, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + steps[i].title, + style: theme.textTheme.titleSmall?.copyWith( + color: steps[i].active + ? scheme.primary + : scheme.onSurface, + ), + ), + Text( + steps[i].detail, + style: theme.textTheme.bodySmall, + ), + ], + ), + ), + ], + ), + ], + ], + ), + ); + } + + List<_PipeStep> _steps(OpsEvent event) { + final pending = event.status == 'pending'; + final processing = event.status == 'processing'; + final processed = event.status == 'processed'; + final failed = event.status == 'failed'; + final pastQueue = processing || processed || failed; + + return [ + const _PipeStep( + title: '1. Webhook accepted', + detail: 'POST /v1/webhooks/events · HMAC verified · idempotent insert', + done: true, + active: false, + ), + _PipeStep( + title: '2. Queued as pending', + detail: pending + ? 'Waiting for FOR UPDATE SKIP LOCKED claim' + : 'Left the pending queue', + done: pastQueue || pending, + active: pending, + ), + _PipeStep( + title: '3. Worker claim', + detail: processing + ? 'status=processing · claim lease held' + : processed || failed + ? 'Claim completed · attempts=${event.attemptCount}' + : 'Not claimed yet', + done: processing || processed || failed, + active: processing, + ), + _PipeStep( + title: '4. Match rules / validate payload', + detail: processed + ? (event.matchedRuleId != null + ? 'Rule matched · id ${event.matchedRuleId}' + : 'No enabled rule matched this type/threshold') + : failed + ? 'Validation or processing error recorded' + : 'Runs inside the worker after claim', + done: processed || failed, + active: false, + ), + _PipeStep( + title: failed ? '5. Marked failed' : '5. Marked processed', + detail: processed + ? 'Ready for Explain · schema-checked summary' + : failed + ? (event.lastError ?? + 'Will retry with backoff until max attempts') + : 'Final status not reached', + done: processed || failed, + active: processed || failed, + ), + ]; + } +} + +class _PipeStep { + const _PipeStep({ + required this.title, + required this.detail, + required this.done, + required this.active, + }); + + final String title; + final String detail; + final bool done; + final bool active; } class _MetaBlock extends StatelessWidget { @@ -192,7 +386,7 @@ class _MetaLine extends StatelessWidget { ), ), const SizedBox(height: 2), - Text(value, style: theme.textTheme.bodyMedium), + SelectableText(value, style: theme.textTheme.bodyMedium), ], ); } diff --git a/mobile/lib/features/events/presentation/events_page.dart b/mobile/lib/features/events/presentation/events_page.dart index 5f2a4bb..cb526dd 100644 --- a/mobile/lib/features/events/presentation/events_page.dart +++ b/mobile/lib/features/events/presentation/events_page.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; -import '../../../core/widgets/app_bottom_nav.dart'; import '../../../core/widgets/empty_state.dart'; import '../../../core/widgets/error_state.dart'; import '../../../core/widgets/filter_chip_bar.dart'; @@ -30,6 +29,11 @@ class EventsPage extends StatelessWidget { appBar: AppBar( title: const Text('Events'), actions: [ + IconButton( + tooltip: 'Refresh', + onPressed: () => context.read().refresh(), + icon: const Icon(Icons.refresh), + ), IconButton( tooltip: 'Settings', onPressed: () => context.push('/settings'), @@ -68,7 +72,9 @@ class EventsPage extends StatelessWidget { child: EmptyState( title: 'No events yet', message: state.filter == null - ? 'Incoming webhook events will appear here.' + ? 'Seed demo webhooks, then pull to refresh:\n' + './scripts/seed_webhooks.sh\n\n' + 'Flow: HMAC ingest → pending → worker claim → processed.' : 'No events match this status filter.', actionLabel: 'Refresh', onAction: () => @@ -104,7 +110,6 @@ class EventsPage extends StatelessWidget { ), ], ), - bottomNavigationBar: const AppBottomNav(selectedIndex: 0), ); } } @@ -124,7 +129,7 @@ class EventsListView extends StatelessWidget { final event = items[index]; return OpsListRow( title: event.type, - subtitle: event.idempotencyKey, + subtitle: '${event.listSubtitle}\n${event.idempotencyKey}', trailing: StatusChip(status: event.status), onTap: () => context.push('/events/${event.id}'), ); diff --git a/mobile/lib/features/explain/data/explain_repository.dart b/mobile/lib/features/explain/data/explain_repository.dart deleted file mode 100644 index ba63931..0000000 --- a/mobile/lib/features/explain/data/explain_repository.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'ai_api.dart'; -import 'summary_models.dart'; - -class ExplainRepository { - ExplainRepository(this._api); - - final AiApi _api; - - Future summarize(List eventIds) => - _api.summarize(eventIds); -} diff --git a/mobile/lib/features/explain/data/explain_repository_impl.dart b/mobile/lib/features/explain/data/explain_repository_impl.dart new file mode 100644 index 0000000..b90cde8 --- /dev/null +++ b/mobile/lib/features/explain/data/explain_repository_impl.dart @@ -0,0 +1,14 @@ +import '../../../core/error/error_mapper.dart'; +import '../domain/explain_repository.dart'; +import 'ai_api.dart'; +import 'summary_models.dart'; + +class ExplainRepositoryImpl implements ExplainRepository { + ExplainRepositoryImpl(this._api); + + final AiApi _api; + + @override + Future summarize(List eventIds) => + guardApi(() => _api.summarize(eventIds)); +} diff --git a/mobile/lib/features/explain/domain/explain_repository.dart b/mobile/lib/features/explain/domain/explain_repository.dart new file mode 100644 index 0000000..08f1df4 --- /dev/null +++ b/mobile/lib/features/explain/domain/explain_repository.dart @@ -0,0 +1,5 @@ +import '../data/summary_models.dart'; + +abstract class ExplainRepository { + Future summarize(List eventIds); +} diff --git a/mobile/lib/features/explain/presentation/cubit/explain_cubit.dart b/mobile/lib/features/explain/presentation/cubit/explain_cubit.dart index 920f4f7..0319975 100644 --- a/mobile/lib/features/explain/presentation/cubit/explain_cubit.dart +++ b/mobile/lib/features/explain/presentation/cubit/explain_cubit.dart @@ -1,7 +1,7 @@ -import 'package:dio/dio.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../data/explain_repository.dart'; +import '../../../../core/error/error_mapper.dart'; +import '../../domain/explain_repository.dart'; import 'explain_state.dart'; class ExplainCubit extends Cubit { @@ -39,25 +39,11 @@ class ExplainCubit extends Cubit { ExplainState( status: ExplainStatus.error, eventIds: eventIds, - errorMessage: _message(e), + errorMessage: mapError(e).message, ), ); } } Future retry() => load(state.eventIds); - - String _message(Object e) { - if (e is DioException) { - final data = e.response?.data; - if (data is Map && data['error'] is Map) { - final msg = data['error']['message']; - if (msg is String && msg.isNotEmpty) { - return msg; - } - } - return 'Failed to summarize events'; - } - return 'Something went wrong'; - } } diff --git a/mobile/lib/features/ops/data/health_api.dart b/mobile/lib/features/ops/data/health_api.dart index 21f3d2c..9a5e65a 100644 --- a/mobile/lib/features/ops/data/health_api.dart +++ b/mobile/lib/features/ops/data/health_api.dart @@ -1,5 +1,6 @@ import 'package:dio/dio.dart'; +import '../../../core/network/request_options.dart'; import 'health_models.dart'; class HealthApi { @@ -8,7 +9,10 @@ class HealthApi { final Dio _dio; Future fetch() async { - final res = await _dio.get>('/v1/health'); + final res = await _dio.get>( + '/v1/health', + options: skipAuthOptions(), + ); return OpsHealth.fromJson(res.data ?? const {}); } } diff --git a/mobile/lib/features/ops/data/ops_repository_impl.dart b/mobile/lib/features/ops/data/ops_repository_impl.dart new file mode 100644 index 0000000..ff73f8f --- /dev/null +++ b/mobile/lib/features/ops/data/ops_repository_impl.dart @@ -0,0 +1,13 @@ +import '../../../core/error/error_mapper.dart'; +import '../domain/ops_repository.dart'; +import 'health_api.dart'; +import 'health_models.dart'; + +class OpsRepositoryImpl implements OpsRepository { + OpsRepositoryImpl(this._api); + + final HealthApi _api; + + @override + Future fetchHealth() => guardApi(_api.fetch); +} diff --git a/mobile/lib/features/ops/domain/ops_repository.dart b/mobile/lib/features/ops/domain/ops_repository.dart new file mode 100644 index 0000000..9e4b491 --- /dev/null +++ b/mobile/lib/features/ops/domain/ops_repository.dart @@ -0,0 +1,5 @@ +import '../data/health_models.dart'; + +abstract class OpsRepository { + Future fetchHealth(); +} diff --git a/mobile/lib/features/ops/presentation/cubit/ops_health_cubit.dart b/mobile/lib/features/ops/presentation/cubit/ops_health_cubit.dart new file mode 100644 index 0000000..9c2e6aa --- /dev/null +++ b/mobile/lib/features/ops/presentation/cubit/ops_health_cubit.dart @@ -0,0 +1,56 @@ +import 'dart:async'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../core/error/error_mapper.dart'; +import '../../domain/ops_repository.dart'; +import 'ops_health_state.dart'; + +class OpsHealthCubit extends Cubit { + OpsHealthCubit(this._repo) : super(const OpsHealthState()); + + final OpsRepository _repo; + Timer? _timer; + + void start({Duration interval = const Duration(seconds: 4)}) { + _timer?.cancel(); + unawaited(refresh()); + _timer = Timer.periodic(interval, (_) => unawaited(refresh())); + } + + void stop() { + _timer?.cancel(); + _timer = null; + emit(const OpsHealthState()); + } + + Future refresh() async { + if (state.status == OpsHealthStatus.idle || + state.status == OpsHealthStatus.error) { + emit(state.copyWith(status: OpsHealthStatus.loading, clearError: true)); + } + try { + final health = await _repo.fetchHealth(); + emit( + OpsHealthState( + status: OpsHealthStatus.ready, + health: health, + ), + ); + } catch (e) { + emit( + OpsHealthState( + status: OpsHealthStatus.error, + health: state.health, + errorMessage: mapError(e).message, + ), + ); + } + } + + @override + Future close() { + _timer?.cancel(); + return super.close(); + } +} diff --git a/mobile/lib/features/ops/presentation/cubit/ops_health_state.dart b/mobile/lib/features/ops/presentation/cubit/ops_health_state.dart new file mode 100644 index 0000000..c8f2ab5 --- /dev/null +++ b/mobile/lib/features/ops/presentation/cubit/ops_health_state.dart @@ -0,0 +1,35 @@ +import 'package:equatable/equatable.dart'; + +import '../../data/health_models.dart'; + +enum OpsHealthStatus { idle, loading, ready, error } + +class OpsHealthState extends Equatable { + const OpsHealthState({ + this.status = OpsHealthStatus.idle, + this.health, + this.errorMessage, + }); + + final OpsHealthStatus status; + final OpsHealth? health; + final String? errorMessage; + + bool get ok => health?.ok == true && status == OpsHealthStatus.ready; + + OpsHealthState copyWith({ + OpsHealthStatus? status, + OpsHealth? health, + String? errorMessage, + bool clearError = false, + }) { + return OpsHealthState( + status: status ?? this.status, + health: health ?? this.health, + errorMessage: clearError ? null : (errorMessage ?? this.errorMessage), + ); + } + + @override + List get props => [status, health, errorMessage]; +} diff --git a/mobile/lib/features/ops/presentation/ops_status_bar.dart b/mobile/lib/features/ops/presentation/ops_status_bar.dart deleted file mode 100644 index cc45fab..0000000 --- a/mobile/lib/features/ops/presentation/ops_status_bar.dart +++ /dev/null @@ -1,151 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; - -import '../../../core/di/injection.dart'; -import '../../../core/theme/app_spacing.dart'; -import '../data/health_api.dart'; -import '../data/health_models.dart'; - -/// Live strip of API / worker / queue state from `GET /v1/health`. -class OpsStatusBar extends StatefulWidget { - const OpsStatusBar({super.key}); - - @override - State createState() => _OpsStatusBarState(); -} - -class _OpsStatusBarState extends State { - OpsHealth? _health; - String? _error; - Timer? _timer; - - @override - void initState() { - super.initState(); - _refresh(); - _timer = Timer.periodic(const Duration(seconds: 4), (_) => _refresh()); - } - - @override - void dispose() { - _timer?.cancel(); - super.dispose(); - } - - Future _refresh() async { - try { - final health = await getIt().fetch(); - if (!mounted) { - return; - } - setState(() { - _health = health; - _error = null; - }); - } catch (_) { - if (!mounted) { - return; - } - setState(() { - _error = 'API unreachable'; - }); - } - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scheme = theme.colorScheme; - final health = _health; - final ok = _error == null && (health?.ok ?? false); - - return Material( - color: ok - ? scheme.primaryContainer.withValues(alpha: 0.45) - : scheme.errorContainer.withValues(alpha: 0.45), - child: InkWell( - onTap: _refresh, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - ok ? Icons.dns_outlined : Icons.cloud_off_outlined, - size: 18, - color: ok ? scheme.primary : scheme.error, - ), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _error ?? _headline(health), - style: theme.textTheme.labelLarge?.copyWith( - color: ok ? scheme.onSurface : scheme.error, - ), - ), - const SizedBox(height: 2), - Text( - _detail(health), - style: theme.textTheme.bodySmall?.copyWith( - color: scheme.onSurfaceVariant, - height: 1.35, - ), - ), - ], - ), - ), - ], - ), - ), - ), - ); - } - - String _headline(OpsHealth? health) { - if (health == null) { - return 'Checking API…'; - } - final tick = health.worker?.lastTick; - final tickLabel = tick == null ? 'no tick yet' : _ago(tick); - return 'API ${health.status} · worker $tickLabel'; - } - - String _detail(OpsHealth? health) { - if (health?.queue == null) { - return 'Tap to refresh · webhook → queue → worker → mobile'; - } - final q = health!.queue!; - final pending = q.count('pending'); - final processing = q.count('processing'); - final processed = q.count('processed'); - final failed = q.count('failed'); - final worker = health.worker; - final totals = worker == null - ? '' - : ' · worker processed ${worker.processedTotal}, errors ${worker.errorTotal}'; - final oldest = q.oldestPendingSeconds == null - ? '' - : ' · oldest pending ${q.oldestPendingSeconds!.round()}s'; - return 'Queue pending $pending · processing $processing · ' - 'processed $processed · failed $failed$totals$oldest'; - } - - String _ago(DateTime when) { - final seconds = DateTime.now().difference(when).inSeconds; - if (seconds < 5) { - return 'just now'; - } - if (seconds < 60) { - return '${seconds}s ago'; - } - return '${when.hour.toString().padLeft(2, '0')}:' - '${when.minute.toString().padLeft(2, '0')}'; - } -} diff --git a/mobile/lib/features/ops/presentation/widgets/ops_status_bar.dart b/mobile/lib/features/ops/presentation/widgets/ops_status_bar.dart new file mode 100644 index 0000000..c2e6665 --- /dev/null +++ b/mobile/lib/features/ops/presentation/widgets/ops_status_bar.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../core/theme/app_spacing.dart'; +import '../../data/health_models.dart'; +import '../cubit/ops_health_cubit.dart'; +import '../cubit/ops_health_state.dart'; + +/// Live strip of API / worker / queue state. Driven by [OpsHealthCubit]. +class OpsStatusBar extends StatelessWidget { + const OpsStatusBar({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final ok = state.ok; + final health = state.health; + + return Material( + color: ok + ? scheme.primaryContainer.withValues(alpha: 0.45) + : scheme.errorContainer.withValues(alpha: 0.45), + child: InkWell( + onTap: () => context.read().refresh(), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + ok ? Icons.dns_outlined : Icons.cloud_off_outlined, + size: 18, + color: ok ? scheme.primary : scheme.error, + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _headline(state), + style: theme.textTheme.labelLarge?.copyWith( + color: ok ? scheme.onSurface : scheme.error, + ), + ), + const SizedBox(height: 2), + Text( + _detail(state, health), + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + height: 1.35, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + }, + ); + } + + String _headline(OpsHealthState state) { + if (state.status == OpsHealthStatus.loading && state.health == null) { + return 'Checking API…'; + } + if (state.errorMessage != null && state.health == null) { + return state.errorMessage!; + } + final health = state.health; + if (health == null) { + return 'API unknown'; + } + final tick = health.worker?.lastTick; + final tickLabel = tick == null ? 'no tick yet' : _ago(tick); + return 'API ${health.status} · worker $tickLabel'; + } + + String _detail(OpsHealthState state, OpsHealth? health) { + if (health?.queue == null) { + return 'Tap to refresh · webhook → queue → worker → mobile'; + } + final q = health!.queue!; + final worker = health.worker; + final totals = worker == null + ? '' + : ' · worker processed ${worker.processedTotal}, errors ${worker.errorTotal}'; + final oldest = q.oldestPendingSeconds == null + ? '' + : ' · oldest pending ${q.oldestPendingSeconds!.round()}s'; + return 'Queue pending ${q.count('pending')} · ' + 'processing ${q.count('processing')} · ' + 'processed ${q.count('processed')} · ' + 'failed ${q.count('failed')}$totals$oldest'; + } + + String _ago(DateTime when) { + final seconds = DateTime.now().difference(when).inSeconds; + if (seconds < 5) { + return 'just now'; + } + if (seconds < 60) { + return '${seconds}s ago'; + } + return '${when.hour.toString().padLeft(2, '0')}:' + '${when.minute.toString().padLeft(2, '0')}'; + } +} diff --git a/mobile/lib/features/rules/data/rules_repository.dart b/mobile/lib/features/rules/data/rules_repository_impl.dart similarity index 56% rename from mobile/lib/features/rules/data/rules_repository.dart rename to mobile/lib/features/rules/data/rules_repository_impl.dart index e563caf..f170415 100644 --- a/mobile/lib/features/rules/data/rules_repository.dart +++ b/mobile/lib/features/rules/data/rules_repository_impl.dart @@ -1,26 +1,34 @@ +import '../../../core/error/error_mapper.dart'; +import '../domain/rules_repository.dart'; import 'rule_models.dart'; import 'rules_api.dart'; -class RulesRepository { - RulesRepository(this._api); +class RulesRepositoryImpl implements RulesRepository { + RulesRepositoryImpl(this._api); final RulesApi _api; - Future> list() => _api.list(); + @override + Future> list() => guardApi(_api.list); + @override Future create({ required String name, required String eventType, double? threshold, bool enabled = true, - }) => - _api.create( + }) { + return guardApi( + () => _api.create( name: name, eventType: eventType, threshold: threshold, enabled: enabled, - ); + ), + ); + } + @override Future update({ required String id, String? name, @@ -28,15 +36,19 @@ class RulesRepository { double? threshold, bool clearThreshold = false, bool? enabled, - }) => - _api.update( + }) { + return guardApi( + () => _api.update( id: id, name: name, eventType: eventType, threshold: threshold, clearThreshold: clearThreshold, enabled: enabled, - ); + ), + ); + } - Future delete(String id) => _api.delete(id); + @override + Future delete(String id) => guardApi(() => _api.delete(id)); } diff --git a/mobile/lib/features/rules/domain/rules_repository.dart b/mobile/lib/features/rules/domain/rules_repository.dart new file mode 100644 index 0000000..c515760 --- /dev/null +++ b/mobile/lib/features/rules/domain/rules_repository.dart @@ -0,0 +1,23 @@ +import '../data/rule_models.dart'; + +abstract class RulesRepository { + Future> list(); + + Future create({ + required String name, + required String eventType, + double? threshold, + bool enabled = true, + }); + + Future update({ + required String id, + String? name, + String? eventType, + double? threshold, + bool clearThreshold = false, + bool? enabled, + }); + + Future delete(String id); +} diff --git a/mobile/lib/features/rules/presentation/cubit/rules_cubit.dart b/mobile/lib/features/rules/presentation/cubit/rules_cubit.dart index 5b9d109..cfd895f 100644 --- a/mobile/lib/features/rules/presentation/cubit/rules_cubit.dart +++ b/mobile/lib/features/rules/presentation/cubit/rules_cubit.dart @@ -1,7 +1,7 @@ -import 'package:dio/dio.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../data/rules_repository.dart'; +import '../../../../core/error/error_mapper.dart'; +import '../../domain/rules_repository.dart'; import 'rules_state.dart'; class RulesCubit extends Cubit { @@ -22,7 +22,7 @@ class RulesCubit extends Cubit { emit( RulesState( status: RulesStatus.error, - errorMessage: _message(e), + errorMessage: mapError(e).message, ), ); } @@ -47,7 +47,7 @@ class RulesCubit extends Cubit { await load(); return true; } catch (e) { - emit(state.copyWith(busy: false, errorMessage: _message(e))); + emit(state.copyWith(busy: false, errorMessage: mapError(e).message)); return false; } } @@ -73,7 +73,7 @@ class RulesCubit extends Cubit { await load(); return true; } catch (e) { - emit(state.copyWith(busy: false, errorMessage: _message(e))); + emit(state.copyWith(busy: false, errorMessage: mapError(e).message)); return false; } } @@ -84,21 +84,7 @@ class RulesCubit extends Cubit { await _repo.delete(id); await load(); } catch (e) { - emit(state.copyWith(busy: false, errorMessage: _message(e))); + emit(state.copyWith(busy: false, errorMessage: mapError(e).message)); } } - - String _message(Object e) { - if (e is DioException) { - final data = e.response?.data; - if (data is Map && data['error'] is Map) { - final msg = data['error']['message']; - if (msg is String && msg.isNotEmpty) { - return msg; - } - } - return 'Request failed'; - } - return 'Something went wrong'; - } } diff --git a/mobile/lib/features/rules/presentation/rules_page.dart b/mobile/lib/features/rules/presentation/rules_page.dart index c8c51d3..e214116 100644 --- a/mobile/lib/features/rules/presentation/rules_page.dart +++ b/mobile/lib/features/rules/presentation/rules_page.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../core/widgets/app_bottom_nav.dart'; import '../../../core/widgets/empty_state.dart'; import '../../../core/widgets/error_state.dart'; import '../../../core/widgets/loading_state.dart'; @@ -18,7 +17,16 @@ class RulesPage extends StatelessWidget { final scheme = Theme.of(context).colorScheme; return Scaffold( - appBar: AppBar(title: const Text('Rules')), + appBar: AppBar( + title: const Text('Rules'), + actions: [ + IconButton( + tooltip: 'Refresh', + onPressed: () => context.read().refresh(), + icon: const Icon(Icons.refresh), + ), + ], + ), floatingActionButton: FloatingActionButton( onPressed: () => showRuleFormSheet(context), tooltip: 'New rule', @@ -45,7 +53,9 @@ class RulesPage extends StatelessWidget { height: MediaQuery.sizeOf(context).height * 0.55, child: EmptyState( title: 'No alert rules yet', - message: 'Create a rule to match incoming events.', + message: + 'Rules run in the Go worker after claim.\n' + 'Seed creates “Demo balance watch” (balance_drop ≤ 150).', icon: Icons.rule_outlined, actionLabel: 'New rule', onAction: () => showRuleFormSheet(context), @@ -83,6 +93,7 @@ class RulesPage extends StatelessWidget { rule.eventType, if (rule.threshold != null) '≤ ${rule.threshold}', rule.enabled ? 'enabled' : 'disabled', + 'matched by worker', ].join(' · '), onTap: () => showRuleFormSheet(context, existing: rule), trailing: IconButton( @@ -101,7 +112,6 @@ class RulesPage extends StatelessWidget { }; }, ), - bottomNavigationBar: const AppBottomNav(selectedIndex: 1), ); } } diff --git a/mobile/lib/features/settings/presentation/settings_page.dart b/mobile/lib/features/settings/presentation/settings_page.dart index 7c481a4..38a61a4 100644 --- a/mobile/lib/features/settings/presentation/settings_page.dart +++ b/mobile/lib/features/settings/presentation/settings_page.dart @@ -1,10 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import '../../../core/constants.dart'; +import '../../../core/demo_credentials.dart'; import '../../../core/theme/app_spacing.dart'; import '../../auth/presentation/cubit/auth_cubit.dart'; +import '../../ops/presentation/widgets/ops_status_bar.dart'; class SettingsPage extends StatelessWidget { const SettingsPage({super.key}); @@ -18,12 +21,12 @@ class SettingsPage extends StatelessWidget { return Scaffold( appBar: AppBar(title: const Text('Settings')), body: ListView( - padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), children: [ + const OpsStatusBar(), Padding( padding: const EdgeInsets.fromLTRB( AppSpacing.md, - AppSpacing.xs, + AppSpacing.md, AppSpacing.md, AppSpacing.xs, ), @@ -34,17 +37,45 @@ class SettingsPage extends StatelessWidget { ), ), ), + _SettingsTile(label: 'Signed in as', value: email), + _SettingsTile(label: 'API base', value: kApiBase), + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.md, + AppSpacing.md, + AppSpacing.xs, + ), + child: Text( + 'Local demo', + style: theme.textTheme.labelMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ), + _SettingsTile(label: 'Demo email', value: kDemoEmail, copyable: true), _SettingsTile( - label: 'Signed in as', - value: email, + label: 'Demo password', + value: kDemoPassword, + copyable: true, ), _SettingsTile( - label: 'API base', - value: kApiBase, + label: 'Webhook user_ref', + value: kDemoUserRef, + copyable: true, + ), + Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Text( + '1) docker compose up --build -d\n' + '2) ./scripts/seed_webhooks.sh\n' + '3) Sign in with the demo account\n' + '4) Watch Events status move pending → processed\n' + '5) Open an event for the backend pipeline + Explain', + style: theme.textTheme.bodySmall?.copyWith(height: 1.45), + ), ), - const SizedBox(height: AppSpacing.md), const Divider(height: 1), - const SizedBox(height: AppSpacing.xs), ListTile( contentPadding: const EdgeInsets.symmetric( horizontal: AppSpacing.md, @@ -65,6 +96,7 @@ class SettingsPage extends StatelessWidget { } }, ), + const SizedBox(height: AppSpacing.lg), ], ), ); @@ -72,10 +104,15 @@ class SettingsPage extends StatelessWidget { } class _SettingsTile extends StatelessWidget { - const _SettingsTile({required this.label, required this.value}); + const _SettingsTile({ + required this.label, + required this.value, + this.copyable = false, + }); final String label; final String value; + final bool copyable; @override Widget build(BuildContext context) { @@ -87,17 +124,37 @@ class _SettingsTile extends StatelessWidget { horizontal: AppSpacing.md, vertical: AppSpacing.sm, ), - child: Column( + child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - label, - style: theme.textTheme.labelSmall?.copyWith( - color: scheme.onSurfaceVariant, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 2), + SelectableText(value, style: theme.textTheme.bodyLarge), + ], ), ), - const SizedBox(height: 2), - Text(value, style: theme.textTheme.bodyLarge), + if (copyable) + IconButton( + tooltip: 'Copy', + onPressed: () async { + await Clipboard.setData(ClipboardData(text: value)); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Copied $label')), + ); + } + }, + icon: const Icon(Icons.copy_outlined, size: 18), + ), ], ), ); diff --git a/mobile/lib/features/shell/presentation/app_shell.dart b/mobile/lib/features/shell/presentation/app_shell.dart new file mode 100644 index 0000000..6a800f9 --- /dev/null +++ b/mobile/lib/features/shell/presentation/app_shell.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../ops/presentation/widgets/ops_status_bar.dart'; + +/// Authenticated chrome: live ops strip + indexed tab shell. +class AppShell extends StatelessWidget { + const AppShell({super.key, required this.navigationShell}); + + final StatefulNavigationShell navigationShell; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const OpsStatusBar(), + Expanded(child: navigationShell), + ], + ), + bottomNavigationBar: NavigationBar( + selectedIndex: navigationShell.currentIndex, + onDestinationSelected: (index) { + navigationShell.goBranch( + index, + initialLocation: index == navigationShell.currentIndex, + ); + }, + destinations: const [ + NavigationDestination( + icon: Icon(Icons.inbox_outlined), + selectedIcon: Icon(Icons.inbox), + label: 'Events', + ), + NavigationDestination( + icon: Icon(Icons.rule_outlined), + selectedIcon: Icon(Icons.rule), + label: 'Rules', + ), + ], + ), + ); + } +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 0d34819..dbb18fa 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -4,12 +4,17 @@ import 'app.dart'; import 'core/di/injection.dart'; import 'core/router/app_router.dart'; import 'features/auth/presentation/cubit/auth_cubit.dart'; +import 'features/auth/presentation/cubit/auth_state.dart'; +import 'features/ops/presentation/cubit/ops_health_cubit.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); await configureDependencies(); final authCubit = getIt(); await authCubit.bootstrap(); + if (authCubit.state.status == AuthStatus.authenticated) { + getIt().start(); + } final router = createAppRouter(authCubit); runApp(WalletOpsApp(authCubit: authCubit, router: router)); } diff --git a/mobile/test/auth_gate_test.dart b/mobile/test/auth_gate_test.dart index 4d72088..2d44116 100644 --- a/mobile/test/auth_gate_test.dart +++ b/mobile/test/auth_gate_test.dart @@ -4,12 +4,20 @@ import 'package:walletops_mobile/app.dart'; import 'package:walletops_mobile/core/di/injection.dart'; import 'package:walletops_mobile/core/router/app_router.dart'; import 'package:walletops_mobile/core/storage/token_storage.dart'; -import 'package:walletops_mobile/features/auth/data/auth_repository.dart'; +import 'package:walletops_mobile/features/auth/domain/auth_repository.dart'; import 'package:walletops_mobile/features/auth/presentation/cubit/auth_cubit.dart'; import 'package:walletops_mobile/features/auth/presentation/login_page.dart'; +import 'package:walletops_mobile/features/ops/data/health_models.dart'; +import 'package:walletops_mobile/features/ops/domain/ops_repository.dart'; +import 'package:walletops_mobile/features/ops/presentation/cubit/ops_health_cubit.dart'; class _MockAuthRepository extends Mock implements AuthRepository {} +class _FakeOpsRepository implements OpsRepository { + @override + Future fetchHealth() async => const OpsHealth(status: 'ok'); +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -25,6 +33,8 @@ void main() { getIt.registerSingleton(InMemoryTokenStorage()); getIt.registerSingleton(repo); getIt.registerSingleton(cubit); + getIt.registerLazySingleton(_FakeOpsRepository.new); + getIt.registerLazySingleton(() => OpsHealthCubit(getIt())); }); tearDown(() async { diff --git a/mobile/test/auth_login_integration_test.dart b/mobile/test/auth_login_integration_test.dart index 284408b..1037b84 100644 --- a/mobile/test/auth_login_integration_test.dart +++ b/mobile/test/auth_login_integration_test.dart @@ -1,7 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:walletops_mobile/core/network/api_client.dart'; import 'package:walletops_mobile/core/storage/token_storage.dart'; -import 'package:walletops_mobile/features/auth/data/auth_repository.dart'; +import 'package:walletops_mobile/features/auth/data/auth_repository_impl.dart'; import 'package:walletops_mobile/features/auth/presentation/cubit/auth_cubit.dart'; import 'package:walletops_mobile/features/auth/presentation/cubit/auth_state.dart'; @@ -17,7 +17,7 @@ void main() { baseUrl: base, onSessionExpired: () {}, ); - final repo = AuthRepository(api: api.authApi, storage: storage); + final repo = AuthRepositoryImpl(api: api.authApi, storage: storage); final cubit = AuthCubit(repo); final email = diff --git a/mobile/test/events_list_test.dart b/mobile/test/events_list_test.dart index 11288f1..dc60546 100644 --- a/mobile/test/events_list_test.dart +++ b/mobile/test/events_list_test.dart @@ -61,8 +61,9 @@ void main() { expect(find.text('balance_drop'), findsOneWidget); expect(find.text('swap_quote'), findsOneWidget); - expect(find.text('evt_fixture_balance'), findsOneWidget); - expect(find.text('evt_fixture_swap'), findsOneWidget); + expect(find.textContaining('evt_fixture_balance'), findsOneWidget); + expect(find.textContaining('evt_fixture_swap'), findsOneWidget); + expect(find.textContaining('120.5 USDC'), findsOneWidget); expect(find.byType(StatusChip), findsNWidgets(2)); expect(find.text('processed'), findsOneWidget); expect(find.text('pending'), findsOneWidget); diff --git a/mobile/test/events_rules_api_integration_test.dart b/mobile/test/events_rules_api_integration_test.dart index 4f77ab7..8f17c39 100644 --- a/mobile/test/events_rules_api_integration_test.dart +++ b/mobile/test/events_rules_api_integration_test.dart @@ -1,15 +1,15 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:walletops_mobile/core/network/api_client.dart'; import 'package:walletops_mobile/core/storage/token_storage.dart'; -import 'package:walletops_mobile/features/auth/data/auth_repository.dart'; +import 'package:walletops_mobile/features/auth/data/auth_repository_impl.dart'; import 'package:walletops_mobile/features/events/data/events_api.dart'; -import 'package:walletops_mobile/features/events/data/events_repository.dart'; +import 'package:walletops_mobile/features/events/data/events_repository_impl.dart'; import 'package:walletops_mobile/features/events/presentation/cubit/event_detail_cubit.dart'; import 'package:walletops_mobile/features/events/presentation/cubit/event_detail_state.dart'; import 'package:walletops_mobile/features/events/presentation/cubit/events_cubit.dart'; import 'package:walletops_mobile/features/events/presentation/cubit/events_state.dart'; import 'package:walletops_mobile/features/rules/data/rules_api.dart'; -import 'package:walletops_mobile/features/rules/data/rules_repository.dart'; +import 'package:walletops_mobile/features/rules/data/rules_repository_impl.dart'; import 'package:walletops_mobile/features/rules/presentation/cubit/rules_cubit.dart'; import 'package:walletops_mobile/features/rules/presentation/cubit/rules_state.dart'; @@ -25,12 +25,12 @@ void main() { baseUrl: base, onSessionExpired: () {}, ); - final auth = AuthRepository(api: api.authApi, storage: storage); + final auth = AuthRepositoryImpl(api: api.authApi, storage: storage); final email = 'mobile-events-${DateTime.now().microsecondsSinceEpoch}@walletops.local'; await auth.register(email: email, password: 'ops-secret-1'); - final rulesCubit = RulesCubit(RulesRepository(RulesApi(api.dio))); + final rulesCubit = RulesCubit(RulesRepositoryImpl(RulesApi(api.dio))); final created = await rulesCubit.create( name: 'drop watch', eventType: 'balance_drop', @@ -54,7 +54,7 @@ void main() { isTrue, ); - final eventsCubit = EventsCubit(EventsRepository(EventsApi(api.dio))); + final eventsCubit = EventsCubit(EventsRepositoryImpl(EventsApi(api.dio))); await eventsCubit.load(); expect( eventsCubit.state.status == EventsStatus.empty || @@ -66,7 +66,7 @@ void main() { // if empty, still validates cubit empty path. if (eventsCubit.state.status == EventsStatus.ready) { final id = eventsCubit.state.items.first.id; - final detail = EventDetailCubit(EventsRepository(EventsApi(api.dio))); + final detail = EventDetailCubit(EventsRepositoryImpl(EventsApi(api.dio))); await detail.load(id); expect(detail.state.status, EventDetailStatus.ready); expect(detail.state.event?.id, id); diff --git a/mobile/test/explain_api_integration_test.dart b/mobile/test/explain_api_integration_test.dart index 61e129e..dcfb0e0 100644 --- a/mobile/test/explain_api_integration_test.dart +++ b/mobile/test/explain_api_integration_test.dart @@ -5,9 +5,9 @@ import 'package:crypto/crypto.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:walletops_mobile/core/network/api_client.dart'; import 'package:walletops_mobile/core/storage/token_storage.dart'; -import 'package:walletops_mobile/features/auth/data/auth_repository.dart'; +import 'package:walletops_mobile/features/auth/data/auth_repository_impl.dart'; import 'package:walletops_mobile/features/explain/data/ai_api.dart'; -import 'package:walletops_mobile/features/explain/data/explain_repository.dart'; +import 'package:walletops_mobile/features/explain/data/explain_repository_impl.dart'; import 'package:walletops_mobile/features/explain/presentation/cubit/explain_cubit.dart'; import 'package:walletops_mobile/features/explain/presentation/cubit/explain_state.dart'; @@ -24,7 +24,7 @@ void main() { baseUrl: base, onSessionExpired: () {}, ); - final auth = AuthRepository(api: api.authApi, storage: storage); + final auth = AuthRepositoryImpl(api: api.authApi, storage: storage); final stamp = DateTime.now().microsecondsSinceEpoch; final email = 'explain-$stamp@walletops.local'; final userRef = 'explain-ref-$stamp'; @@ -79,7 +79,7 @@ void main() { expect(res.statusCode, anyOf(200, 202), reason: resBody); final eventId = jsonDecode(resBody)['id'] as String; - final cubit = ExplainCubit(ExplainRepository(AiApi(api.dio))); + final cubit = ExplainCubit(ExplainRepositoryImpl(AiApi(api.dio))); await cubit.load([eventId]); expect(cubit.state.status, ExplainStatus.ready); expect(cubit.state.summary?.title, isNotEmpty);