feat: bugfixes, finished go_router migration, implemented better visibility of states

This commit is contained in:
Anton Stubenbord
2023-10-06 01:17:08 +02:00
parent ad23df4f89
commit a2c5ced3b7
102 changed files with 1512 additions and 3090 deletions

View File

@@ -1,11 +1,9 @@
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:dio/dio.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/widgets.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hive_flutter/adapters.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/core/config/hive/hive_extensions.dart';
import 'package:paperless_mobile/core/database/tables/global_settings.dart';
@@ -14,6 +12,7 @@ import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart'
import 'package:paperless_mobile/core/database/tables/local_user_settings.dart';
import 'package:paperless_mobile/core/database/tables/user_credentials.dart';
import 'package:paperless_mobile/core/factory/paperless_api_factory.dart';
import 'package:paperless_mobile/core/interceptor/language_header.interceptor.dart';
import 'package:paperless_mobile/core/model/info_message_exception.dart';
import 'package:paperless_mobile/core/security/session_manager.dart';
import 'package:paperless_mobile/core/service/connectivity_status_service.dart';
@@ -22,21 +21,26 @@ import 'package:paperless_mobile/features/login/model/client_certificate.dart';
import 'package:paperless_mobile/features/login/model/login_form_credentials.dart';
import 'package:paperless_mobile/features/login/model/reachability_status.dart';
import 'package:paperless_mobile/features/login/services/authentication_service.dart';
import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
part 'authentication_state.dart';
typedef _FutureVoidCallback = Future<void> Function();
class AuthenticationCubit extends Cubit<AuthenticationState> {
final LocalAuthenticationService _localAuthService;
final PaperlessApiFactory _apiFactory;
final SessionManager _sessionManager;
final ConnectivityStatusService _connectivityService;
final LocalNotificationService _notificationService;
AuthenticationCubit(
this._localAuthService,
this._apiFactory,
this._sessionManager,
this._connectivityService,
this._notificationService,
) : super(const UnauthenticatedState());
Future<void> login({
@@ -45,7 +49,11 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
ClientCertificate? clientCertificate,
}) async {
assert(credentials.username != null && credentials.password != null);
emit(const CheckingLoginState());
if (state is AuthenticatingState) {
// Cancel duplicate login requests
return;
}
emit(const AuthenticatingState(AuthenticatingStage.authenticating));
final localUserId = "${credentials.username}@$serverUrl";
_debugPrintMessage(
"login",
@@ -58,35 +66,63 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
credentials,
clientCertificate,
_sessionManager,
onFetchUserInformation: () async {
emit(const AuthenticatingState(
AuthenticatingStage.fetchingUserInformation));
},
onPerformLogin: () async {
emit(const AuthenticatingState(AuthenticatingStage.authenticating));
},
onPersistLocalUserData: () async {
emit(const AuthenticatingState(
AuthenticatingStage.persistingLocalUserData));
},
);
// Mark logged in user as currently active user.
final globalSettings =
Hive.box<GlobalSettings>(HiveBoxes.globalSettings).getValue()!;
globalSettings.loggedInUserId = localUserId;
await globalSettings.save();
emit(AuthenticatedState(localUserId: localUserId));
_debugPrintMessage(
"login",
"User successfully logged in.",
} catch (e) {
emit(
AuthenticationErrorState(
serverUrl: serverUrl,
username: credentials.username!,
password: credentials.password!,
clientCertificate: clientCertificate,
),
);
} catch (error) {
emit(const UnauthenticatedState());
rethrow;
}
// Mark logged in user as currently active user.
final globalSettings =
Hive.box<GlobalSettings>(HiveBoxes.globalSettings).getValue()!;
globalSettings.loggedInUserId = localUserId;
await globalSettings.save();
emit(AuthenticatedState(localUserId: localUserId));
_debugPrintMessage(
"login",
"User successfully logged in.",
);
}
/// Switches to another account if it exists.
Future<void> switchAccount(String localUserId) async {
emit(const SwitchingAccountsState());
_debugPrintMessage(
"switchAccount",
"Trying to switch to user $localUserId...",
);
final globalSettings =
Hive.box<GlobalSettings>(HiveBoxes.globalSettings).getValue()!;
if (globalSettings.loggedInUserId == localUserId) {
emit(AuthenticatedState(localUserId: localUserId));
return;
}
final userAccountBox =
Hive.box<LocalUserAccount>(HiveBoxes.localUserAccount);
// if (globalSettings.loggedInUserId == localUserId) {
// _debugPrintMessage(
// "switchAccount",
// "User $localUserId is already logged in.",
// );
// emit(AuthenticatedState(localUserId: localUserId));
// return;
// }
final userAccountBox = Hive.localUserAccountBox;
if (!userAccountBox.containsKey(localUserId)) {
debugPrint("User $localUserId not yet registered.");
@@ -99,10 +135,18 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
final authenticated = await _localAuthService
.authenticateLocalUser("Authenticate to switch your account.");
if (!authenticated) {
debugPrint("User not authenticated.");
_debugPrintMessage(
"switchAccount",
"User could not be authenticated.",
);
emit(VerifyIdentityState(userId: localUserId));
return;
}
}
final currentlyLoggedInUser = globalSettings.loggedInUserId;
if (currentlyLoggedInUser != localUserId) {
await _notificationService.cancelUserNotifications(localUserId);
}
await withEncryptedBox<UserCredentials, void>(
HiveBoxes.localUserCredentials, (credentialsBox) async {
if (!credentialsBox.containsKey(localUserId)) {
@@ -131,9 +175,7 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
apiVersion,
);
emit(AuthenticatedState(
localUserId: localUserId,
));
emit(AuthenticatedState(localUserId: localUserId));
});
}
@@ -142,19 +184,33 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
required String serverUrl,
ClientCertificate? clientCertificate,
required bool enableBiometricAuthentication,
required String locale,
}) async {
assert(credentials.password != null && credentials.username != null);
final localUserId = "${credentials.username}@$serverUrl";
final sessionManager = SessionManager();
await _addUser(
localUserId,
serverUrl,
credentials,
clientCertificate,
sessionManager,
);
return localUserId;
final sessionManager = SessionManager([
LanguageHeaderInterceptor(locale),
]);
try {
await _addUser(
localUserId,
serverUrl,
credentials,
clientCertificate,
sessionManager,
// onPerformLogin: () async {
// emit(AuthenticatingState(AuthenticatingStage.authenticating));
// await Future.delayed(const Duration(milliseconds: 500));
// },
);
return localUserId;
} catch (error, stackTrace) {
print(error);
debugPrintStack(stackTrace: stackTrace);
rethrow;
}
}
Future<void> removeAccount(String userId) async {
@@ -170,28 +226,33 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
}
///
/// Performs a conditional hydration based on the local authentication success.
/// Restores the previous session if exists.
///
Future<void> restoreSessionState() async {
Future<void> restoreSession([String? userId]) async {
emit(const RestoringSessionState());
_debugPrintMessage(
"restoreSessionState",
"Trying to restore previous session...",
);
final globalSettings =
Hive.box<GlobalSettings>(HiveBoxes.globalSettings).getValue()!;
final localUserId = globalSettings.loggedInUserId;
if (localUserId == null) {
final restoreSessionForUser = userId ?? globalSettings.loggedInUserId;
// final localUserId = globalSettings.loggedInUserId;
if (restoreSessionForUser == null) {
_debugPrintMessage(
"restoreSessionState",
"There is nothing to restore.",
);
final otherAccountsExist = Hive.localUserAccountBox.isNotEmpty;
// If there is nothing to restore, we can quit here.
emit(const UnauthenticatedState());
emit(
UnauthenticatedState(redirectToAccountSelection: otherAccountsExist),
);
return;
}
final localUserAccountBox =
Hive.box<LocalUserAccount>(HiveBoxes.localUserAccount);
final localUserAccount = localUserAccountBox.get(localUserId)!;
final localUserAccount = localUserAccountBox.get(restoreSessionForUser)!;
_debugPrintMessage(
"restoreSessionState",
"Checking if biometric authentication is required...",
@@ -207,7 +268,7 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
final localAuthSuccess =
await _localAuthService.authenticateLocalUser(authenticationMesage);
if (!localAuthSuccess) {
emit(const RequiresLocalAuthenticationState());
emit(VerifyIdentityState(userId: restoreSessionForUser));
_debugPrintMessage(
"restoreSessionState",
"User could not be authenticated.",
@@ -231,7 +292,7 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
final authentication =
await withEncryptedBox<UserCredentials, UserCredentials>(
HiveBoxes.localUserCredentials, (box) {
return box.get(globalSettings.loggedInUserId!);
return box.get(restoreSessionForUser);
});
if (authentication == null) {
@@ -290,8 +351,9 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
"Skipping update of server user (server could not be reached).",
);
}
emit(AuthenticatedState(localUserId: localUserId));
globalSettings.loggedInUserId = restoreSessionForUser;
await globalSettings.save();
emit(AuthenticatedState(localUserId: restoreSessionForUser));
_debugPrintMessage(
"restoreSessionState",
@@ -300,7 +362,7 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
}
Future<void> logout([bool removeAccount = false]) async {
emit(const LogginOutState());
emit(const LoggingOutState());
_debugPrintMessage(
"logout",
"Trying to log out current user...",
@@ -308,13 +370,16 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
await _resetExternalState();
final globalSettings = Hive.globalSettingsBox.getValue()!;
final userId = globalSettings.loggedInUserId!;
await _notificationService.cancelUserNotifications(userId);
final otherAccountsExist = Hive.localUserAccountBox.length > 1;
emit(UnauthenticatedState(redirectToAccountSelection: otherAccountsExist));
if (removeAccount) {
this.removeAccount(userId);
await this.removeAccount(userId);
}
globalSettings.loggedInUserId = null;
await globalSettings.save();
emit(const UnauthenticatedState());
_debugPrintMessage(
"logout",
"User successfully logged out.",
@@ -322,16 +387,8 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
}
Future<void> _resetExternalState() async {
_debugPrintMessage(
"_resetExternalState",
"Resetting session manager and clearing storage...",
);
_sessionManager.resetSettings();
await HydratedBloc.storage.clear();
_debugPrintMessage(
"_resetExternalState",
"Session manager successfully reset and storage cleared.",
);
}
Future<int> _addUser(
@@ -339,8 +396,11 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
String serverUrl,
LoginFormCredentials credentials,
ClientCertificate? clientCert,
SessionManager sessionManager,
) async {
SessionManager sessionManager, {
_FutureVoidCallback? onPerformLogin,
_FutureVoidCallback? onPersistLocalUserData,
_FutureVoidCallback? onFetchUserInformation,
}) async {
assert(credentials.username != null && credentials.password != null);
_debugPrintMessage("_addUser", "Adding new user $localUserId...");
@@ -356,6 +416,8 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
"Trying to login user ${credentials.username} on $serverUrl...",
);
await onPerformLogin?.call();
final token = await authApi.login(
username: credentials.username!,
password: credentials.password!,
@@ -384,6 +446,7 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
);
throw InfoMessageException(code: ErrorCode.userAlreadyExists);
}
await onFetchUserInformation?.call();
final apiVersion = await _getApiVersion(sessionManager.client);
_debugPrintMessage(
"_addUser",
@@ -413,6 +476,7 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
"_addUser",
"Persisting local user account...",
);
await onPersistLocalUserData?.call();
// Create user account
await userAccountBox.put(
localUserId,
@@ -490,7 +554,7 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
"API version ($apiVersion) successfully retrieved.",
);
return apiVersion;
} on DioException catch (e) {
} on DioException catch (_) {
return defaultValue;
}
}

View File

@@ -7,34 +7,76 @@ sealed class AuthenticationState {
switch (this) { AuthenticatedState() => true, _ => false };
}
class UnauthenticatedState extends AuthenticationState {
const UnauthenticatedState();
class UnauthenticatedState extends AuthenticationState with EquatableMixin {
final bool redirectToAccountSelection;
const UnauthenticatedState({this.redirectToAccountSelection = false});
@override
List<Object?> get props => [redirectToAccountSelection];
}
class RequiresLocalAuthenticationState extends AuthenticationState {
const RequiresLocalAuthenticationState();
class RestoringSessionState extends AuthenticationState {
const RestoringSessionState();
}
class CheckingLoginState extends AuthenticationState {
const CheckingLoginState();
class VerifyIdentityState extends AuthenticationState {
final String userId;
const VerifyIdentityState({required this.userId});
}
class LogginOutState extends AuthenticationState {
const LogginOutState();
class AuthenticatingState extends AuthenticationState with EquatableMixin {
final AuthenticatingStage currentStage;
const AuthenticatingState(this.currentStage);
@override
List<Object?> get props => [currentStage];
}
class AuthenticatedState extends AuthenticationState {
class LoggingOutState extends AuthenticationState {
const LoggingOutState();
}
class AuthenticatedState extends AuthenticationState with EquatableMixin {
final String localUserId;
const AuthenticatedState({
required this.localUserId,
});
const AuthenticatedState({required this.localUserId});
@override
List<Object?> get props => [localUserId];
}
class SwitchingAccountsState extends AuthenticationState {
const SwitchingAccountsState();
}
class AuthenticationErrorState extends AuthenticationState {
const AuthenticationErrorState();
class AuthenticationErrorState extends AuthenticationState with EquatableMixin {
final ErrorCode? errorCode;
final String serverUrl;
final ClientCertificate? clientCertificate;
final String username;
final String password;
const AuthenticationErrorState({
this.errorCode,
required this.serverUrl,
this.clientCertificate,
required this.username,
required this.password,
});
@override
List<Object?> get props => [
errorCode,
serverUrl,
clientCertificate,
username,
password,
];
}
enum AuthenticatingStage {
authenticating,
persistingLocalUserData,
fetchingUserInformation,
}

View File

@@ -1,48 +0,0 @@
import 'package:equatable/equatable.dart';
class OldAuthenticationState with EquatableMixin {
final bool showBiometricAuthenticationScreen;
final bool isAuthenticated;
final String? username;
final String? fullName;
final String? localUserId;
final int? apiVersion;
const OldAuthenticationState({
this.isAuthenticated = false,
this.showBiometricAuthenticationScreen = false,
this.username,
this.fullName,
this.localUserId,
this.apiVersion,
});
OldAuthenticationState copyWith({
bool? isAuthenticated,
bool? showBiometricAuthenticationScreen,
String? username,
String? fullName,
String? localUserId,
int? apiVersion,
}) {
return OldAuthenticationState(
isAuthenticated: isAuthenticated ?? this.isAuthenticated,
showBiometricAuthenticationScreen: showBiometricAuthenticationScreen ??
this.showBiometricAuthenticationScreen,
username: username ?? this.username,
fullName: fullName ?? this.fullName,
localUserId: localUserId ?? this.localUserId,
apiVersion: apiVersion ?? this.apiVersion,
);
}
@override
List<Object?> get props => [
localUserId,
username,
fullName,
isAuthenticated,
showBiometricAuthenticationScreen,
apiVersion,
];
}

View File

@@ -12,5 +12,9 @@ class ClientCertificate {
@HiveField(1)
String? passphrase;
ClientCertificate({required this.bytes, this.passphrase});
ClientCertificate({
required this.bytes,
this.passphrase,
});
}

View File

@@ -7,9 +7,16 @@ class ClientCertificateFormModel {
final Uint8List bytes;
final String? passphrase;
ClientCertificateFormModel({required this.bytes, this.passphrase});
ClientCertificateFormModel({
required this.bytes,
this.passphrase,
});
ClientCertificateFormModel copyWith({Uint8List? bytes, String? passphrase}) {
ClientCertificateFormModel copyWith({
Uint8List? bytes,
String? passphrase,
String? filePath,
}) {
return ClientCertificateFormModel(
bytes: bytes ?? this.bytes,
passphrase: passphrase ?? this.passphrase,

View File

@@ -3,27 +3,21 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:hive_flutter/adapters.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
import 'package:paperless_mobile/core/exception/server_message_exception.dart';
import 'package:paperless_mobile/core/model/info_message_exception.dart';
import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart';
import 'package:paperless_mobile/core/service/connectivity_status_service.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/login/model/client_certificate.dart';
import 'package:paperless_mobile/features/login/model/client_certificate_form_model.dart';
import 'package:paperless_mobile/features/login/model/login_form_credentials.dart';
import 'package:paperless_mobile/features/login/model/reachability_status.dart';
import 'package:paperless_mobile/features/login/view/widgets/form_fields/client_certificate_form_field.dart';
import 'package:paperless_mobile/features/login/view/widgets/form_fields/server_address_form_field.dart';
import 'package:paperless_mobile/features/login/view/widgets/form_fields/user_credentials_form_field.dart';
import 'package:paperless_mobile/features/login/view/widgets/login_pages/server_connection_page.dart';
import 'package:paperless_mobile/features/users/view/widgets/user_account_list_tile.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart';
import 'widgets/login_pages/server_login_page.dart';
import 'widgets/never_scrollable_scroll_behavior.dart';
class AddAccountPage extends StatefulWidget {
final FutureOr<void> Function(
BuildContext context,
@@ -33,17 +27,27 @@ class AddAccountPage extends StatefulWidget {
ClientCertificate? clientCertificate,
) onSubmit;
final String submitText;
final String titleString;
final String? initialServerUrl;
final String? initialUsername;
final String? initialPassword;
final ClientCertificate? initialClientCertificate;
final String submitText;
final String titleText;
final bool showLocalAccounts;
final Widget? bottomLeftButton;
const AddAccountPage({
Key? key,
required this.onSubmit,
required this.submitText,
required this.titleString,
required this.titleText,
this.showLocalAccounts = false,
this.initialServerUrl,
this.initialUsername,
this.initialPassword,
this.initialClientCertificate,
this.bottomLeftButton,
}) : super(key: key);
@override
@@ -52,86 +56,170 @@ class AddAccountPage extends StatefulWidget {
class _AddAccountPageState extends State<AddAccountPage> {
final _formKey = GlobalKey<FormBuilderState>();
bool _isCheckingConnection = false;
ReachabilityStatus _reachabilityStatus = ReachabilityStatus.unknown;
final PageController _pageController = PageController();
bool _isFormSubmitted = false;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable:
Hive.box<LocalUserAccount>(HiveBoxes.localUserAccount).listenable(),
builder: (context, localAccounts, child) {
return Scaffold(
resizeToAvoidBottomInset: false,
body: FormBuilder(
key: _formKey,
child: PageView(
controller: _pageController,
scrollBehavior: NeverScrollableScrollBehavior(),
children: [
if (widget.showLocalAccounts && localAccounts.isNotEmpty)
Scaffold(
appBar: AppBar(
title: Text(S.of(context)!.logInToExistingAccount),
),
bottomNavigationBar: BottomAppBar(
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FilledButton(
child: Text(S.of(context)!.goToLogin),
onPressed: () {
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
},
),
],
),
),
body: ListView.builder(
itemBuilder: (context, index) {
final account = localAccounts.values.elementAt(index);
return Card(
child: UserAccountListTile(
account: account,
onTap: () {
context
.read<AuthenticationCubit>()
.switchAccount(account.id);
},
),
);
},
itemCount: localAccounts.length,
),
),
ServerConnectionPage(
titleText: widget.titleString,
formBuilderKey: _formKey,
onContinue: () {
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
},
),
ServerLoginPage(
formBuilderKey: _formKey,
submitText: widget.submitText,
onSubmit: _login,
),
],
return Scaffold(
appBar: AppBar(
title: Text(widget.titleText),
),
bottomNavigationBar: BottomAppBar(
child: Row(
mainAxisAlignment: widget.bottomLeftButton != null
? MainAxisAlignment.spaceBetween
: MainAxisAlignment.end,
children: [
if (widget.bottomLeftButton != null) widget.bottomLeftButton!,
FilledButton(
child: Text(S.of(context)!.loginPageSignInTitle),
onPressed: _reachabilityStatus == ReachabilityStatus.reachable &&
!_isFormSubmitted
? _onSubmit
: null,
),
),
);
},
],
),
),
resizeToAvoidBottomInset: true,
body: FormBuilder(
key: _formKey,
child: ListView(
children: [
ServerAddressFormField(
initialValue: widget.initialServerUrl,
onSubmit: (address) {
_updateReachability(address);
},
).padded(),
ClientCertificateFormField(
initialBytes: widget.initialClientCertificate?.bytes,
initialPassphrase: widget.initialClientCertificate?.passphrase,
onChanged: (_) => _updateReachability(),
).padded(),
_buildStatusIndicator(),
if (_reachabilityStatus == ReachabilityStatus.reachable) ...[
UserCredentialsFormField(
formKey: _formKey,
initialUsername: widget.initialUsername,
initialPassword: widget.initialPassword,
onFieldsSubmitted: _onSubmit,
),
Text(
S.of(context)!.loginRequiredPermissionsHint,
style: Theme.of(context).textTheme.bodySmall?.apply(
color: Theme.of(context)
.colorScheme
.onBackground
.withOpacity(0.6),
),
).padded(16),
]
],
),
),
);
}
Future<void> _login() async {
Future<void> _updateReachability([String? address]) async {
setState(() {
_isCheckingConnection = true;
});
final certForm =
_formKey.currentState?.getRawValue<ClientCertificateFormModel>(
ClientCertificateFormField.fkClientCertificate,
);
final status = await context
.read<ConnectivityStatusService>()
.isPaperlessServerReachable(
address ??
_formKey.currentState!
.getRawValue(ServerAddressFormField.fkServerAddress),
certForm != null
? ClientCertificate(
bytes: certForm.bytes,
passphrase: certForm.passphrase,
)
: null,
);
setState(() {
_isCheckingConnection = false;
_reachabilityStatus = status;
});
}
Widget _buildStatusIndicator() {
if (_isCheckingConnection) {
return const ListTile();
}
Widget _buildIconText(
IconData icon,
String text, [
Color? color,
]) {
return ListTile(
title: Text(
text,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: color),
),
leading: Icon(
icon,
color: color,
),
);
}
Color errorColor = Theme.of(context).colorScheme.error;
switch (_reachabilityStatus) {
case ReachabilityStatus.unknown:
return Container();
case ReachabilityStatus.reachable:
return _buildIconText(
Icons.done,
S.of(context)!.connectionSuccessfulylEstablished,
Colors.green,
);
case ReachabilityStatus.notReachable:
return _buildIconText(
Icons.close,
S.of(context)!.couldNotEstablishConnectionToTheServer,
errorColor,
);
case ReachabilityStatus.unknownHost:
return _buildIconText(
Icons.close,
S.of(context)!.hostCouldNotBeResolved,
errorColor,
);
case ReachabilityStatus.missingClientCertificate:
return _buildIconText(
Icons.close,
S.of(context)!.loginPageReachabilityMissingClientCertificateText,
errorColor,
);
case ReachabilityStatus.invalidClientCertificateConfiguration:
return _buildIconText(
Icons.close,
S.of(context)!.incorrectOrMissingCertificatePassphrase,
errorColor,
);
case ReachabilityStatus.connectionTimeout:
return _buildIconText(
Icons.close,
S.of(context)!.connectionTimedOut,
errorColor,
);
}
}
Future<void> _onSubmit() async {
FocusScope.of(context).unfocus();
setState(() {
_isFormSubmitted = true;
});
if (_formKey.currentState?.saveAndValidate() ?? false) {
final form = _formKey.currentState!.value;
ClientCertificate? clientCert;
@@ -162,6 +250,10 @@ class _AddAccountPageState extends State<AddAccountPage> {
showInfoMessage(context, error);
} catch (error) {
showGenericError(context, error);
} finally {
setState(() {
_isFormSubmitted = false;
});
}
}
}

View File

@@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:hive_flutter/adapters.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/core/config/hive/hive_extensions.dart';
import 'package:paperless_mobile/core/database/tables/global_settings.dart';
import 'package:paperless_mobile/core/model/info_message_exception.dart';
import 'package:paperless_mobile/features/app_intro/application_intro_slideshow.dart';
@@ -13,18 +13,41 @@ import 'package:paperless_mobile/features/login/model/login_form_credentials.dar
import 'package:paperless_mobile/features/login/view/add_account_page.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart';
import 'package:paperless_mobile/routes/typed/branches/documents_route.dart';
import 'package:paperless_mobile/routes/typed/top_level/login_route.dart';
class LoginPage extends StatelessWidget {
const LoginPage({super.key});
final String? initialServerUrl;
final String? initialUsername;
final String? initialPassword;
final ClientCertificate? initialClientCertificate;
const LoginPage({
super.key,
this.initialServerUrl,
this.initialUsername,
this.initialPassword,
this.initialClientCertificate,
});
@override
Widget build(BuildContext context) {
return AddAccountPage(
titleString: S.of(context)!.connectToPaperless,
titleText: S.of(context)!.connectToPaperless,
submitText: S.of(context)!.signIn,
onSubmit: _onLogin,
showLocalAccounts: true,
initialServerUrl: initialServerUrl,
initialUsername: initialUsername,
initialPassword: initialPassword,
initialClientCertificate: initialClientCertificate,
bottomLeftButton: Hive.localUserAccountBox.isNotEmpty
? TextButton(
child: Text(S.of(context)!.logInToExistingAccount),
onPressed: () {
const LoginToExistingAccountRoute().go(context);
},
)
: null,
);
}

View File

@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hive_flutter/adapters.dart';
import 'package:paperless_mobile/core/config/hive/hive_extensions.dart';
import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart';
import 'package:paperless_mobile/features/users/view/widgets/user_account_list_tile.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/routes/typed/top_level/login_route.dart';
class LoginToExistingAccountPage extends StatelessWidget {
const LoginToExistingAccountPage({super.key});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: Hive.localUserAccountBox.listenable(),
builder: (context, value, _) {
final localAccounts = value.values;
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
title: Text(S.of(context)!.logInToExistingAccount),
),
bottomNavigationBar: BottomAppBar(
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
child: Text(S.of(context)!.addAnotherAccount),
onPressed: () {
const LoginRoute().go(context);
},
),
],
),
),
body: ListView.builder(
itemBuilder: (context, index) {
final account = localAccounts.elementAt(index);
return Card(
child: UserAccountListTile(
account: account,
onTap: () {
context
.read<AuthenticationCubit>()
.switchAccount(account.id);
},
trailing: IconButton(
tooltip: S.of(context)!.remove,
icon: Icon(Icons.close),
onPressed: () {
context
.read<AuthenticationCubit>()
.removeAccount(account.id);
},
),
),
);
},
itemCount: localAccounts.length,
),
);
},
);
}
}

View File

@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/routes/typed/top_level/login_route.dart';
import 'package:provider/provider.dart';
class VerifyIdentityPage extends StatelessWidget {
final String userId;
const VerifyIdentityPage({super.key, required this.userId});
@override
Widget build(BuildContext context) {
return Material(
child: Scaffold(
appBar: AppBar(
elevation: 0,
backgroundColor: Theme.of(context).colorScheme.background,
title: Text(S.of(context)!.verifyYourIdentity),
),
bottomNavigationBar: BottomAppBar(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: () {
const LoginToExistingAccountRoute().go(context);
},
child: Text(S.of(context)!.goToLogin),
),
FilledButton(
onPressed: () =>
context.read<AuthenticationCubit>().restoreSession(userId),
child: Text(S.of(context)!.verifyIdentity),
),
],
),
),
body: Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Text(
S.of(context)!.useTheConfiguredBiometricFactorToAuthenticate,
textAlign: TextAlign.center,
).paddedSymmetrically(horizontal: 16),
const Icon(
Icons.fingerprint,
size: 96,
),
// Wrap(
// alignment: WrapAlignment.spaceBetween,
// runAlignment: WrapAlignment.spaceBetween,
// runSpacing: 8,
// spacing: 8,
// children: [
// ],
// ).padded(16),
],
),
),
);
}
}

View File

@@ -1,4 +1,5 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
@@ -12,11 +13,16 @@ import 'obscured_input_text_form_field.dart';
class ClientCertificateFormField extends StatefulWidget {
static const fkClientCertificate = 'clientCertificate';
final String? initialPassphrase;
final Uint8List? initialBytes;
final void Function(ClientCertificateFormModel? cert) onChanged;
const ClientCertificateFormField({
Key? key,
super.key,
required this.onChanged,
}) : super(key: key);
this.initialPassphrase,
this.initialBytes,
});
@override
State<ClientCertificateFormField> createState() =>
@@ -31,7 +37,12 @@ class _ClientCertificateFormFieldState
return FormBuilderField<ClientCertificateFormModel?>(
key: const ValueKey('login-client-cert'),
onChanged: widget.onChanged,
initialValue: null,
initialValue: widget.initialBytes != null
? ClientCertificateFormModel(
bytes: widget.initialBytes!,
passphrase: widget.initialPassphrase,
)
: null,
validator: (value) {
if (value == null) {
return null;
@@ -108,8 +119,7 @@ class _ClientCertificateFormFieldState
),
label: S.of(context)!.passphrase,
).padded(),
] else
...[]
]
],
),
),
@@ -122,20 +132,23 @@ class _ClientCertificateFormFieldState
}
Future<void> _onSelectFile(
FormFieldState<ClientCertificateFormModel?> field) async {
FilePickerResult? result = await FilePicker.platform.pickFiles(
FormFieldState<ClientCertificateFormModel?> field,
) async {
final result = await FilePicker.platform.pickFiles(
allowMultiple: false,
);
if (result != null && result.files.single.path != null) {
File file = File(result.files.single.path!);
setState(() {
_selectedFile = file;
});
final changedValue =
field.value?.copyWith(bytes: file.readAsBytesSync()) ??
ClientCertificateFormModel(bytes: file.readAsBytesSync());
field.didChange(changedValue);
if (result == null || result.files.single.path == null) {
return;
}
File file = File(result.files.single.path!);
setState(() {
_selectedFile = file;
});
final bytes = await file.readAsBytes();
final changedValue = field.value?.copyWith(bytes: bytes) ??
ClientCertificateFormModel(bytes: bytes);
field.didChange(changedValue);
}
Widget _buildSelectedFileText(

View File

@@ -8,11 +8,12 @@ import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class ServerAddressFormField extends StatefulWidget {
static const String fkServerAddress = "serverAddress";
final String? initialValue;
final void Function(String? address) onSubmit;
const ServerAddressFormField({
Key? key,
required this.onSubmit,
this.initialValue,
}) : super(key: key);
@override
@@ -38,6 +39,7 @@ class _ServerAddressFormFieldState extends State<ServerAddressFormField> {
@override
Widget build(BuildContext context) {
return FormBuilderField<String>(
initialValue: widget.initialValue,
name: ServerAddressFormField.fkServerAddress,
autovalidateMode: AutovalidateMode.onUserInteraction,
validator: (value) {
@@ -90,7 +92,7 @@ class _ServerAddressFormFieldState extends State<ServerAddressFormField> {
)
: null,
),
autofocus: true,
autofocus: false,
onSubmitted: (_) {
onFieldSubmitted();
_formatInput();

View File

@@ -1,19 +1,27 @@
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:hive_flutter/adapters.dart';
import 'package:paperless_mobile/core/config/hive/hive_extensions.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/login/model/login_form_credentials.dart';
import 'package:paperless_mobile/features/login/view/widgets/form_fields/obscured_input_text_form_field.dart';
import 'package:paperless_mobile/features/login/view/widgets/form_fields/server_address_form_field.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class UserCredentialsFormField extends StatefulWidget {
static const fkCredentials = 'credentials';
final void Function() onFieldsSubmitted;
final String? initialUsername;
final String? initialPassword;
final GlobalKey<FormBuilderState> formKey;
const UserCredentialsFormField({
Key? key,
required this.onFieldsSubmitted,
this.initialUsername,
this.initialPassword,
required this.formKey,
}) : super(key: key);
@override
@@ -28,6 +36,10 @@ class _UserCredentialsFormFieldState extends State<UserCredentialsFormField> {
@override
Widget build(BuildContext context) {
return FormBuilderField<LoginFormCredentials?>(
initialValue: LoginFormCredentials(
password: widget.initialPassword,
username: widget.initialUsername,
),
name: UserCredentialsFormField.fkCredentials,
builder: (field) => AutofillGroup(
child: Column(
@@ -50,6 +62,17 @@ class _UserCredentialsFormFieldState extends State<UserCredentialsFormField> {
if (value?.trim().isEmpty ?? true) {
return S.of(context)!.usernameMustNotBeEmpty;
}
final serverAddress = widget.formKey.currentState!
.getRawValue<String>(
ServerAddressFormField.fkServerAddress);
if (serverAddress != null) {
final userExists = Hive.localUserAccountBox.values
.map((e) => e.id)
.contains('$value@$serverAddress');
if (userExists) {
return S.of(context)!.userAlreadyExists;
}
}
return null;
},
autofillHints: const [AutofillHints.username],

View File

@@ -1,170 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:paperless_mobile/core/service/connectivity_status_service.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/login/model/client_certificate.dart';
import 'package:paperless_mobile/features/login/model/client_certificate_form_model.dart';
import 'package:paperless_mobile/features/login/model/reachability_status.dart';
import 'package:paperless_mobile/features/login/view/widgets/form_fields/client_certificate_form_field.dart';
import 'package:paperless_mobile/features/login/view/widgets/form_fields/server_address_form_field.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:provider/provider.dart';
class ServerConnectionPage extends StatefulWidget {
final GlobalKey<FormBuilderState> formBuilderKey;
final VoidCallback onContinue;
final String titleText;
const ServerConnectionPage({
super.key,
required this.formBuilderKey,
required this.onContinue,
required this.titleText,
});
@override
State<ServerConnectionPage> createState() => _ServerConnectionPageState();
}
class _ServerConnectionPageState extends State<ServerConnectionPage> {
bool _isCheckingConnection = false;
ReachabilityStatus _reachabilityStatus = ReachabilityStatus.unknown;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
toolbarHeight: kToolbarHeight - 4,
title: Text(widget.titleText),
bottom: PreferredSize(
child: _isCheckingConnection
? const LinearProgressIndicator()
: const SizedBox(height: 4.0),
preferredSize: const Size.fromHeight(4.0),
),
),
resizeToAvoidBottomInset: true,
body: SingleChildScrollView(
child: Column(
children: [
ServerAddressFormField(
onSubmit: (address) {
_updateReachability(address);
},
).padded(),
ClientCertificateFormField(
onChanged: (_) => _updateReachability(),
).padded(),
_buildStatusIndicator(),
],
).padded(),
),
bottomNavigationBar: BottomAppBar(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
child: Text(S.of(context)!.testConnection),
onPressed: _updateReachability,
),
FilledButton(
child: Text(S.of(context)!.continueLabel),
onPressed: _reachabilityStatus == ReachabilityStatus.reachable
? widget.onContinue
: null,
),
],
),
),
);
}
Future<void> _updateReachability([String? address]) async {
setState(() {
_isCheckingConnection = true;
});
final certForm = widget.formBuilderKey.currentState
?.getRawValue(ClientCertificateFormField.fkClientCertificate)
as ClientCertificateFormModel?;
final status = await context
.read<ConnectivityStatusService>()
.isPaperlessServerReachable(
address ??
widget.formBuilderKey.currentState!
.getRawValue(ServerAddressFormField.fkServerAddress),
certForm != null
? ClientCertificate(
bytes: certForm.bytes, passphrase: certForm.passphrase)
: null,
);
setState(() {
_isCheckingConnection = false;
_reachabilityStatus = status;
});
}
Widget _buildStatusIndicator() {
if (_isCheckingConnection) {
return const ListTile();
}
Color errorColor = Theme.of(context).colorScheme.error;
switch (_reachabilityStatus) {
case ReachabilityStatus.unknown:
return Container();
case ReachabilityStatus.reachable:
return _buildIconText(
Icons.done,
S.of(context)!.connectionSuccessfulylEstablished,
Colors.green,
);
case ReachabilityStatus.notReachable:
return _buildIconText(
Icons.close,
S.of(context)!.couldNotEstablishConnectionToTheServer,
errorColor,
);
case ReachabilityStatus.unknownHost:
return _buildIconText(
Icons.close,
S.of(context)!.hostCouldNotBeResolved,
errorColor,
);
case ReachabilityStatus.missingClientCertificate:
return _buildIconText(
Icons.close,
S.of(context)!.loginPageReachabilityMissingClientCertificateText,
errorColor,
);
case ReachabilityStatus.invalidClientCertificateConfiguration:
return _buildIconText(
Icons.close,
S.of(context)!.incorrectOrMissingCertificatePassphrase,
errorColor,
);
case ReachabilityStatus.connectionTimeout:
return _buildIconText(
Icons.close,
S.of(context)!.connectionTimedOut,
errorColor,
);
}
}
Widget _buildIconText(
IconData icon,
String text, [
Color? color,
]) {
return ListTile(
title: Text(
text,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: color),
),
leading: Icon(
icon,
color: color,
),
);
}
}

View File

@@ -1,85 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/login/view/widgets/form_fields/server_address_form_field.dart';
import 'package:paperless_mobile/features/login/view/widgets/form_fields/user_credentials_form_field.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class ServerLoginPage extends StatefulWidget {
final String submitText;
final Future<void> Function() onSubmit;
final GlobalKey<FormBuilderState> formBuilderKey;
const ServerLoginPage({
super.key,
required this.onSubmit,
required this.formBuilderKey,
required this.submitText,
});
@override
State<ServerLoginPage> createState() => _ServerLoginPageState();
}
class _ServerLoginPageState extends State<ServerLoginPage> {
bool _isLoginLoading = false;
@override
Widget build(BuildContext context) {
final serverAddress = (widget.formBuilderKey.currentState
?.getRawValue(ServerAddressFormField.fkServerAddress)
as String?)
?.replaceAll(RegExp(r'https?://'), '') ??
'';
return Scaffold(
appBar: AppBar(
title: Text(S.of(context)!.loginPageSignInTitle),
bottom: _isLoginLoading
? const PreferredSize(
preferredSize: Size.fromHeight(4.0),
child: LinearProgressIndicator(),
)
: null,
),
body: ListView(
children: [
Text(
S.of(context)!.signInToServer(serverAddress) + ":",
style: Theme.of(context).textTheme.labelLarge,
).padded(16),
UserCredentialsFormField(
onFieldsSubmitted: widget.onSubmit,
),
Text(
S.of(context)!.loginRequiredPermissionsHint,
style: Theme.of(context).textTheme.bodySmall?.apply(
color: Theme.of(context)
.colorScheme
.onBackground
.withOpacity(0.6),
),
).padded(16),
],
),
bottomNavigationBar: BottomAppBar(
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FilledButton(
onPressed: !_isLoginLoading
? () async {
setState(() => _isLoginLoading = true);
try {
await widget.onSubmit();
} finally {
setState(() => _isLoginLoading = false);
}
}
: null,
child: Text(S.of(context)!.signIn),
)
],
),
),
);
}
}

View File

@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/theme.dart';
class LoginTransitionPage extends StatelessWidget {
final String text;
const LoginTransitionPage({super.key, required this.text});
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async => false,
child: AnnotatedRegion<SystemUiOverlayStyle>(
value: buildOverlayStyle(
Theme.of(context),
systemNavigationBarColor: Theme.of(context).colorScheme.background,
),
child: Scaffold(
body: Stack(
alignment: Alignment.center,
children: [
const CircularProgressIndicator(),
Align(
alignment: Alignment.bottomCenter,
child: Text(text).paddedOnly(bottom: 24),
),
],
).padded(16),
),
),
);
}
}

View File

@@ -1,8 +0,0 @@
import 'package:flutter/widgets.dart';
class NeverScrollableScrollBehavior extends ScrollBehavior {
@override
ScrollPhysics getScrollPhysics(BuildContext context) {
return const NeverScrollableScrollPhysics();
}
}