Compare commits

..

1 commit

Author SHA1 Message Date
oskar 400319e502 Implement invite management on mobile
All checks were successful
ci / changes (push) Successful in 3s
ci / backend (push) Has been skipped
ci / flutter (push) Successful in 1m8s
- Added Flutter invite screens for accepting/resolving invitations and handling invalid states with `InviteScreen` and `InvalidInviteScreen`.
- Integrated `InviteController` with accompanying `InviteApi` for resolving and accepting invites.
- Updated routing logic to handle `/invite` and `/invite/invalid` paths.
- Added localization strings for invite-related messages.
- Enabled Flutter deep link handling across all supported platforms.
- Implemented unit tests for `InviteController` and `InviteLinkService`.
- Updated project entitlements and configurations for invite link validation on iOS/Android.
- Enhanced dependency injection with invite modules and services.
2026-01-16 22:13:51 +01:00
26 changed files with 1111 additions and 2 deletions

View file

@ -27,6 +27,15 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data
android:scheme="https"
android:host="app.mosenioring.com"
android:path="/invite"/>
</intent-filter>
</activity>
<activity
android:name="net.openid.appauth.RedirectUriReceiverActivity"

View file

@ -0,0 +1,11 @@
{
"applinks": {
"apps": [],
"details": [
{
"appID": "TEAMID.pl.okit.mosenioring",
"paths": ["/invite"]
}
]
}
}

View file

@ -0,0 +1,12 @@
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "pl.okit.mosenioring",
"sha256_cert_fingerprints": [
"REPLACE_WITH_SHA256_CERT_FINGERPRINT"
]
}
}
]

View file

@ -361,6 +361,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -540,6 +541,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -562,6 +564,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:app.mosenioring.com</string>
</array>
</dict>
</plist>

View file

@ -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"
}

View file

@ -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

View file

@ -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';
}

View file

@ -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(

View file

@ -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<GoRouter>((ref) {
final appGateState = ref.watch(appGateControllerProvider);
final inviteState = ref.watch(inviteControllerProvider);
return GoRouter(
initialLocation: '/login',
@ -25,13 +29,47 @@ final appRouterProvider = Provider<GoRouter>((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';

View file

@ -0,0 +1,40 @@
import 'dart:async';
import 'package:app_links/app_links.dart';
abstract class InviteLinkService {
Stream<String> get tokenStream;
Future<String?> 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<String> get tokenStream => _appLinks.uriLinkStream
.map(InviteLinkService.extractToken)
.where((token) => token != null)
.cast<String>();
@override
Future<String?> getInitialToken() async {
final uri = await _appLinks.getInitialLink();
if (uri == null) {
return null;
}
return InviteLinkService.extractToken(uri);
}
}

View file

@ -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<AppConfig>((ref) => AppConfig.fromEnvironment());
@ -49,6 +54,14 @@ final connectivityProvider = Provider<Connectivity>((ref) {
return Connectivity();
});
final appLinksProvider = Provider<AppLinks>((ref) {
return AppLinks();
});
final inviteLinkServiceProvider = Provider<InviteLinkService>((ref) {
return AppInviteLinkService(ref.watch(appLinksProvider));
});
final connectivityServiceProvider = Provider<ConnectivityService>((ref) {
return ConnectivityPlusService(ref.watch(connectivityProvider));
});
@ -187,6 +200,14 @@ final httpClientProvider = Provider<HttpClient>((ref) {
return ApiHttpClient(ref.watch(apiClientProvider));
});
final inviteApiProvider = Provider<InviteApi>((ref) {
return InviteApiImpl(ref.watch(httpClientProvider));
});
final inviteControllerProvider = NotifierProvider<InviteController, InviteState>(() {
return InviteController();
});
final platformInfoProvider = Provider<PlatformInfo>((ref) {
return const DevicePlatformInfo();
});

View file

@ -0,0 +1,67 @@
import '../../../core/network/http_client.dart';
class InviteApiResponse<T> {
const InviteApiResponse({
required this.statusCode,
this.data,
});
final int statusCode;
final T? data;
}
abstract class InviteApi {
Future<InviteApiResponse<Map<String, dynamic>>> resolveInvite({
required String token,
});
Future<InviteApiResponse<Map<String, dynamic>>> acceptInvite({
required String token,
});
}
class InviteApiImpl implements InviteApi {
InviteApiImpl(this._httpClient);
final HttpClient _httpClient;
@override
Future<InviteApiResponse<Map<String, dynamic>>> resolveInvite({
required String token,
}) async {
final response = await _httpClient.post<Object>(
'/api/v1/invites/resolve',
data: {'token': token},
headers: const {'Content-Type': 'application/json'},
);
return InviteApiResponse(
statusCode: response.statusCode,
data: _asJsonMap(response.data),
);
}
@override
Future<InviteApiResponse<Map<String, dynamic>>> acceptInvite({
required String token,
}) async {
final response = await _httpClient.post<Object>(
'/api/v1/invites/accept',
data: {'token': token},
headers: const {'Content-Type': 'application/json'},
);
return InviteApiResponse(
statusCode: response.statusCode,
data: _asJsonMap(response.data),
);
}
Map<String, dynamic>? _asJsonMap(Object? data) {
if (data is Map<String, dynamic>) {
return data;
}
if (data is Map) {
return Map<String, dynamic>.from(data);
}
return null;
}
}

View file

@ -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<String, dynamic> 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,
);
}
}

View file

@ -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),
),
),
],
),
),
),
),
);
}
}

View file

@ -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<InviteState> {
StreamSubscription<String>? _linkSubscription;
@override
InviteState build() {
Future.microtask(_bootstrap);
return const InviteState();
}
InviteApi get _inviteApi => ref.read(inviteApiProvider);
ConnectivityService get _connectivity => ref.read(connectivityServiceProvider);
Future<void> _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<void> 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<void> 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<void> 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<void> _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<void> _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<String, dynamic> 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,
);
}
}

View file

@ -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,
),
],
),
);
}
}

View file

@ -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,
);
}
}

View file

@ -7,9 +7,13 @@
#include "generated_plugin_registrant.h"
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <gtk/gtk_plugin.h>
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);
}

View file

@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_linux
gtk
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View file

@ -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"))

View file

@ -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

View file

@ -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<Map<String, dynamic>> resolveResponse;
InviteApiResponse<Map<String, dynamic>> acceptResponse;
@override
Future<InviteApiResponse<Map<String, dynamic>>> resolveInvite({
required String token,
}) async {
return resolveResponse;
}
@override
Future<InviteApiResponse<Map<String, dynamic>>> acceptInvite({
required String token,
}) async {
return acceptResponse;
}
}
class FakeInviteLinkService implements InviteLinkService {
FakeInviteLinkService({this.initialToken});
final String? initialToken;
final StreamController<String> _controller = StreamController.broadcast();
@override
Stream<String> get tokenStream => _controller.stream;
@override
Future<String?> getInitialToken() async => initialToken;
Future<void> dispose() async {
await _controller.close();
}
}
class FakeConnectivityService implements ConnectivityService {
FakeConnectivityService(this._online);
bool _online;
set online(bool value) => _online = value;
@override
Future<bool> isOnline() async => _online;
}
class FakeAuthController extends AuthController {
FakeAuthController(this._initialState);
final AuthState _initialState;
@override
AuthState build() => _initialState;
@override
Future<void> 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);
});
}

View file

@ -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);
});
}

View file

@ -6,11 +6,14 @@
#include "generated_plugin_registrant.h"
#include <app_links/app_links_plugin_c_api.h>
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <local_auth_windows/local_auth_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
AppLinksPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("AppLinksPluginCApi"));
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(

View file

@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
app_links
connectivity_plus
flutter_secure_storage_windows
local_auth_windows