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"/>
|
||||
<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"
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
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;
|
||||
|
|
@ -541,7 +540,6 @@
|
|||
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;
|
||||
|
|
@ -564,7 +562,6 @@
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
"loginRequired": "Email and password are required.",
|
||||
"signedInMessage": "You are signed in.",
|
||||
"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"
|
||||
"logoutTooltip": "Logout"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -147,96 +147,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -34,51 +34,4 @@ 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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ 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 {
|
||||
|
|
@ -11,7 +10,6 @@ class App extends ConsumerWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ref.watch(inviteControllerProvider);
|
||||
final router = ref.watch(appRouterProvider);
|
||||
|
||||
return MaterialApp.router(
|
||||
|
|
|
|||
|
|
@ -6,13 +6,9 @@ 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',
|
||||
|
|
@ -29,47 +25,13 @@ 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;
|
||||
|
||||
if (location.startsWith('/invite')) {
|
||||
if (destination == AppGateDestination.login) {
|
||||
return '/login';
|
||||
}
|
||||
if (destination == AppGateDestination.unlock) {
|
||||
return '/unlock';
|
||||
}
|
||||
return '/';
|
||||
}
|
||||
final location = state.matchedLocation;
|
||||
|
||||
if (destination == AppGateDestination.login && location != '/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:dio/dio.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 '../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';
|
||||
|
|
@ -40,9 +38,6 @@ 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());
|
||||
|
||||
|
|
@ -54,14 +49,6 @@ 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));
|
||||
});
|
||||
|
|
@ -200,14 +187,6 @@ 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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 <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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_secure_storage_linux
|
||||
gtk
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import app_links
|
||||
import connectivity_plus
|
||||
import flutter_appauth
|
||||
import flutter_secure_storage_darwin
|
||||
|
|
@ -13,7 +12,6 @@ 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"))
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -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 <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(
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
app_links
|
||||
connectivity_plus
|
||||
flutter_secure_storage_windows
|
||||
local_auth_windows
|
||||
|
|
|
|||
Loading…
Reference in a new issue