Compare commits
No commits in common. "feature/invite-management-front" and "master" have entirely different histories.
feature/in
...
master
|
|
@ -27,15 +27,6 @@
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</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>
|
||||||
<activity
|
<activity
|
||||||
android:name="net.openid.appauth.RedirectUriReceiverActivity"
|
android:name="net.openid.appauth.RedirectUriReceiverActivity"
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
{
|
|
||||||
"applinks": {
|
|
||||||
"apps": [],
|
|
||||||
"details": [
|
|
||||||
{
|
|
||||||
"appID": "TEAMID.pl.okit.mosenioring",
|
|
||||||
"paths": ["/invite"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
[
|
|
||||||
{
|
|
||||||
"relation": ["delegate_permission/common.handle_all_urls"],
|
|
||||||
"target": {
|
|
||||||
"namespace": "android_app",
|
|
||||||
"package_name": "pl.okit.mosenioring",
|
|
||||||
"sha256_cert_fingerprints": [
|
|
||||||
"REPLACE_WITH_SHA256_CERT_FINGERPRINT"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
@ -361,7 +361,6 @@
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
|
@ -541,7 +540,6 @@
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
|
@ -564,7 +562,6 @@
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
<?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>
|
|
||||||
|
|
@ -8,20 +8,5 @@
|
||||||
"loginButton": "Sign in with Keycloak",
|
"loginButton": "Sign in with Keycloak",
|
||||||
"loginRequired": "Email and password are required.",
|
"loginRequired": "Email and password are required.",
|
||||||
"signedInMessage": "You are signed in.",
|
"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"
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -147,96 +147,6 @@ abstract class AppLocalizations {
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Logout'**
|
/// **'Logout'**
|
||||||
String get logoutTooltip;
|
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
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -34,51 +34,4 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get logoutTooltip => 'Logout';
|
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';
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:mosenioring/l10n/app_localizations.dart';
|
import 'package:mosenioring/l10n/app_localizations.dart';
|
||||||
|
|
||||||
import '../di/providers.dart';
|
|
||||||
import 'router.dart';
|
import 'router.dart';
|
||||||
|
|
||||||
class App extends ConsumerWidget {
|
class App extends ConsumerWidget {
|
||||||
|
|
@ -11,7 +10,6 @@ class App extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
ref.watch(inviteControllerProvider);
|
|
||||||
final router = ref.watch(appRouterProvider);
|
final router = ref.watch(appRouterProvider);
|
||||||
|
|
||||||
return MaterialApp.router(
|
return MaterialApp.router(
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,9 @@ import '../features/auth/presentation/login_page.dart';
|
||||||
import '../features/home/presentation/home_page.dart';
|
import '../features/home/presentation/home_page.dart';
|
||||||
import '../features/offline_lock/presentation/unlock_screen.dart';
|
import '../features/offline_lock/presentation/unlock_screen.dart';
|
||||||
import '../features/app_gate/presentation/app_gate_state.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 appRouterProvider = Provider<GoRouter>((ref) {
|
||||||
final appGateState = ref.watch(appGateControllerProvider);
|
final appGateState = ref.watch(appGateControllerProvider);
|
||||||
final inviteState = ref.watch(inviteControllerProvider);
|
|
||||||
|
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
initialLocation: '/login',
|
initialLocation: '/login',
|
||||||
|
|
@ -29,47 +25,13 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||||
path: '/unlock',
|
path: '/unlock',
|
||||||
builder: (context, state) => const UnlockScreen(),
|
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) {
|
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) {
|
if (appGateState.isLoading) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
final destination = appGateState.destination;
|
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') {
|
if (destination == AppGateDestination.login && location != '/login') {
|
||||||
return '/login';
|
return '/login';
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import 'package:app_links/app_links.dart';
|
|
||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter_appauth/flutter_appauth.dart';
|
import 'package:flutter_appauth/flutter_appauth.dart';
|
||||||
|
|
@ -7,7 +6,6 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:local_auth/local_auth.dart';
|
import 'package:local_auth/local_auth.dart';
|
||||||
|
|
||||||
import '../core/config/app_config.dart';
|
import '../core/config/app_config.dart';
|
||||||
import '../core/deeplink/invite_link_service.dart';
|
|
||||||
import '../core/network/api_client.dart';
|
import '../core/network/api_client.dart';
|
||||||
import '../core/network/connectivity_plus_service.dart';
|
import '../core/network/connectivity_plus_service.dart';
|
||||||
import '../core/network/connectivity_service.dart';
|
import '../core/network/connectivity_service.dart';
|
||||||
|
|
@ -40,9 +38,6 @@ import '../features/telemetry/domain/telemetry_service.dart';
|
||||||
import '../features/telemetry/domain/token_provider.dart';
|
import '../features/telemetry/domain/token_provider.dart';
|
||||||
import '../features/telemetry/presentation/telemetry_controller.dart';
|
import '../features/telemetry/presentation/telemetry_controller.dart';
|
||||||
import '../features/telemetry/presentation/telemetry_state.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());
|
final appConfigProvider = Provider<AppConfig>((ref) => AppConfig.fromEnvironment());
|
||||||
|
|
||||||
|
|
@ -54,14 +49,6 @@ final connectivityProvider = Provider<Connectivity>((ref) {
|
||||||
return Connectivity();
|
return Connectivity();
|
||||||
});
|
});
|
||||||
|
|
||||||
final appLinksProvider = Provider<AppLinks>((ref) {
|
|
||||||
return AppLinks();
|
|
||||||
});
|
|
||||||
|
|
||||||
final inviteLinkServiceProvider = Provider<InviteLinkService>((ref) {
|
|
||||||
return AppInviteLinkService(ref.watch(appLinksProvider));
|
|
||||||
});
|
|
||||||
|
|
||||||
final connectivityServiceProvider = Provider<ConnectivityService>((ref) {
|
final connectivityServiceProvider = Provider<ConnectivityService>((ref) {
|
||||||
return ConnectivityPlusService(ref.watch(connectivityProvider));
|
return ConnectivityPlusService(ref.watch(connectivityProvider));
|
||||||
});
|
});
|
||||||
|
|
@ -200,14 +187,6 @@ final httpClientProvider = Provider<HttpClient>((ref) {
|
||||||
return ApiHttpClient(ref.watch(apiClientProvider));
|
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) {
|
final platformInfoProvider = Provider<PlatformInfo>((ref) {
|
||||||
return const DevicePlatformInfo();
|
return const DevicePlatformInfo();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,238 +0,0 @@
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,168 +0,0 @@
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -7,13 +7,9 @@
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||||
#include <gtk/gtk_plugin.h>
|
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
||||||
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
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);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
flutter_secure_storage_linux
|
flutter_secure_storage_linux
|
||||||
gtk
|
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@
|
||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
import app_links
|
|
||||||
import connectivity_plus
|
import connectivity_plus
|
||||||
import flutter_appauth
|
import flutter_appauth
|
||||||
import flutter_secure_storage_darwin
|
import flutter_secure_storage_darwin
|
||||||
|
|
@ -13,7 +12,6 @@ import local_auth_darwin
|
||||||
import path_provider_foundation
|
import path_provider_foundation
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
|
|
||||||
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
||||||
FlutterAppauthPlugin.register(with: registry.registrar(forPlugin: "FlutterAppauthPlugin"))
|
FlutterAppauthPlugin.register(with: registry.registrar(forPlugin: "FlutterAppauthPlugin"))
|
||||||
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@ dependencies:
|
||||||
# The following adds the Cupertino Icons font to your application.
|
# The following adds the Cupertino Icons font to your application.
|
||||||
# Use with the CupertinoIcons class for iOS style icons.
|
# Use with the CupertinoIcons class for iOS style icons.
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
app_links: ^6.3.0
|
|
||||||
connectivity_plus: ^7.0.0
|
connectivity_plus: ^7.0.0
|
||||||
cryptography: ^2.7.0
|
cryptography: ^2.7.0
|
||||||
dio: ^5.7.0
|
dio: ^5.7.0
|
||||||
|
|
|
||||||
|
|
@ -1,174 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -6,14 +6,11 @@
|
||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <app_links/app_links_plugin_c_api.h>
|
|
||||||
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
|
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
|
||||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||||
#include <local_auth_windows/local_auth_plugin.h>
|
#include <local_auth_windows/local_auth_plugin.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
AppLinksPluginCApiRegisterWithRegistrar(
|
|
||||||
registry->GetRegistrarForPlugin("AppLinksPluginCApi"));
|
|
||||||
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
|
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
|
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
|
||||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
app_links
|
|
||||||
connectivity_plus
|
connectivity_plus
|
||||||
flutter_secure_storage_windows
|
flutter_secure_storage_windows
|
||||||
local_auth_windows
|
local_auth_windows
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue