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