Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
35 changes: 26 additions & 9 deletions mobile/lib/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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<OpsHealthCubit>()),
],
child: BlocListener<AuthCubit, AuthState>(
listenWhen: (prev, next) => prev.status != next.status,
listener: (context, state) {
final ops = context.read<OpsHealthCubit>();
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,
),
),
);
}
Expand Down
28 changes: 20 additions & 8 deletions mobile/lib/core/di/injection.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -36,22 +44,26 @@ Future<void> configureDependencies({
);
getIt.registerSingleton<ApiClient>(api);

final authRepo = AuthRepository(api: api.authApi, storage: storage);
final authRepo = AuthRepositoryImpl(api: api.authApi, storage: storage);
getIt.registerSingleton<AuthRepository>(authRepo);

authCubit = AuthCubit(authRepo);
getIt.registerSingleton<AuthCubit>(authCubit);

final eventsRepo = EventsRepository(EventsApi(api.dio));
final eventsRepo = EventsRepositoryImpl(EventsApi(api.dio));
getIt.registerSingleton<EventsRepository>(eventsRepo);
getIt.registerFactory(() => EventsCubit(eventsRepo));
getIt.registerFactory(() => EventDetailCubit(eventsRepo));

final rulesRepo = RulesRepository(RulesApi(api.dio));
final rulesRepo = RulesRepositoryImpl(RulesApi(api.dio));
getIt.registerSingleton<RulesRepository>(rulesRepo);
getIt.registerFactory(() => RulesCubit(rulesRepo));

final explainRepo = ExplainRepository(AiApi(api.dio));
final explainRepo = ExplainRepositoryImpl(AiApi(api.dio));
getIt.registerSingleton<ExplainRepository>(explainRepo);
getIt.registerFactory(() => ExplainCubit(explainRepo));

final opsRepo = OpsRepositoryImpl(HealthApi(api.dio));
getIt.registerSingleton<OpsRepository>(opsRepo);
getIt.registerLazySingleton(() => OpsHealthCubit(opsRepo));
}
10 changes: 10 additions & 0 deletions mobile/lib/core/error/app_exception.dart
Original file line number Diff line number Diff line change
@@ -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;
}
52 changes: 52 additions & 0 deletions mobile/lib/core/error/error_mapper.dart
Original file line number Diff line number Diff line change
@@ -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<T> guardApi<T>(Future<T> Function() run) async {
try {
return await run();
} on DioException catch (e) {
throw mapDioError(e);
}
}
3 changes: 3 additions & 0 deletions mobile/lib/core/network/request_options.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import 'package:dio/dio.dart';

Options skipAuthOptions() => Options(extra: const {'skipAuth': true});
50 changes: 37 additions & 13 deletions mobile/lib/core/router/app_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -48,12 +49,42 @@ GoRouter createAppRouter(AuthCubit authCubit) {
path: '/register',
builder: (context, state) => const RegisterPage(),
),
GoRoute(
path: '/events',
builder: (context, state) => BlocProvider(
create: (_) => getIt<EventsCubit>()..load(),
child: const EventsPage(),
),
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) {
return MultiBlocProvider(
providers: [
BlocProvider(
create: (_) => getIt<EventsCubit>()..load(),
),
BlocProvider(
create: (_) => getIt<RulesCubit>()..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',
Expand All @@ -80,13 +111,6 @@ GoRouter createAppRouter(AuthCubit authCubit) {
);
},
),
GoRoute(
path: '/rules',
builder: (context, state) => BlocProvider(
create: (_) => getIt<RulesCubit>()..load(),
child: const RulesPage(),
),
),
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsPage(),
Expand Down
39 changes: 0 additions & 39 deletions mobile/lib/core/widgets/app_bottom_nav.dart

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -12,46 +14,55 @@ class AuthRepository {
final AuthApi _api;
final TokenStorage _storage;

@override
Future<bool> hasSession() async {
final access = await _storage.readAccessToken();
return access != null && access.isNotEmpty;
}

@override
Future<UserProfile> 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<UserProfile> 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<UserProfile?> 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<void> logout() => _storage.clear();
}
Loading
Loading