diff --git a/front001/mosenioring/android/app/src/main/AndroidManifest.xml b/front001/mosenioring/android/app/src/main/AndroidManifest.xml index 68c2b79..c00298a 100644 --- a/front001/mosenioring/android/app/src/main/AndroidManifest.xml +++ b/front001/mosenioring/android/app/src/main/AndroidManifest.xml @@ -27,6 +27,15 @@ + + + + + + + + + + com.apple.developer.associated-domains + + applinks:app.mosenioring.com + + + diff --git a/front001/mosenioring/lib/l10n/app_en.arb b/front001/mosenioring/lib/l10n/app_en.arb index e2ca057..e0c3580 100644 --- a/front001/mosenioring/lib/l10n/app_en.arb +++ b/front001/mosenioring/lib/l10n/app_en.arb @@ -8,5 +8,20 @@ "loginButton": "Sign in with Keycloak", "loginRequired": "Email and password are required.", "signedInMessage": "You are signed in.", - "logoutTooltip": "Logout" + "logoutTooltip": "Logout", + "inviteTitle": "Invitation", + "inviteLoadingMessage": "Loading invitation...", + "inviteWaitingMessage": "Waiting for invitation link.", + "inviteDetailsTitle": "You're invited", + "inviteEmailLabel": "Email", + "inviteRoleLabel": "Role", + "inviteExpiresLabel": "Expires", + "inviteContinueButton": "Continue", + "inviteRetryButton": "Retry", + "inviteOfflineResolveMessage": "Internet required to accept the invitation.", + "inviteOfflineAcceptMessage": "Internet required to accept the invitation.", + "inviteGenericError": "Something went wrong with the invitation.", + "inviteInvalidTitle": "Invitation unavailable", + "inviteInvalidMessage": "This invitation is invalid or expired.", + "inviteDismissButton": "Back to sign in" } diff --git a/front001/mosenioring/lib/l10n/app_localizations.dart b/front001/mosenioring/lib/l10n/app_localizations.dart index 8fc14aa..4204ca7 100644 --- a/front001/mosenioring/lib/l10n/app_localizations.dart +++ b/front001/mosenioring/lib/l10n/app_localizations.dart @@ -147,6 +147,96 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Logout'** String get logoutTooltip; + + /// No description provided for @inviteTitle. + /// + /// In en, this message translates to: + /// **'Invitation'** + String get inviteTitle; + + /// No description provided for @inviteLoadingMessage. + /// + /// In en, this message translates to: + /// **'Loading invitation...'** + String get inviteLoadingMessage; + + /// No description provided for @inviteWaitingMessage. + /// + /// In en, this message translates to: + /// **'Waiting for invitation link.'** + String get inviteWaitingMessage; + + /// No description provided for @inviteDetailsTitle. + /// + /// In en, this message translates to: + /// **'You\'re invited'** + String get inviteDetailsTitle; + + /// No description provided for @inviteEmailLabel. + /// + /// In en, this message translates to: + /// **'Email'** + String get inviteEmailLabel; + + /// No description provided for @inviteRoleLabel. + /// + /// In en, this message translates to: + /// **'Role'** + String get inviteRoleLabel; + + /// No description provided for @inviteExpiresLabel. + /// + /// In en, this message translates to: + /// **'Expires'** + String get inviteExpiresLabel; + + /// No description provided for @inviteContinueButton. + /// + /// In en, this message translates to: + /// **'Continue'** + String get inviteContinueButton; + + /// No description provided for @inviteRetryButton. + /// + /// In en, this message translates to: + /// **'Retry'** + String get inviteRetryButton; + + /// No description provided for @inviteOfflineResolveMessage. + /// + /// In en, this message translates to: + /// **'Internet required to accept the invitation.'** + String get inviteOfflineResolveMessage; + + /// No description provided for @inviteOfflineAcceptMessage. + /// + /// In en, this message translates to: + /// **'Internet required to accept the invitation.'** + String get inviteOfflineAcceptMessage; + + /// No description provided for @inviteGenericError. + /// + /// In en, this message translates to: + /// **'Something went wrong with the invitation.'** + String get inviteGenericError; + + /// No description provided for @inviteInvalidTitle. + /// + /// In en, this message translates to: + /// **'Invitation unavailable'** + String get inviteInvalidTitle; + + /// No description provided for @inviteInvalidMessage. + /// + /// In en, this message translates to: + /// **'This invitation is invalid or expired.'** + String get inviteInvalidMessage; + + /// No description provided for @inviteDismissButton. + /// + /// In en, this message translates to: + /// **'Back to sign in'** + String get inviteDismissButton; } class _AppLocalizationsDelegate diff --git a/front001/mosenioring/lib/l10n/app_localizations_en.dart b/front001/mosenioring/lib/l10n/app_localizations_en.dart index 8efc8e8..e82803b 100644 --- a/front001/mosenioring/lib/l10n/app_localizations_en.dart +++ b/front001/mosenioring/lib/l10n/app_localizations_en.dart @@ -34,4 +34,51 @@ class AppLocalizationsEn extends AppLocalizations { @override String get logoutTooltip => 'Logout'; + + @override + String get inviteTitle => 'Invitation'; + + @override + String get inviteLoadingMessage => 'Loading invitation...'; + + @override + String get inviteWaitingMessage => 'Waiting for invitation link.'; + + @override + String get inviteDetailsTitle => 'You\'re invited'; + + @override + String get inviteEmailLabel => 'Email'; + + @override + String get inviteRoleLabel => 'Role'; + + @override + String get inviteExpiresLabel => 'Expires'; + + @override + String get inviteContinueButton => 'Continue'; + + @override + String get inviteRetryButton => 'Retry'; + + @override + String get inviteOfflineResolveMessage => + 'Internet required to accept the invitation.'; + + @override + String get inviteOfflineAcceptMessage => + 'Internet required to accept the invitation.'; + + @override + String get inviteGenericError => 'Something went wrong with the invitation.'; + + @override + String get inviteInvalidTitle => 'Invitation unavailable'; + + @override + String get inviteInvalidMessage => 'This invitation is invalid or expired.'; + + @override + String get inviteDismissButton => 'Back to sign in'; } diff --git a/front001/mosenioring/lib/src/app/app.dart b/front001/mosenioring/lib/src/app/app.dart index 869b1d1..607f2fe 100644 --- a/front001/mosenioring/lib/src/app/app.dart +++ b/front001/mosenioring/lib/src/app/app.dart @@ -3,6 +3,7 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mosenioring/l10n/app_localizations.dart'; +import '../di/providers.dart'; import 'router.dart'; class App extends ConsumerWidget { @@ -10,6 +11,7 @@ class App extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + ref.watch(inviteControllerProvider); final router = ref.watch(appRouterProvider); return MaterialApp.router( diff --git a/front001/mosenioring/lib/src/app/router.dart b/front001/mosenioring/lib/src/app/router.dart index fe80336..b7a45c2 100644 --- a/front001/mosenioring/lib/src/app/router.dart +++ b/front001/mosenioring/lib/src/app/router.dart @@ -6,9 +6,13 @@ import '../features/auth/presentation/login_page.dart'; import '../features/home/presentation/home_page.dart'; import '../features/offline_lock/presentation/unlock_screen.dart'; import '../features/app_gate/presentation/app_gate_state.dart'; +import '../features/invite/presentation/invite_screen.dart'; +import '../features/invite/presentation/invalid_invite_screen.dart'; +import '../features/invite/presentation/invite_state.dart'; final appRouterProvider = Provider((ref) { final appGateState = ref.watch(appGateControllerProvider); + final inviteState = ref.watch(inviteControllerProvider); return GoRouter( initialLocation: '/login', @@ -25,13 +29,47 @@ final appRouterProvider = Provider((ref) { path: '/unlock', builder: (context, state) => const UnlockScreen(), ), + GoRoute( + path: '/invite', + builder: (context, state) => const InviteScreen(), + ), + GoRoute( + path: '/invite/invalid', + builder: (context, state) => const InvalidInviteScreen(), + ), ], redirect: (context, state) { + final location = state.matchedLocation; + + if (inviteState.hasToken) { + if (inviteState.status == InviteStatus.invalid && + location != '/invite/invalid') { + return '/invite/invalid'; + } + if (inviteState.status != InviteStatus.invalid && location != '/invite') { + return '/invite'; + } + if (inviteState.status != InviteStatus.invalid && + location == '/invite/invalid') { + return '/invite'; + } + return null; + } + if (appGateState.isLoading) { return null; } final destination = appGateState.destination; - final location = state.matchedLocation; + + if (location.startsWith('/invite')) { + if (destination == AppGateDestination.login) { + return '/login'; + } + if (destination == AppGateDestination.unlock) { + return '/unlock'; + } + return '/'; + } if (destination == AppGateDestination.login && location != '/login') { return '/login'; diff --git a/front001/mosenioring/lib/src/core/deeplink/invite_link_service.dart b/front001/mosenioring/lib/src/core/deeplink/invite_link_service.dart new file mode 100644 index 0000000..c178c5b --- /dev/null +++ b/front001/mosenioring/lib/src/core/deeplink/invite_link_service.dart @@ -0,0 +1,40 @@ +import 'dart:async'; + +import 'package:app_links/app_links.dart'; + +abstract class InviteLinkService { + Stream get tokenStream; + Future getInitialToken(); + + static String? extractToken(Uri uri) { + if (uri.path != '/invite') { + return null; + } + final token = uri.queryParameters['token']; + if (token == null || token.trim().isEmpty) { + return null; + } + return token; + } +} + +class AppInviteLinkService implements InviteLinkService { + AppInviteLinkService(this._appLinks); + + final AppLinks _appLinks; + + @override + Stream get tokenStream => _appLinks.uriLinkStream + .map(InviteLinkService.extractToken) + .where((token) => token != null) + .cast(); + + @override + Future getInitialToken() async { + final uri = await _appLinks.getInitialLink(); + if (uri == null) { + return null; + } + return InviteLinkService.extractToken(uri); + } +} diff --git a/front001/mosenioring/lib/src/di/providers.dart b/front001/mosenioring/lib/src/di/providers.dart index f7db814..53d2bd0 100644 --- a/front001/mosenioring/lib/src/di/providers.dart +++ b/front001/mosenioring/lib/src/di/providers.dart @@ -1,3 +1,4 @@ +import 'package:app_links/app_links.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:dio/dio.dart'; import 'package:flutter_appauth/flutter_appauth.dart'; @@ -6,6 +7,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:local_auth/local_auth.dart'; import '../core/config/app_config.dart'; +import '../core/deeplink/invite_link_service.dart'; import '../core/network/api_client.dart'; import '../core/network/connectivity_plus_service.dart'; import '../core/network/connectivity_service.dart'; @@ -38,6 +40,9 @@ import '../features/telemetry/domain/telemetry_service.dart'; import '../features/telemetry/domain/token_provider.dart'; import '../features/telemetry/presentation/telemetry_controller.dart'; import '../features/telemetry/presentation/telemetry_state.dart'; +import '../features/invite/data/invite_api.dart'; +import '../features/invite/presentation/invite_controller.dart'; +import '../features/invite/presentation/invite_state.dart'; final appConfigProvider = Provider((ref) => AppConfig.fromEnvironment()); @@ -49,6 +54,14 @@ final connectivityProvider = Provider((ref) { return Connectivity(); }); +final appLinksProvider = Provider((ref) { + return AppLinks(); +}); + +final inviteLinkServiceProvider = Provider((ref) { + return AppInviteLinkService(ref.watch(appLinksProvider)); +}); + final connectivityServiceProvider = Provider((ref) { return ConnectivityPlusService(ref.watch(connectivityProvider)); }); @@ -187,6 +200,14 @@ final httpClientProvider = Provider((ref) { return ApiHttpClient(ref.watch(apiClientProvider)); }); +final inviteApiProvider = Provider((ref) { + return InviteApiImpl(ref.watch(httpClientProvider)); +}); + +final inviteControllerProvider = NotifierProvider(() { + return InviteController(); +}); + final platformInfoProvider = Provider((ref) { return const DevicePlatformInfo(); }); diff --git a/front001/mosenioring/lib/src/features/invite/data/invite_api.dart b/front001/mosenioring/lib/src/features/invite/data/invite_api.dart new file mode 100644 index 0000000..a6ab7aa --- /dev/null +++ b/front001/mosenioring/lib/src/features/invite/data/invite_api.dart @@ -0,0 +1,67 @@ +import '../../../core/network/http_client.dart'; + +class InviteApiResponse { + const InviteApiResponse({ + required this.statusCode, + this.data, + }); + + final int statusCode; + final T? data; +} + +abstract class InviteApi { + Future>> resolveInvite({ + required String token, + }); + + Future>> acceptInvite({ + required String token, + }); +} + +class InviteApiImpl implements InviteApi { + InviteApiImpl(this._httpClient); + + final HttpClient _httpClient; + + @override + Future>> resolveInvite({ + required String token, + }) async { + final response = await _httpClient.post( + '/api/v1/invites/resolve', + data: {'token': token}, + headers: const {'Content-Type': 'application/json'}, + ); + return InviteApiResponse( + statusCode: response.statusCode, + data: _asJsonMap(response.data), + ); + } + + @override + Future>> acceptInvite({ + required String token, + }) async { + final response = await _httpClient.post( + '/api/v1/invites/accept', + data: {'token': token}, + headers: const {'Content-Type': 'application/json'}, + ); + return InviteApiResponse( + statusCode: response.statusCode, + data: _asJsonMap(response.data), + ); + } + + Map? _asJsonMap(Object? data) { + if (data is Map) { + return data; + } + if (data is Map) { + return Map.from(data); + } + return null; + } +} diff --git a/front001/mosenioring/lib/src/features/invite/domain/invite_resolution.dart b/front001/mosenioring/lib/src/features/invite/domain/invite_resolution.dart new file mode 100644 index 0000000..09af790 --- /dev/null +++ b/front001/mosenioring/lib/src/features/invite/domain/invite_resolution.dart @@ -0,0 +1,24 @@ +class InviteResolution { + const InviteResolution({ + required this.role, + required this.emailMasked, + required this.expiresAt, + }); + + final String role; + final String emailMasked; + final DateTime expiresAt; + + factory InviteResolution.fromJson(Map json) { + final expiresAtRaw = json['expiresAt']?.toString() ?? ''; + final expiresAt = DateTime.tryParse(expiresAtRaw); + if (expiresAt == null) { + throw FormatException('Invalid expiresAt'); + } + return InviteResolution( + role: json['role']?.toString() ?? '', + emailMasked: json['emailMasked']?.toString() ?? '', + expiresAt: expiresAt, + ); + } +} diff --git a/front001/mosenioring/lib/src/features/invite/presentation/invalid_invite_screen.dart b/front001/mosenioring/lib/src/features/invite/presentation/invalid_invite_screen.dart new file mode 100644 index 0000000..bb0024e --- /dev/null +++ b/front001/mosenioring/lib/src/features/invite/presentation/invalid_invite_screen.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mosenioring/l10n/app_localizations.dart'; + +import '../../../di/providers.dart'; + +class InvalidInviteScreen extends ConsumerWidget { + const InvalidInviteScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final controller = ref.read(inviteControllerProvider.notifier); + final l10n = AppLocalizations.of(context)!; + + return Scaffold( + appBar: AppBar(title: Text(l10n.inviteInvalidTitle)), + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset( + 'assets/logo.png', + height: 96, + width: 96, + ), + const SizedBox(height: 16), + Text( + l10n.inviteInvalidMessage, + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: controller.clearInvite, + child: Text(l10n.inviteDismissButton), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/front001/mosenioring/lib/src/features/invite/presentation/invite_controller.dart b/front001/mosenioring/lib/src/features/invite/presentation/invite_controller.dart new file mode 100644 index 0000000..09889aa --- /dev/null +++ b/front001/mosenioring/lib/src/features/invite/presentation/invite_controller.dart @@ -0,0 +1,238 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/network/connectivity_service.dart'; +import '../../../core/network/http_client.dart'; +import '../../../di/providers.dart'; +import '../data/invite_api.dart'; +import '../domain/invite_resolution.dart'; +import 'invite_state.dart'; + +// Checklist: reuse GoRouter routing, Riverpod Notifiers, Dio/HttpClient + AppConfig base URL, +// AuthController/AppAuth for login, and inline error text style from existing screens. +class InviteController extends Notifier { + StreamSubscription? _linkSubscription; + + @override + InviteState build() { + Future.microtask(_bootstrap); + return const InviteState(); + } + + InviteApi get _inviteApi => ref.read(inviteApiProvider); + ConnectivityService get _connectivity => ref.read(connectivityServiceProvider); + + Future _bootstrap() async { + final linkService = ref.read(inviteLinkServiceProvider); + final initialToken = await linkService.getInitialToken(); + if (initialToken != null && initialToken.isNotEmpty) { + await onTokenReceived(initialToken); + } + _linkSubscription = linkService.tokenStream.listen(onTokenReceived); + ref.onDispose(() async { + await _linkSubscription?.cancel(); + }); + } + + Future onTokenReceived(String token) async { + if (token.isEmpty) { + return; + } + if (state.token == token && state.status != InviteStatus.invalid) { + return; + } + state = state.copyWith( + token: token, + status: InviteStatus.resolving, + resolution: null, + errorMessage: null, + offlineContext: null, + ); + await _resolveInvite(token); + } + + Future continueInvite() async { + if (state.isLoading) { + return; + } + final token = state.token; + if (token == null || token.isEmpty) { + return; + } + final authState = ref.read(authControllerProvider); + if (!authState.isAuthenticated) { + await ref + .read(authControllerProvider.notifier) + .login(email: '', password: ''); + final refreshedAuth = ref.read(authControllerProvider); + if (!refreshedAuth.isAuthenticated) { + state = state.copyWith( + errorMessage: refreshedAuth.errorMessage ?? 'Sign in required.', + ); + return; + } + } + await _acceptInvite(token); + } + + Future retry() async { + if (state.status != InviteStatus.offline) { + return; + } + final token = state.token; + if (token == null || token.isEmpty) { + return; + } + if (state.offlineContext == InviteOfflineContext.accept && + ref.read(authControllerProvider).isAuthenticated) { + await _acceptInvite(token); + return; + } + await _resolveInvite(token); + } + + void clearInvite() { + state = const InviteState(); + } + + Future _resolveInvite(String token) async { + state = state.copyWith( + status: InviteStatus.resolving, + errorMessage: null, + offlineContext: null, + ); + final isOnline = await _connectivity.isOnline(); + if (!isOnline) { + state = state.copyWith( + status: InviteStatus.offline, + offlineContext: InviteOfflineContext.resolve, + ); + return; + } + try { + final response = await _inviteApi.resolveInvite(token: token); + if (!ref.mounted) return; + if (response.statusCode == 200 && response.data != null) { + final resolution = _parseResolution(response.data!); + state = state.copyWith( + status: InviteStatus.resolved, + resolution: resolution, + errorMessage: null, + offlineContext: null, + ); + return; + } + if (response.statusCode == 400) { + state = state.copyWith( + status: InviteStatus.invalid, + resolution: null, + errorMessage: null, + offlineContext: null, + ); + return; + } + state = state.copyWith( + status: InviteStatus.error, + errorMessage: 'Unable to resolve invitation.', + offlineContext: null, + ); + } on HttpClientException catch (error) { + if (!ref.mounted) return; + if (error.isNetworkError) { + state = state.copyWith( + status: InviteStatus.offline, + offlineContext: InviteOfflineContext.resolve, + ); + return; + } + state = state.copyWith( + status: InviteStatus.error, + errorMessage: 'Unable to resolve invitation.', + offlineContext: null, + ); + } catch (_) { + if (!ref.mounted) return; + state = state.copyWith( + status: InviteStatus.error, + errorMessage: 'Unable to resolve invitation.', + offlineContext: null, + ); + } + } + + Future _acceptInvite(String token) async { + final isOnline = await _connectivity.isOnline(); + if (!isOnline) { + state = state.copyWith( + status: InviteStatus.offline, + offlineContext: InviteOfflineContext.accept, + ); + return; + } + state = state.copyWith( + status: InviteStatus.accepting, + errorMessage: null, + offlineContext: null, + ); + try { + final response = await _inviteApi.acceptInvite(token: token); + if (!ref.mounted) return; + if (response.statusCode == 200) { + state = InviteState( + status: InviteStatus.accepted, + acceptedPatientId: response.data?['patientId']?.toString(), + ); + ref.read(appGateControllerProvider.notifier).setOnlineAuthenticated(); + return; + } + if (response.statusCode == 400) { + state = state.copyWith( + status: InviteStatus.invalid, + resolution: null, + errorMessage: null, + offlineContext: null, + ); + return; + } + state = state.copyWith( + status: InviteStatus.error, + errorMessage: 'Unable to accept invitation.', + offlineContext: null, + ); + } on HttpClientException catch (error) { + if (!ref.mounted) return; + if (error.isNetworkError) { + state = state.copyWith( + status: InviteStatus.offline, + offlineContext: InviteOfflineContext.accept, + ); + return; + } + state = state.copyWith( + status: InviteStatus.error, + errorMessage: 'Unable to accept invitation.', + offlineContext: null, + ); + } catch (_) { + if (!ref.mounted) return; + state = state.copyWith( + status: InviteStatus.error, + errorMessage: 'Unable to accept invitation.', + offlineContext: null, + ); + } + } + + InviteResolution _parseResolution(Map json) { + final expiresAt = DateTime.tryParse(json['expiresAt']?.toString() ?? ''); + if (expiresAt == null) { + throw FormatException('Invalid expiresAt'); + } + return InviteResolution( + role: json['role']?.toString() ?? '', + emailMasked: json['emailMasked']?.toString() ?? '', + expiresAt: expiresAt, + ); + } +} diff --git a/front001/mosenioring/lib/src/features/invite/presentation/invite_screen.dart b/front001/mosenioring/lib/src/features/invite/presentation/invite_screen.dart new file mode 100644 index 0000000..5a5370b --- /dev/null +++ b/front001/mosenioring/lib/src/features/invite/presentation/invite_screen.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mosenioring/l10n/app_localizations.dart'; + +import '../../../di/providers.dart'; +import '../domain/invite_resolution.dart'; +import 'invite_state.dart'; + +class InviteScreen extends ConsumerWidget { + const InviteScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final inviteState = ref.watch(inviteControllerProvider); + final controller = ref.read(inviteControllerProvider.notifier); + final l10n = AppLocalizations.of(context)!; + final canContinue = inviteState.resolution != null && !inviteState.isLoading; + + return Scaffold( + appBar: AppBar(title: Text(l10n.inviteTitle)), + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset( + 'assets/logo.png', + height: 96, + width: 96, + ), + const SizedBox(height: 16), + if (inviteState.status == InviteStatus.resolving) + Text( + l10n.inviteLoadingMessage, + textAlign: TextAlign.center, + ) + else if (inviteState.resolution != null) + _InviteDetails(resolution: inviteState.resolution!) + else if (inviteState.status == InviteStatus.error) + Text( + inviteState.errorMessage ?? l10n.inviteGenericError, + textAlign: TextAlign.center, + ) + else + Text( + l10n.inviteWaitingMessage, + textAlign: TextAlign.center, + ), + if (inviteState.status == InviteStatus.offline) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + inviteState.offlineContext == InviteOfflineContext.accept + ? l10n.inviteOfflineAcceptMessage + : l10n.inviteOfflineResolveMessage, + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + if (inviteState.errorMessage != null && + inviteState.status != InviteStatus.error) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + inviteState.errorMessage!, + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: canContinue ? controller.continueInvite : null, + child: inviteState.status == InviteStatus.accepting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(l10n.inviteContinueButton), + ), + ), + if (inviteState.status == InviteStatus.offline) + Padding( + padding: const EdgeInsets.only(top: 12), + child: SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: controller.retry, + child: Text(l10n.inviteRetryButton), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _InviteDetails extends StatelessWidget { + const _InviteDetails({required this.resolution}); + + final InviteResolution resolution; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final localizations = MaterialLocalizations.of(context); + final expiresAt = resolution.expiresAt.toLocal(); + final expiresLabel = + '${localizations.formatFullDate(expiresAt)} ${localizations.formatTimeOfDay(TimeOfDay.fromDateTime(expiresAt))}'; + + return Column( + children: [ + Text( + l10n.inviteDetailsTitle, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + _InviteDetailRow(label: l10n.inviteEmailLabel, value: resolution.emailMasked), + _InviteDetailRow(label: l10n.inviteRoleLabel, value: resolution.role), + _InviteDetailRow(label: l10n.inviteExpiresLabel, value: expiresLabel), + ], + ); + } +} + +class _InviteDetailRow extends StatelessWidget { + const _InviteDetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + label, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + const SizedBox(width: 12), + Text( + value, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ); + } +} diff --git a/front001/mosenioring/lib/src/features/invite/presentation/invite_state.dart b/front001/mosenioring/lib/src/features/invite/presentation/invite_state.dart new file mode 100644 index 0000000..743ccea --- /dev/null +++ b/front001/mosenioring/lib/src/features/invite/presentation/invite_state.dart @@ -0,0 +1,58 @@ +import '../domain/invite_resolution.dart'; + +enum InviteStatus { + idle, + resolving, + resolved, + accepting, + accepted, + invalid, + error, + offline, +} + +enum InviteOfflineContext { + resolve, + accept, +} + +class InviteState { + const InviteState({ + this.status = InviteStatus.idle, + this.token, + this.resolution, + this.errorMessage, + this.offlineContext, + this.acceptedPatientId, + }); + + final InviteStatus status; + final String? token; + final InviteResolution? resolution; + final String? errorMessage; + final InviteOfflineContext? offlineContext; + final String? acceptedPatientId; + + bool get hasToken => token != null && token!.isNotEmpty; + + bool get isLoading => + status == InviteStatus.resolving || status == InviteStatus.accepting; + + InviteState copyWith({ + InviteStatus? status, + String? token, + InviteResolution? resolution, + String? errorMessage, + InviteOfflineContext? offlineContext, + String? acceptedPatientId, + }) { + return InviteState( + status: status ?? this.status, + token: token ?? this.token, + resolution: resolution ?? this.resolution, + errorMessage: errorMessage, + offlineContext: offlineContext, + acceptedPatientId: acceptedPatientId ?? this.acceptedPatientId, + ); + } +} diff --git a/front001/mosenioring/linux/flutter/generated_plugin_registrant.cc b/front001/mosenioring/linux/flutter/generated_plugin_registrant.cc index d0e7f79..c0ebdc8 100644 --- a/front001/mosenioring/linux/flutter/generated_plugin_registrant.cc +++ b/front001/mosenioring/linux/flutter/generated_plugin_registrant.cc @@ -7,9 +7,13 @@ #include "generated_plugin_registrant.h" #include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) gtk_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); + gtk_plugin_register_with_registrar(gtk_registrar); } diff --git a/front001/mosenioring/linux/flutter/generated_plugins.cmake b/front001/mosenioring/linux/flutter/generated_plugins.cmake index b29e9ba..ac25ca0 100644 --- a/front001/mosenioring/linux/flutter/generated_plugins.cmake +++ b/front001/mosenioring/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_linux + gtk ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/front001/mosenioring/macos/Flutter/GeneratedPluginRegistrant.swift b/front001/mosenioring/macos/Flutter/GeneratedPluginRegistrant.swift index 25b674d..5f43335 100644 --- a/front001/mosenioring/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/front001/mosenioring/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,7 @@ import FlutterMacOS import Foundation +import app_links import connectivity_plus import flutter_appauth import flutter_secure_storage_darwin @@ -12,6 +13,7 @@ import local_auth_darwin import path_provider_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) FlutterAppauthPlugin.register(with: registry.registrar(forPlugin: "FlutterAppauthPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) diff --git a/front001/mosenioring/pubspec.yaml b/front001/mosenioring/pubspec.yaml index 68933d5..61c9397 100644 --- a/front001/mosenioring/pubspec.yaml +++ b/front001/mosenioring/pubspec.yaml @@ -36,6 +36,7 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 + app_links: ^6.3.0 connectivity_plus: ^7.0.0 cryptography: ^2.7.0 dio: ^5.7.0 diff --git a/front001/mosenioring/test/features/invite/invite_controller_test.dart b/front001/mosenioring/test/features/invite/invite_controller_test.dart new file mode 100644 index 0000000..6d883c7 --- /dev/null +++ b/front001/mosenioring/test/features/invite/invite_controller_test.dart @@ -0,0 +1,174 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mosenioring/src/core/deeplink/invite_link_service.dart'; +import 'package:mosenioring/src/core/network/connectivity_service.dart'; +import 'package:mosenioring/src/di/providers.dart'; +import 'package:mosenioring/src/features/app_gate/presentation/app_gate_controller.dart'; +import 'package:mosenioring/src/features/app_gate/presentation/app_gate_state.dart'; +import 'package:mosenioring/src/features/auth/domain/models/auth_token.dart'; +import 'package:mosenioring/src/features/auth/presentation/auth_controller.dart'; +import 'package:mosenioring/src/features/auth/presentation/auth_state.dart'; +import 'package:mosenioring/src/features/invite/data/invite_api.dart'; +import 'package:mosenioring/src/features/invite/presentation/invite_state.dart'; + +class FakeInviteApi implements InviteApi { + FakeInviteApi({ + required this.resolveResponse, + required this.acceptResponse, + }); + + InviteApiResponse> resolveResponse; + InviteApiResponse> acceptResponse; + + @override + Future>> resolveInvite({ + required String token, + }) async { + return resolveResponse; + } + + @override + Future>> acceptInvite({ + required String token, + }) async { + return acceptResponse; + } +} + +class FakeInviteLinkService implements InviteLinkService { + FakeInviteLinkService({this.initialToken}); + + final String? initialToken; + final StreamController _controller = StreamController.broadcast(); + + @override + Stream get tokenStream => _controller.stream; + + @override + Future getInitialToken() async => initialToken; + + Future dispose() async { + await _controller.close(); + } +} + +class FakeConnectivityService implements ConnectivityService { + FakeConnectivityService(this._online); + + bool _online; + + set online(bool value) => _online = value; + + @override + Future isOnline() async => _online; +} + +class FakeAuthController extends AuthController { + FakeAuthController(this._initialState); + + final AuthState _initialState; + + @override + AuthState build() => _initialState; + + @override + Future login({required String email, required String password}) async { + state = const AuthState(token: AuthToken(accessToken: 'token')); + } +} + +class FakeAppGateController extends AppGateController { + @override + AppGateState build() => const AppGateState(); + + @override + void setOnlineAuthenticated() { + state = const AppGateState(destination: AppGateDestination.home); + } + + @override + void setOfflineUnlocked() {} + + @override + void resetToLogin() {} +} + +void main() { + test('resolve then accept invite', () async { + final inviteApi = FakeInviteApi( + resolveResponse: InviteApiResponse( + statusCode: 200, + data: { + 'role': 'CAREGIVER', + 'emailMasked': 't***@example.com', + 'expiresAt': DateTime.now().toUtc().toIso8601String(), + }, + ), + acceptResponse: InviteApiResponse( + statusCode: 200, + data: {'status': 'accepted', 'patientId': 'patient-1'}, + ), + ); + final linkService = FakeInviteLinkService(); + final connectivityService = FakeConnectivityService(true); + + final container = ProviderContainer( + overrides: [ + inviteApiProvider.overrideWithValue(inviteApi), + inviteLinkServiceProvider.overrideWithValue(linkService), + connectivityServiceProvider.overrideWithValue(connectivityService), + authControllerProvider.overrideWith(() => FakeAuthController( + const AuthState(token: AuthToken(accessToken: 'token')), + )), + appGateControllerProvider.overrideWith(() => FakeAppGateController()), + ], + ); + addTearDown(() async { + await linkService.dispose(); + container.dispose(); + }); + + await container + .read(inviteControllerProvider.notifier) + .onTokenReceived('token'); + var state = container.read(inviteControllerProvider); + expect(state.status, InviteStatus.resolved); + + await container.read(inviteControllerProvider.notifier).continueInvite(); + state = container.read(inviteControllerProvider); + expect(state.status, InviteStatus.accepted); + expect(state.token, isNull); + }); + + test('invalid invite resolves to invalid state', () async { + final inviteApi = FakeInviteApi( + resolveResponse: const InviteApiResponse(statusCode: 400, data: null), + acceptResponse: const InviteApiResponse(statusCode: 200, data: null), + ); + final linkService = FakeInviteLinkService(); + final connectivityService = FakeConnectivityService(true); + + final container = ProviderContainer( + overrides: [ + inviteApiProvider.overrideWithValue(inviteApi), + inviteLinkServiceProvider.overrideWithValue(linkService), + connectivityServiceProvider.overrideWithValue(connectivityService), + authControllerProvider.overrideWith(() => FakeAuthController(const AuthState())), + appGateControllerProvider.overrideWith(() => FakeAppGateController()), + ], + ); + addTearDown(() async { + await linkService.dispose(); + container.dispose(); + }); + + await container + .read(inviteControllerProvider.notifier) + .onTokenReceived('token'); + final state = container.read(inviteControllerProvider); + + expect(state.status, InviteStatus.invalid); + }); +} diff --git a/front001/mosenioring/test/features/invite/invite_link_service_test.dart b/front001/mosenioring/test/features/invite/invite_link_service_test.dart new file mode 100644 index 0000000..62174ee --- /dev/null +++ b/front001/mosenioring/test/features/invite/invite_link_service_test.dart @@ -0,0 +1,20 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mosenioring/src/core/deeplink/invite_link_service.dart'; + +void main() { + test('extracts token from invite link', () { + final uri = Uri.parse('https://app.mosenioring.com/invite?token=abc123'); + + final token = InviteLinkService.extractToken(uri); + + expect(token, 'abc123'); + }); + + test('returns null when token missing or path mismatched', () { + final noToken = Uri.parse('https://app.mosenioring.com/invite'); + final wrongPath = Uri.parse('https://app.mosenioring.com/other?token=abc123'); + + expect(InviteLinkService.extractToken(noToken), isNull); + expect(InviteLinkService.extractToken(wrongPath), isNull); + }); +} diff --git a/front001/mosenioring/windows/flutter/generated_plugin_registrant.cc b/front001/mosenioring/windows/flutter/generated_plugin_registrant.cc index d7240f1..1abc829 100644 --- a/front001/mosenioring/windows/flutter/generated_plugin_registrant.cc +++ b/front001/mosenioring/windows/flutter/generated_plugin_registrant.cc @@ -6,11 +6,14 @@ #include "generated_plugin_registrant.h" +#include #include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + AppLinksPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AppLinksPluginCApi")); ConnectivityPlusWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( diff --git a/front001/mosenioring/windows/flutter/generated_plugins.cmake b/front001/mosenioring/windows/flutter/generated_plugins.cmake index 9b83ab5..e27e839 100644 --- a/front001/mosenioring/windows/flutter/generated_plugins.cmake +++ b/front001/mosenioring/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + app_links connectivity_plus flutter_secure_storage_windows local_auth_windows