feat: Implement switching between accounts (multi user support), still WIP

This commit is contained in:
Anton Stubenbord
2023-04-21 01:32:43 +02:00
parent 1334f546ee
commit 95dd0a2405
50 changed files with 1055 additions and 721 deletions

View File

@@ -2,9 +2,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:paperless_mobile/constants.dart';
import 'package:paperless_mobile/core/bloc/paperless_server_information_cubit.dart';
import 'package:paperless_mobile/core/widgets/paperless_logo.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/settings/cubit/application_settings_cubit.dart';
import 'package:paperless_mobile/features/settings/view/settings_page.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
@@ -42,8 +42,7 @@ class AppDrawer extends StatelessWidget {
leading: const Icon(Icons.bug_report_outlined),
title: Text(S.of(context)!.reportABug),
onTap: () {
launchUrlString(
'https://github.com/astubenbord/paperless-mobile/issues/new');
launchUrlString('https://github.com/astubenbord/paperless-mobile/issues/new');
},
),
ListTile(
@@ -69,7 +68,10 @@ class AppDrawer extends StatelessWidget {
),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const SettingsPage(),
builder: (_) => BlocProvider.value(
value: context.read<PaperlessServerInformationCubit>(),
child: const SettingsPage(),
),
),
),
),

View File

@@ -10,8 +10,7 @@ class ApplicationIntroSlideshow extends StatefulWidget {
const ApplicationIntroSlideshow({super.key});
@override
State<ApplicationIntroSlideshow> createState() =>
_ApplicationIntroSlideshowState();
State<ApplicationIntroSlideshow> createState() => _ApplicationIntroSlideshowState();
}
//TODO: INTL ALL
@@ -28,7 +27,9 @@ class _ApplicationIntroSlideshowState extends State<ApplicationIntroSlideshow> {
showDoneButton: true,
next: Text(S.of(context)!.next),
done: Text(S.of(context)!.done),
onDone: () => Navigator.pop(context),
onDone: () {
Navigator.pop(context);
},
dotsDecorator: DotsDecorator(
color: Theme.of(context).colorScheme.onBackground,
activeColor: Theme.of(context).colorScheme.primary,

View File

@@ -5,8 +5,7 @@ import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.dart';
import 'package:paperless_mobile/features/document_details/view/dialogs/select_file_type_dialog.dart';
import 'package:paperless_mobile/features/settings/global_app_settings.dart';
import 'package:paperless_mobile/features/settings/cubit/application_settings_cubit.dart';
import 'package:paperless_mobile/features/settings/model/global_settings.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart';
@@ -44,9 +43,8 @@ class _DocumentDownloadButtonState extends State<DocumentDownloadButton> {
width: 16,
)
: const Icon(Icons.download),
onPressed: widget.document != null && widget.enabled
? () => _onDownload(widget.document!)
: null,
onPressed:
widget.document != null && widget.enabled ? () => _onDownload(widget.document!) : null,
).paddedOnly(right: 4);
}
@@ -70,7 +68,7 @@ class _DocumentDownloadButtonState extends State<DocumentDownloadButton> {
setState(() => _isDownloadPending = true);
await context.read<DocumentDetailsCubit>().downloadDocument(
downloadOriginal: downloadOriginal,
locale: context.read<GlobalAppSettings>().preferredLocaleSubtag,
locale: context.read<GlobalSettings>().preferredLocaleSubtag,
);
// showSnackBar(context, S.of(context)!.documentSuccessfullyDownloaded);
} on PaperlessServerException catch (error, stackTrace) {

View File

@@ -3,10 +3,10 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/bloc/paperless_server_information_cubit.dart';
import 'package:paperless_mobile/core/bloc/paperless_server_information_state.dart';
import 'package:paperless_mobile/core/delegate/customizable_sliver_persistent_header_delegate.dart';
import 'package:paperless_mobile/core/widgets/material/search/m3_search_bar.dart'
as s;
import 'package:paperless_mobile/core/widgets/material/search/m3_search_bar.dart' as s;
import 'package:paperless_mobile/features/document_search/view/document_search_page.dart';
import 'package:paperless_mobile/features/settings/view/dialogs/account_settings_dialog.dart';
import 'package:paperless_mobile/features/settings/view/manage_accounts_page.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class SliverSearchBar extends StatelessWidget {
@@ -37,8 +37,7 @@ class SliverSearchBar extends StatelessWidget {
onPressed: Scaffold.of(context).openDrawer,
),
trailingIcon: IconButton(
icon: BlocBuilder<PaperlessServerInformationCubit,
PaperlessServerInformationState>(
icon: BlocBuilder<PaperlessServerInformationCubit, PaperlessServerInformationState>(
builder: (context, state) {
return CircleAvatar(
child: Text(state.information?.userInitials ?? ''),
@@ -48,7 +47,10 @@ class SliverSearchBar extends StatelessWidget {
onPressed: () {
showDialog(
context: context,
builder: (context) => const AccountSettingsDialog(),
builder: (_) => BlocProvider.value(
value: context.read<PaperlessServerInformationCubit>(),
child: const ManageAccountsPage(),
),
);
},
),

View File

@@ -46,9 +46,8 @@ class DocumentDetailedItem extends DocumentItem {
padding.bottom -
kBottomNavigationBarHeight -
kToolbarHeight;
final maxHeight = highlights != null
? min(600.0, availableHeight)
: min(500.0, availableHeight);
final maxHeight =
highlights != null ? min(600.0, availableHeight) : min(500.0, availableHeight);
return Card(
color: isSelected ? Theme.of(context).colorScheme.inversePrimary : null,
child: InkWell(

View File

@@ -25,6 +25,7 @@ import 'package:paperless_mobile/features/inbox/cubit/inbox_cubit.dart';
import 'package:paperless_mobile/features/inbox/view/pages/inbox_page.dart';
import 'package:paperless_mobile/features/labels/cubit/label_cubit.dart';
import 'package:paperless_mobile/features/labels/view/pages/labels_page.dart';
import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart';
import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart';
import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart';
import 'package:paperless_mobile/features/sharing/share_intent_queue.dart';
@@ -185,6 +186,7 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
@override
Widget build(BuildContext context) {
final userId = context.watch<AuthenticationCubit>().state.userId;
final destinations = [
RouteDescription(
icon: const Icon(Icons.description_outlined),
@@ -232,19 +234,20 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
];
final routes = <Widget>[
MultiBlocProvider(
key: ValueKey(userId),
providers: [
BlocProvider(
create: (context) => DocumentsCubit(
context.read(),
context.read(),
context.read(),
),
)..reload(),
),
BlocProvider(
create: (context) => SavedViewCubit(
context.read(),
context.read(),
),
)..reload(),
),
],
child: const DocumentsPage(),
@@ -254,6 +257,7 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
child: const ScannerPage(),
),
MultiBlocProvider(
key: ValueKey(userId),
providers: [
BlocProvider(
create: (context) => LabelCubit(context.read()),
@@ -266,12 +270,12 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
child: const InboxPage(),
),
];
return MultiBlocListener(
listeners: [
BlocListener<ConnectivityCubit, ConnectivityState>(
//Only re-initialize data if the connectivity changed from not connected to connected
listenWhen: (previous, current) =>
current == ConnectivityState.connected,
listenWhen: (previous, current) => current == ConnectivityState.connected,
listener: (context, state) {
_initializeData(context);
},
@@ -280,9 +284,7 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
listener: (context, state) {
if (state.task != null) {
// Handle local notifications on task change (only when app is running for now).
context
.read<LocalNotificationService>()
.notifyTaskChanged(state.task!);
context.read<LocalNotificationService>().notifyTaskChanged(state.task!);
}
},
),
@@ -295,9 +297,7 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
children: [
NavigationRail(
labelType: NavigationRailLabelType.all,
destinations: destinations
.map((e) => e.toNavigationRailDestination())
.toList(),
destinations: destinations.map((e) => e.toNavigationRailDestination()).toList(),
selectedIndex: _currentIndex,
onDestinationSelected: _onNavigationChanged,
),
@@ -315,8 +315,7 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
elevation: 4.0,
selectedIndex: _currentIndex,
onDestinationSelected: _onNavigationChanged,
destinations:
destinations.map((e) => e.toNavigationDestination()).toList(),
destinations: destinations.map((e) => e.toNavigationDestination()).toList(),
),
body: routes[_currentIndex],
);

View File

@@ -5,9 +5,8 @@ import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/repository/saved_view_repository.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart';
import 'package:paperless_mobile/features/settings/global_app_settings.dart';
import 'package:paperless_mobile/features/settings/model/global_settings.dart';
import 'package:paperless_mobile/features/settings/cubit/application_settings_cubit.dart';
import 'package:paperless_mobile/features/settings/view/widgets/user_settings_builder.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
@@ -33,9 +32,7 @@ class VerifyIdentityPage extends StatelessWidget {
return Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(S
.of(context)!
.useTheConfiguredBiometricFactorToAuthenticate)
Text(S.of(context)!.useTheConfiguredBiometricFactorToAuthenticate)
.paddedSymmetrically(horizontal: 16),
const Icon(
Icons.fingerprint,
@@ -57,9 +54,7 @@ class VerifyIdentityPage extends StatelessWidget {
),
),
ElevatedButton(
onPressed: () => context
.read<AuthenticationCubit>()
.restoreSessionState(),
onPressed: () => context.read<AuthenticationCubit>().restoreSessionState(),
child: Text(S.of(context)!.verifyIdentity),
),
],

View File

@@ -13,8 +13,7 @@ import 'package:paperless_mobile/features/paged_document_view/cubit/document_pag
part 'inbox_cubit.g.dart';
part 'inbox_state.dart';
class InboxCubit extends HydratedCubit<InboxState>
with DocumentPagingBlocMixin {
class InboxCubit extends HydratedCubit<InboxState> with DocumentPagingBlocMixin {
final LabelRepository _labelRepository;
final PaperlessDocumentsApi _documentsApi;
@@ -37,10 +36,7 @@ class InboxCubit extends HydratedCubit<InboxState>
this,
onDeleted: remove,
onUpdated: (document) {
if (document.tags
.toSet()
.intersection(state.inboxTags.toSet())
.isEmpty) {
if (document.tags.toSet().intersection(state.inboxTags.toSet()).isEmpty) {
remove(document);
emit(state.copyWith(itemsInInboxCount: state.itemsInInboxCount - 1));
} else {
@@ -76,28 +72,32 @@ class InboxCubit extends HydratedCubit<InboxState>
/// Fetches inbox tag ids and loads the inbox items (documents).
///
Future<void> loadInbox() async {
debugPrint("Initializing inbox...");
final inboxTags = await _labelRepository.findAllTags().then(
(tags) => tags.where((t) => t.isInboxTag).map((t) => t.id!),
);
if (!isClosed) {
debugPrint("Initializing inbox...");
if (inboxTags.isEmpty) {
// no inbox tags = no inbox items.
return emit(
state.copyWith(
hasLoaded: true,
value: [],
inboxTags: [],
final inboxTags = await _labelRepository.findAllTags().then(
(tags) => tags.where((t) => t.isInboxTag).map((t) => t.id!),
);
if (inboxTags.isEmpty) {
// no inbox tags = no inbox items.
return emit(
state.copyWith(
hasLoaded: true,
value: [],
inboxTags: [],
),
);
}
emit(state.copyWith(inboxTags: inboxTags));
updateFilter(
filter: DocumentFilter(
sortField: SortField.added,
tags: IdsTagsQuery.fromIds(inboxTags),
),
);
}
emit(state.copyWith(inboxTags: inboxTags));
updateFilter(
filter: DocumentFilter(
sortField: SortField.added,
tags: IdsTagsQuery.fromIds(inboxTags),
),
);
}
///
@@ -133,8 +133,7 @@ class InboxCubit extends HydratedCubit<InboxState>
/// from the inbox.
///
Future<Iterable<int>> removeFromInbox(DocumentModel document) async {
final tagsToRemove =
document.tags.toSet().intersection(state.inboxTags.toSet());
final tagsToRemove = document.tags.toSet().intersection(state.inboxTags.toSet());
final updatedTags = {...document.tags}..removeAll(tagsToRemove);
final updatedDocument = await api.update(
@@ -188,8 +187,8 @@ class InboxCubit extends HydratedCubit<InboxState>
Future<void> assignAsn(DocumentModel document) async {
if (document.archiveSerialNumber == null) {
final int asn = await _documentsApi.findNextAsn();
final updatedDocument = await _documentsApi
.update(document.copyWith(archiveSerialNumber: () => asn));
final updatedDocument =
await _documentsApi.update(document.copyWith(archiveSerialNumber: () => asn));
replace(updatedDocument);
}

View File

@@ -1,18 +1,23 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/widgets.dart';
import 'package:hive_flutter/adapters.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/core/interceptor/dio_http_error_interceptor.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/repository/saved_view_repository.dart';
import 'package:paperless_mobile/core/security/session_manager.dart';
import 'package:paperless_mobile/features/login/model/authentication_information.dart';
import 'package:paperless_mobile/features/login/model/client_certificate.dart';
import 'package:paperless_mobile/features/login/model/user_credentials.model.dart';
import 'package:paperless_mobile/features/login/model/login_form_credentials.dart';
import 'package:paperless_mobile/features/login/model/user_account.dart';
import 'package:paperless_mobile/features/login/model/user_credentials.dart';
import 'package:paperless_mobile/features/login/services/authentication_service.dart';
import 'package:paperless_mobile/features/settings/global_app_settings.dart';
import 'package:paperless_mobile/features/settings/user_app_settings.dart';
import 'package:paperless_mobile/features/settings/model/global_settings.dart';
import 'package:paperless_mobile/features/settings/model/user_settings.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
part 'authentication_state.dart';
@@ -20,15 +25,21 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
final LocalAuthenticationService _localAuthService;
final PaperlessAuthenticationApi _authApi;
final SessionManager _dioWrapper;
final LabelRepository _labelRepository;
final SavedViewRepository _savedViewRepository;
final PaperlessServerStatsApi _serverStatsApi;
AuthenticationCubit(
this._localAuthService,
this._authApi,
this._dioWrapper,
) : super(AuthenticationState.initial);
this._labelRepository,
this._savedViewRepository,
this._serverStatsApi,
) : super(const AuthenticationState());
Future<void> login({
required UserCredentials credentials,
required LoginFormCredentials credentials,
required String serverUrl,
ClientCertificate? clientCertificate,
}) async {
@@ -47,107 +58,239 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
clientCertificate: clientCertificate,
authToken: token,
);
final authInfo = AuthenticationInformation(
username: credentials.username!,
serverUrl: serverUrl,
clientCertificate: clientCertificate,
token: token,
);
final userId = "${credentials.username}@$serverUrl";
// If it is first time login, create settings for this user.
final userSettingsBox = Hive.box<UserSettings>(HiveBoxes.userSettings);
final userAccountBox = Hive.box<UserAccount>(HiveBoxes.userAccount);
if (!userSettingsBox.containsKey(userId)) {
userSettingsBox.put(userId, UserSettings());
}
final fullName = await _fetchFullName();
if (!userAccountBox.containsKey(userId)) {
userAccountBox.put(
userId,
UserAccount(
serverUrl: serverUrl,
username: credentials.username!,
fullName: fullName,
),
);
}
// Mark logged in user as currently active user.
final globalSettings = GlobalAppSettings.boxedValue;
final globalSettings = GlobalSettings.boxedValue;
globalSettings.currentLoggedInUser = userId;
await globalSettings.save();
globalSettings.save();
// Save credentials in encrypted box
final encryptedBox = await _openEncryptedBox();
await encryptedBox.put(
final userCredentialsBox = await _getUserCredentialsBox();
await userCredentialsBox.put(
userId,
authInfo,
);
encryptedBox.close();
emit(
AuthenticationState(
wasLoginStored: false,
authentication: authInfo,
UserCredentials(
token: token,
clientCertificate: clientCertificate,
),
);
userCredentialsBox.close();
emit(
AuthenticationState(
isAuthenticated: true,
username: credentials.username,
userId: userId,
fullName: fullName,
//TODO: Query ui settings with full name and add as parameter here...
),
);
}
/// Switches to another account if it exists.
Future<void> switchAccount(String userId) async {
final globalSettings = GlobalSettings.boxedValue;
if (globalSettings.currentLoggedInUser == userId) {
return;
}
final userAccountBox = Hive.box<UserAccount>(HiveBoxes.userAccount);
final userSettingsBox = Hive.box<UserSettings>(HiveBoxes.userSettings);
if (!userSettingsBox.containsKey(userId)) {
debugPrint("User $userId not yet registered.");
return;
}
final userSettings = userSettingsBox.get(userId)!;
final account = userAccountBox.get(userId)!;
if (userSettings.isBiometricAuthenticationEnabled) {
final authenticated =
await _localAuthService.authenticateLocalUser("Authenticate to switch your account.");
if (!authenticated) {
debugPrint("User unable to authenticate.");
return;
}
}
final credentialsBox = await _getUserCredentialsBox();
if (!credentialsBox.containsKey(userId)) {
await credentialsBox.close();
debugPrint("Invalid authentication for $userId");
return;
}
final credentials = credentialsBox.get(userId);
await _resetExternalState();
_dioWrapper.updateSettings(
authToken: credentials!.token,
clientCertificate: credentials.clientCertificate,
serverInformation: PaperlessServerInformationModel(),
baseUrl: account.serverUrl,
);
globalSettings.currentLoggedInUser = userId;
await globalSettings.save();
await _reloadRepositories();
emit(
AuthenticationState(
isAuthenticated: true,
username: account.username,
fullName: account.fullName,
userId: userId,
),
);
}
Future<String> addAccount({
required LoginFormCredentials credentials,
required String serverUrl,
ClientCertificate? clientCertificate,
required bool enableBiometricAuthentication,
}) async {
assert(credentials.password != null && credentials.username != null);
final userId = "${credentials.username}@$serverUrl";
final userAccountsBox = Hive.box<UserAccount>(HiveBoxes.userAccount);
final userSettingsBox = Hive.box<UserSettings>(HiveBoxes.userSettings);
if (userAccountsBox.containsKey(userId)) {
throw Exception("User already exists");
}
// Creates a parallel session to get token and disposes of resources after.
final sessionManager = SessionManager([
DioHttpErrorInterceptor(),
]);
sessionManager.updateSettings(
clientCertificate: clientCertificate,
baseUrl: serverUrl,
);
final authApi = PaperlessAuthenticationApiImpl(sessionManager.client);
final token = await authApi.login(
username: credentials.username!,
password: credentials.password!,
);
sessionManager.resetSettings();
await userSettingsBox.put(
userId,
UserSettings(
isBiometricAuthenticationEnabled: enableBiometricAuthentication,
),
);
final fullName = await _fetchFullName();
await userAccountsBox.put(
userId,
UserAccount(
serverUrl: serverUrl,
username: credentials.username!,
fullName: fullName,
),
);
final userCredentialsBox = await _getUserCredentialsBox();
await userCredentialsBox.put(
userId,
UserCredentials(
token: token,
clientCertificate: clientCertificate,
),
);
await userCredentialsBox.close();
return userId;
}
Future<void> removeAccount(String userId) async {
final globalSettings = GlobalSettings.boxedValue;
final currentUser = globalSettings.currentLoggedInUser;
final userAccountBox = Hive.box<UserAccount>(HiveBoxes.userAccount);
final userCredentialsBox = await _getUserCredentialsBox();
final userSettingsBox = Hive.box<UserSettings>(HiveBoxes.userSettings);
await userAccountBox.delete(userId);
await userCredentialsBox.delete(userId);
await userSettingsBox.delete(userId);
if (currentUser == userId) {
return logout();
}
}
///
/// Performs a conditional hydration based on the local authentication success.
///
Future<void> restoreSessionState() async {
final globalSettings = GlobalAppSettings.boxedValue;
if (globalSettings.currentLoggedInUser == null) {
final globalSettings = GlobalSettings.boxedValue;
final userId = globalSettings.currentLoggedInUser;
if (userId == null) {
// If there is nothing to restore, we can quit here.
return;
}
final userSettings = Hive.box<UserAppSettings>(HiveBoxes.userSettings)
.get(globalSettings.currentLoggedInUser!);
final userSettings = Hive.box<UserSettings>(HiveBoxes.userSettings).get(userId)!;
final userAccount = Hive.box<UserAccount>(HiveBoxes.userAccount).get(userId)!;
if (userSettings!.isBiometricAuthenticationEnabled) {
final localAuthSuccess = await _localAuthService
.authenticateLocalUser("Authenticate to log back in"); //TODO: INTL
if (localAuthSuccess) {
final authentication = await _readAuthenticationFromEncryptedBox(
globalSettings.currentLoggedInUser!);
if (authentication != null) {
_dioWrapper.updateSettings(
clientCertificate: authentication.clientCertificate,
authToken: authentication.token,
baseUrl: authentication.serverUrl,
);
return emit(
AuthenticationState(
wasLoginStored: true,
authentication: state.authentication,
wasLocalAuthenticationSuccessful: true,
),
);
}
} else {
return emit(
AuthenticationState(
wasLoginStored: true,
wasLocalAuthenticationSuccessful: false,
authentication: null,
),
);
if (userSettings.isBiometricAuthenticationEnabled) {
final localAuthSuccess =
await _localAuthService.authenticateLocalUser("Authenticate to log back in"); //TODO: INTL
if (!localAuthSuccess) {
emit(const AuthenticationState(showBiometricAuthenticationScreen: true));
return;
}
}
final userCredentialsBox = await _getUserCredentialsBox();
final authentication = userCredentialsBox.get(globalSettings.currentLoggedInUser!);
if (authentication != null) {
_dioWrapper.updateSettings(
clientCertificate: authentication.clientCertificate,
authToken: authentication.token,
baseUrl: userAccount.serverUrl,
serverInformation: PaperlessServerInformationModel(),
);
emit(
AuthenticationState(
isAuthenticated: true,
showBiometricAuthenticationScreen: false,
username: userAccount.username,
),
);
} else {
final authentication = await _readAuthenticationFromEncryptedBox(
globalSettings.currentLoggedInUser!);
if (authentication != null) {
_dioWrapper.updateSettings(
clientCertificate: authentication.clientCertificate,
authToken: authentication.token,
baseUrl: authentication.serverUrl,
);
emit(
AuthenticationState(
authentication: authentication,
wasLoginStored: true,
),
);
} else {
return emit(AuthenticationState.initial);
}
throw Exception("User should be authenticated but no authentication information was found.");
}
}
Future<AuthenticationInformation?> _readAuthenticationFromEncryptedBox(
String userId) {
return _openEncryptedBox().then((box) => box.get(userId));
Future<void> logout() async {
await _resetExternalState();
final globalSettings = GlobalSettings.boxedValue;
globalSettings
..currentLoggedInUser = null
..save();
emit(const AuthenticationState());
}
Future<Box<AuthenticationInformation?>> _openEncryptedBox() async {
Future<Uint8List> _getEncryptedBoxKey() async {
const secureStorage = FlutterSecureStorage();
final encryptionKeyString = await secureStorage.read(key: 'key');
if (encryptionKeyString == null) {
if (!await secureStorage.containsKey(key: 'key')) {
final key = Hive.generateSecureKey();
await secureStorage.write(
@@ -155,17 +298,40 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
value: base64UrlEncode(key),
);
}
final key = await secureStorage.read(key: 'key');
final encryptionKeyUint8List = base64Url.decode(key!);
return await Hive.openBox<AuthenticationInformation>(
HiveBoxes.vault,
encryptionCipher: HiveAesCipher(encryptionKeyUint8List),
final key = (await secureStorage.read(key: 'key'))!;
return base64Decode(key);
}
Future<Box<UserCredentials>> _getUserCredentialsBox() async {
final keyBytes = await _getEncryptedBoxKey();
return Hive.openBox<UserCredentials>(
HiveBoxes.userCredentials,
encryptionCipher: HiveAesCipher(keyBytes),
);
}
Future<void> logout() async {
await Hive.box<AuthenticationInformation>(HiveBoxes.authentication).clear();
Future<void> _resetExternalState() {
_dioWrapper.resetSettings();
emit(AuthenticationState.initial);
return Future.wait([
HydratedBloc.storage.clear(),
_labelRepository.clear(),
_savedViewRepository.clear(),
]);
}
Future<void> _reloadRepositories() {
return Future.wait([
_labelRepository.initialize(),
_savedViewRepository.findAll(),
]);
}
Future<String?> _fetchFullName() async {
try {
final uiSettings = await _serverStatsApi.getUiSettings();
return uiSettings.displayName;
} catch (error) {
return null;
}
}
}

View File

@@ -1,35 +1,43 @@
part of 'authentication_cubit.dart';
@JsonSerializable()
class AuthenticationState {
final bool wasLoginStored;
@JsonKey(includeFromJson: false, includeToJson: false)
final bool? wasLocalAuthenticationSuccessful;
final AuthenticationInformation? authentication;
class AuthenticationState with EquatableMixin {
final bool showBiometricAuthenticationScreen;
final bool isAuthenticated;
final String? username;
final String? fullName;
final String? userId;
static final AuthenticationState initial = AuthenticationState(
wasLoginStored: false,
);
bool get isAuthenticated => authentication != null;
AuthenticationState({
required this.wasLoginStored,
this.wasLocalAuthenticationSuccessful,
this.authentication,
const AuthenticationState({
this.isAuthenticated = false,
this.showBiometricAuthenticationScreen = false,
this.username,
this.fullName,
this.userId,
});
AuthenticationState copyWith({
bool? wasLoginStored,
bool? isAuthenticated,
AuthenticationInformation? authentication,
bool? wasLocalAuthenticationSuccessful,
bool? showBiometricAuthenticationScreen,
String? username,
String? fullName,
String? userId,
}) {
return AuthenticationState(
wasLoginStored: wasLoginStored ?? this.wasLoginStored,
authentication: authentication ?? this.authentication,
wasLocalAuthenticationSuccessful: wasLocalAuthenticationSuccessful ??
this.wasLocalAuthenticationSuccessful,
isAuthenticated: isAuthenticated ?? this.isAuthenticated,
showBiometricAuthenticationScreen:
showBiometricAuthenticationScreen ?? this.showBiometricAuthenticationScreen,
username: username ?? this.username,
fullName: fullName ?? this.fullName,
userId: userId ?? this.userId,
);
}
@override
List<Object?> get props => [
userId,
username,
fullName,
isAuthenticated,
showBiometricAuthenticationScreen,
];
}

View File

@@ -3,44 +3,15 @@ import 'dart:typed_data';
import 'package:hive_flutter/adapters.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/core/type/types.dart';
part 'client_certificate.g.dart';
@HiveType(typeId: HiveTypeIds.clientCertificate)
class ClientCertificate {
static const bytesKey = 'bytes';
static const passphraseKey = 'passphrase';
@HiveField(0)
final Uint8List bytes;
Uint8List bytes;
@HiveField(1)
final String? passphrase;
String? passphrase;
ClientCertificate({required this.bytes, this.passphrase});
static ClientCertificate? nullable(Uint8List? bytes, {String? passphrase}) {
if (bytes != null) {
return ClientCertificate(bytes: bytes, passphrase: passphrase);
}
return null;
}
JSON toJson() {
return {
bytesKey: base64Encode(bytes),
passphraseKey: passphrase,
};
}
ClientCertificate.fromJson(JSON json)
: bytes = base64Decode(json[bytesKey]),
passphrase = json[passphraseKey];
ClientCertificate copyWith({Uint8List? bytes, String? passphrase}) {
return ClientCertificate(
bytes: bytes ?? this.bytes,
passphrase: passphrase ?? this.passphrase,
);
}
}

View File

@@ -0,0 +1,19 @@
import 'dart:convert';
import 'dart:typed_data';
class ClientCertificateFormModel {
static const bytesKey = 'bytes';
static const passphraseKey = 'passphrase';
final Uint8List bytes;
final String? passphrase;
ClientCertificateFormModel({required this.bytes, this.passphrase});
ClientCertificateFormModel copyWith({Uint8List? bytes, String? passphrase}) {
return ClientCertificateFormModel(
bytes: bytes ?? this.bytes,
passphrase: passphrase ?? this.passphrase,
);
}
}

View File

@@ -0,0 +1,13 @@
class LoginFormCredentials {
final String? username;
final String? password;
LoginFormCredentials({this.username, this.password});
LoginFormCredentials copyWith({String? username, String? password}) {
return LoginFormCredentials(
username: username ?? this.username,
password: password ?? this.password,
);
}
}

View File

@@ -0,0 +1,20 @@
import 'package:hive_flutter/adapters.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
part 'user_account.g.dart';
@HiveType(typeId: HiveTypeIds.userAccount)
class UserAccount {
@HiveField(0)
final String serverUrl;
@HiveField(1)
final String username;
@HiveField(2)
final String? fullName;
UserAccount({
required this.serverUrl,
required this.username,
this.fullName,
});
}

View File

@@ -0,0 +1,18 @@
import 'package:hive/hive.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/features/login/model/client_certificate.dart';
part 'user_credentials.g.dart';
@HiveType(typeId: HiveTypeIds.userCredentials)
class UserCredentials extends HiveObject {
@HiveField(0)
final String token;
@HiveField(1)
final ClientCertificate? clientCertificate;
UserCredentials({
required this.token,
this.clientCertificate,
});
}

View File

@@ -1,13 +0,0 @@
class UserCredentials {
final String? username;
final String? password;
UserCredentials({this.username, this.password});
UserCredentials copyWith({String? username, String? password}) {
return UserCredentials(
username: username ?? this.username,
password: password ?? this.password,
);
}
}

View File

@@ -3,18 +3,37 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/type/types.dart';
import 'package:paperless_mobile/features/app_intro/application_intro_slideshow.dart';
import 'package:paperless_mobile/features/login/cubit/authentication_cubit.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/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/settings/model/global_settings.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 LoginPage extends StatefulWidget {
const LoginPage({Key? key}) : super(key: key);
final void Function(
BuildContext context,
String username,
String password,
String serverUrl,
ClientCertificate? clientCertificate,
) onSubmit;
final String submitText;
const LoginPage({
Key? key,
required this.onSubmit,
required this.submitText,
}) : super(key: key);
@override
State<LoginPage> createState() => _LoginPageState();
@@ -46,7 +65,8 @@ class _LoginPageState extends State<LoginPage> {
),
ServerLoginPage(
formBuilderKey: _formKey,
onDone: _login,
submitText: widget.submitText,
onSubmit: _login,
),
],
),
@@ -58,24 +78,23 @@ class _LoginPageState extends State<LoginPage> {
FocusScope.of(context).unfocus();
if (_formKey.currentState?.saveAndValidate() ?? false) {
final form = _formKey.currentState!.value;
try {
await context.read<AuthenticationCubit>().login(
credentials: form[UserCredentialsFormField.fkCredentials],
serverUrl: form[ServerAddressFormField.fkServerAddress],
clientCertificate:
form[ClientCertificateFormField.fkClientCertificate],
);
} on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace);
} on PaperlessValidationErrors catch (error, stackTrace) {
if (error.hasFieldUnspecificError) {
showLocalizedError(context, error.fieldUnspecificError!);
} else {
showGenericError(context, error.values.first, stackTrace);
}
} catch (unknownError, stackTrace) {
showGenericError(context, unknownError.toString(), stackTrace);
ClientCertificate? clientCert;
final clientCertFormModel =
form[ClientCertificateFormField.fkClientCertificate] as ClientCertificateFormModel?;
if (clientCertFormModel != null) {
clientCert = ClientCertificate(
bytes: clientCertFormModel.bytes,
passphrase: clientCertFormModel.passphrase,
);
}
final credentials = form[UserCredentialsFormField.fkCredentials] as LoginFormCredentials;
widget.onSubmit(
context,
credentials.username!,
credentials.password!,
form[ServerAddressFormField.fkServerAddress],
clientCert,
);
}
}
}

View File

@@ -5,6 +5,7 @@ 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/model/client_certificate.dart';
import 'package:paperless_mobile/features/login/model/client_certificate_form_model.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/constants.dart';
@@ -15,23 +16,21 @@ import 'obscured_input_text_form_field.dart';
class ClientCertificateFormField extends StatefulWidget {
static const fkClientCertificate = 'clientCertificate';
final void Function(ClientCertificate? cert) onChanged;
final void Function(ClientCertificateFormModel? cert) onChanged;
const ClientCertificateFormField({
Key? key,
required this.onChanged,
}) : super(key: key);
@override
State<ClientCertificateFormField> createState() =>
_ClientCertificateFormFieldState();
State<ClientCertificateFormField> createState() => _ClientCertificateFormFieldState();
}
class _ClientCertificateFormFieldState
extends State<ClientCertificateFormField> {
class _ClientCertificateFormFieldState extends State<ClientCertificateFormField> {
File? _selectedFile;
@override
Widget build(BuildContext context) {
return FormBuilderField<ClientCertificate?>(
return FormBuilderField<ClientCertificateFormModel?>(
key: const ValueKey('login-client-cert'),
onChanged: widget.onChanged,
initialValue: null,
@@ -46,8 +45,7 @@ class _ClientCertificateFormFieldState
return null;
},
builder: (field) {
final theme =
Theme.of(context).copyWith(dividerColor: Colors.transparent); //new
final theme = Theme.of(context).copyWith(dividerColor: Colors.transparent); //new
return Theme(
data: theme,
child: ExpansionTile(
@@ -124,7 +122,7 @@ class _ClientCertificateFormFieldState
);
}
Future<void> _onSelectFile(FormFieldState<ClientCertificate?> field) async {
Future<void> _onSelectFile(FormFieldState<ClientCertificateFormModel?> field) async {
FilePickerResult? result = await FilePicker.platform.pickFiles(
allowMultiple: false,
);
@@ -133,14 +131,13 @@ class _ClientCertificateFormFieldState
setState(() {
_selectedFile = file;
});
final changedValue =
field.value?.copyWith(bytes: file.readAsBytesSync()) ??
ClientCertificate(bytes: file.readAsBytesSync());
final changedValue = field.value?.copyWith(bytes: file.readAsBytesSync()) ??
ClientCertificateFormModel(bytes: file.readAsBytesSync());
field.didChange(changedValue);
}
}
Widget _buildSelectedFileText(FormFieldState<ClientCertificate?> field) {
Widget _buildSelectedFileText(FormFieldState<ClientCertificateFormModel?> field) {
if (field.value == null) {
assert(_selectedFile == null);
return Text(

View File

@@ -2,7 +2,7 @@ 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/model/user_credentials.model.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/generated/l10n/app_localizations.dart';
@@ -14,14 +14,13 @@ class UserCredentialsFormField extends StatefulWidget {
}) : super(key: key);
@override
State<UserCredentialsFormField> createState() =>
_UserCredentialsFormFieldState();
State<UserCredentialsFormField> createState() => _UserCredentialsFormFieldState();
}
class _UserCredentialsFormFieldState extends State<UserCredentialsFormField> {
@override
Widget build(BuildContext context) {
return FormBuilderField<UserCredentials?>(
return FormBuilderField<LoginFormCredentials?>(
name: UserCredentialsFormField.fkCredentials,
builder: (field) => AutofillGroup(
child: Column(
@@ -34,7 +33,7 @@ class _UserCredentialsFormFieldState extends State<UserCredentialsFormField> {
autocorrect: false,
onChanged: (username) => field.didChange(
field.value?.copyWith(username: username) ??
UserCredentials(username: username),
LoginFormCredentials(username: username),
),
validator: (value) {
if (value?.trim().isEmpty ?? true) {
@@ -51,7 +50,7 @@ class _UserCredentialsFormFieldState extends State<UserCredentialsFormField> {
label: S.of(context)!.password,
onChanged: (password) => field.didChange(
field.value?.copyWith(password: password) ??
UserCredentials(password: password),
LoginFormCredentials(password: password),
),
validator: (value) {
if (value?.trim().isEmpty ?? true) {

View File

@@ -3,6 +3,8 @@ import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:paperless_mobile/core/service/connectivity_status_service.dart';
import 'package:paperless_mobile/core/widgets/paperless_logo.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';
@@ -35,9 +37,8 @@ class _ServerConnectionPageState extends State<ServerConnectionPage> {
toolbarHeight: kToolbarHeight - 4,
title: Text(S.of(context)!.connectToPaperless),
bottom: PreferredSize(
child: _isCheckingConnection
? const LinearProgressIndicator()
: const SizedBox(height: 4.0),
child:
_isCheckingConnection ? const LinearProgressIndicator() : const SizedBox(height: 4.0),
preferredSize: const Size.fromHeight(4.0),
),
),
@@ -67,9 +68,8 @@ class _ServerConnectionPageState extends State<ServerConnectionPage> {
),
FilledButton(
child: Text(S.of(context)!.continueLabel),
onPressed: _reachabilityStatus == ReachabilityStatus.reachable
? widget.onContinue
: null,
onPressed:
_reachabilityStatus == ReachabilityStatus.reachable ? widget.onContinue : null,
),
],
),
@@ -81,16 +81,16 @@ class _ServerConnectionPageState extends State<ServerConnectionPage> {
setState(() {
_isCheckingConnection = true;
});
final status = await context
.read<ConnectivityStatusService>()
.isPaperlessServerReachable(
final certForm = widget.formBuilderKey.currentState
?.getRawValue(ClientCertificateFormField.fkClientCertificate)
as ClientCertificateFormModel?;
final status = await context.read<ConnectivityStatusService>().isPaperlessServerReachable(
address ??
widget.formBuilderKey.currentState!
.getRawValue(ServerAddressFormField.fkServerAddress),
widget.formBuilderKey.currentState?.getRawValue(
ClientCertificateFormField.fkClientCertificate,
),
certForm != null
? ClientCertificate(bytes: certForm.bytes, passphrase: certForm.passphrase)
: null,
);
setState(() {
_isCheckingConnection = false;

View File

@@ -6,12 +6,14 @@ import 'package:paperless_mobile/features/login/view/widgets/form_fields/user_cr
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class ServerLoginPage extends StatefulWidget {
final Future<void> Function() onDone;
final String submitText;
final Future<void> Function() onSubmit;
final GlobalKey<FormBuilderState> formBuilderKey;
const ServerLoginPage({
super.key,
required this.onDone,
required this.onSubmit,
required this.formBuilderKey,
required this.submitText,
});
@override
@@ -23,8 +25,7 @@ class _ServerLoginPageState extends State<ServerLoginPage> {
@override
Widget build(BuildContext context) {
final serverAddress = (widget.formBuilderKey.currentState
?.getRawValue(ServerAddressFormField.fkServerAddress)
as String?)
?.getRawValue(ServerAddressFormField.fkServerAddress) as String?)
?.replaceAll(RegExp(r'https?://'), '') ??
'';
return Scaffold(
@@ -50,7 +51,7 @@ class _ServerLoginPageState extends State<ServerLoginPage> {
FilledButton(
onPressed: () async {
setState(() => _isLoginLoading = true);
await widget.onDone();
await widget.onSubmit();
setState(() => _isLoginLoading = false);
},
child: Text(S.of(context)!.signIn),

View File

@@ -43,12 +43,14 @@ class SavedViewDetailsCubit extends HydratedCubit<SavedViewDetailsState>
_labelRepository.addListener(
this,
onChanged: (labels) {
emit(state.copyWith(
correspondents: labels.correspondents,
documentTypes: labels.documentTypes,
tags: labels.tags,
storagePaths: labels.storagePaths,
));
if (!isClosed) {
emit(state.copyWith(
correspondents: labels.correspondents,
documentTypes: labels.documentTypes,
tags: labels.tags,
storagePaths: labels.storagePaths,
));
}
},
);
updateFilter(filter: savedView.toDocumentFilter());

View File

@@ -2,10 +2,10 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/bloc/paperless_server_information_cubit.dart';
import 'package:paperless_mobile/core/bloc/paperless_server_information_state.dart';
import 'package:paperless_mobile/core/widgets/material/search/m3_search_bar.dart'
as s;
import 'package:paperless_mobile/core/widgets/material/search/m3_search_bar.dart' as s;
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/settings/view/dialogs/account_settings_dialog.dart';
import 'package:paperless_mobile/features/settings/view/manage_accounts_page.dart';
typedef OpenSearchCallback = void Function(BuildContext context);
@@ -47,8 +47,7 @@ class _SearchAppBarState extends State<SearchAppBar> {
onPressed: Scaffold.of(context).openDrawer,
),
trailingIcon: IconButton(
icon: BlocBuilder<PaperlessServerInformationCubit,
PaperlessServerInformationState>(
icon: BlocBuilder<PaperlessServerInformationCubit, PaperlessServerInformationState>(
builder: (context, state) {
return CircleAvatar(
child: Text(state.information?.userInitials ?? ''),
@@ -58,7 +57,10 @@ class _SearchAppBarState extends State<SearchAppBar> {
onPressed: () {
showDialog(
context: context,
builder: (context) => const AccountSettingsDialog(),
builder: (context) => BlocProvider.value(
value: context.read<PaperlessServerInformationCubit>(),
child: const ManageAccountsPage(),
),
);
},
),

View File

@@ -1,64 +0,0 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:paperless_mobile/features/login/services/authentication_service.dart';
import 'package:paperless_mobile/features/settings/model/color_scheme_option.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
part 'application_settings_cubit.g.dart';
part 'application_settings_state.dart';
class ApplicationSettingsCubit extends HydratedCubit<ApplicationSettingsState> {
final LocalAuthenticationService _localAuthenticationService;
ApplicationSettingsCubit(this._localAuthenticationService)
: super(ApplicationSettingsState.defaultSettings);
Future<void> setLocale(String? localeSubtag) async {
final updatedSettings = state.copyWith(preferredLocaleSubtag: localeSubtag);
_updateSettings(updatedSettings);
}
Future<void> setIsBiometricAuthenticationEnabled(
bool isEnabled, {
required String localizedReason,
}) async {
final isActionAuthorized = await _localAuthenticationService
.authenticateLocalUser(localizedReason);
if (isActionAuthorized) {
final updatedSettings =
state.copyWith(isLocalAuthenticationEnabled: isEnabled);
_updateSettings(updatedSettings);
}
}
void setThemeMode(ThemeMode? selectedMode) {
final updatedSettings = state.copyWith(preferredThemeMode: selectedMode);
_updateSettings(updatedSettings);
}
void setColorSchemeOption(ColorSchemeOption schemeOption) {
final updatedSettings =
state.copyWith(preferredColorSchemeOption: schemeOption);
_updateSettings(updatedSettings);
}
void _updateSettings(ApplicationSettingsState settings) async {
emit(settings);
}
@override
Future<void> clear() async {
await super.clear();
emit(ApplicationSettingsState.defaultSettings);
}
@override
ApplicationSettingsState? fromJson(Map<String, dynamic> json) =>
ApplicationSettingsState.fromJson(json);
@override
Map<String, dynamic>? toJson(ApplicationSettingsState state) =>
state.toJson();
}

View File

@@ -1,53 +0,0 @@
part of 'application_settings_cubit.dart';
///
/// State holding the current application settings such as selected language, theme mode and more.
///
@JsonSerializable()
class ApplicationSettingsState {
static final defaultSettings = ApplicationSettingsState(
preferredLocaleSubtag: _defaultPreferredLocaleSubtag,
);
final bool isLocalAuthenticationEnabled;
final String preferredLocaleSubtag;
final ThemeMode preferredThemeMode;
final ColorSchemeOption preferredColorSchemeOption;
ApplicationSettingsState({
required this.preferredLocaleSubtag,
this.preferredThemeMode = ThemeMode.system,
this.isLocalAuthenticationEnabled = false,
this.preferredColorSchemeOption = ColorSchemeOption.classic,
});
Map<String, dynamic> toJson() => _$ApplicationSettingsStateToJson(this);
factory ApplicationSettingsState.fromJson(Map<String, dynamic> json) =>
_$ApplicationSettingsStateFromJson(json);
ApplicationSettingsState copyWith({
bool? isLocalAuthenticationEnabled,
String? preferredLocaleSubtag,
ThemeMode? preferredThemeMode,
ColorSchemeOption? preferredColorSchemeOption,
}) {
return ApplicationSettingsState(
isLocalAuthenticationEnabled:
isLocalAuthenticationEnabled ?? this.isLocalAuthenticationEnabled,
preferredLocaleSubtag:
preferredLocaleSubtag ?? this.preferredLocaleSubtag,
preferredThemeMode: preferredThemeMode ?? this.preferredThemeMode,
preferredColorSchemeOption:
preferredColorSchemeOption ?? this.preferredColorSchemeOption,
);
}
static String get _defaultPreferredLocaleSubtag {
String preferredLocale = Platform.localeName.split("_").first;
if (!S.supportedLocales
.any((locale) => locale.languageCode == preferredLocale)) {
preferredLocale = 'en';
}
return preferredLocale;
}
}

View File

@@ -3,10 +3,10 @@ import 'package:hive/hive.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/features/settings/model/color_scheme_option.dart';
part 'global_app_settings.g.dart';
part 'global_settings.g.dart';
@HiveType(typeId: HiveTypeIds.globalSettings)
class GlobalAppSettings with ChangeNotifier, HiveObjectMixin {
class GlobalSettings with HiveObjectMixin {
@HiveField(0)
String preferredLocaleSubtag;
@@ -22,7 +22,7 @@ class GlobalAppSettings with ChangeNotifier, HiveObjectMixin {
@HiveField(4)
String? currentLoggedInUser;
GlobalAppSettings({
GlobalSettings({
required this.preferredLocaleSubtag,
this.preferredThemeMode = ThemeMode.system,
this.preferredColorSchemeOption = ColorSchemeOption.classic,
@@ -30,7 +30,6 @@ class GlobalAppSettings with ChangeNotifier, HiveObjectMixin {
this.currentLoggedInUser,
});
static GlobalAppSettings get boxedValue =>
Hive.box<GlobalAppSettings>(HiveBoxes.globalSettings)
.get(HiveBoxSingleValueKey.value)!;
static GlobalSettings get boxedValue =>
Hive.box<GlobalSettings>(HiveBoxes.globalSettings).getValue()!;
}

View File

@@ -1,16 +1,14 @@
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/features/settings/model/color_scheme_option.dart';
part 'user_app_settings.g.dart';
part 'user_settings.g.dart';
@HiveType(typeId: HiveTypeIds.userSettings)
class UserAppSettings with HiveObjectMixin {
class UserSettings with HiveObjectMixin {
@HiveField(0)
bool isBiometricAuthenticationEnabled;
UserAppSettings({
UserSettings({
this.isBiometricAuthenticationEnabled = false,
});
}

View File

@@ -1,18 +1,18 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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/paperless_server_information_cubit.dart';
import 'package:paperless_mobile/core/bloc/paperless_server_information_state.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/repository/saved_view_repository.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/core/widgets/hint_card.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart';
import 'package:paperless_mobile/features/settings/global_app_settings.dart';
import 'package:paperless_mobile/features/settings/cubit/application_settings_cubit.dart';
import 'package:paperless_mobile/features/login/model/user_account.dart';
import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart';
class AccountSettingsDialog extends StatelessWidget {
@@ -20,74 +20,95 @@ class AccountSettingsDialog extends StatelessWidget {
@override
Widget build(BuildContext context) {
return AlertDialog(
scrollable: true,
contentPadding: EdgeInsets.zero,
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(S.of(context)!.account),
const CloseButton(),
],
),
content: BlocBuilder<PaperlessServerInformationCubit,
PaperlessServerInformationState>(
builder: (context, state) {
return Column(
children: [
ExpansionTile(
leading: CircleAvatar(
child: Text(state.information?.userInitials ?? ''),
return GlobalSettingsBuilder(builder: (context, globalSettings) {
return AlertDialog(
insetPadding: EdgeInsets.symmetric(horizontal: 24, vertical: 32),
scrollable: true,
contentPadding: EdgeInsets.zero,
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(S.of(context)!.account),
const CloseButton(),
],
),
content: BlocBuilder<PaperlessServerInformationCubit, PaperlessServerInformationState>(
builder: (context, state) {
return Column(
children: [
ValueListenableBuilder(
valueListenable: Hive.box<UserAccount>(HiveBoxes.userAccount).listenable(),
builder: (context, box, _) {
// final currentUser = globalSettings.currentLoggedInUser;
final currentUser = null;
final accountIds =
box.keys.whereNot((element) => element == currentUser).toList();
final accounts = accountIds.map((id) => box.get(id)!).toList();
return ExpansionTile(
leading: CircleAvatar(
child: Text(state.information?.userInitials ?? ''),
),
title: Text(state.information?.username ?? ''),
subtitle: Text(state.information?.host ?? ''),
children:
accounts.map((account) => _buildAccountTile(account, true)).toList(),
);
},
),
title: Text(state.information?.username ?? ''),
subtitle: Text(state.information?.host ?? ''),
children: const [
HintCard(
hintText: "WIP: Coming soon with multi user support!",
),
],
),
const Divider(),
ListTile(
dense: true,
leading: const Icon(Icons.person_add_rounded),
title: Text(S.of(context)!.addAnotherAccount),
onTap: () {},
),
const Divider(),
FilledButton(
style: ButtonStyle(
backgroundColor: MaterialStatePropertyAll(
Theme.of(context).colorScheme.error,
),
ListTile(
dense: true,
leading: const Icon(Icons.person_add_rounded),
title: Text(S.of(context)!.addAnotherAccount),
onTap: () {},
),
child: Text(
S.of(context)!.disconnect,
style: TextStyle(
color: Theme.of(context).colorScheme.onError,
const Divider(),
FilledButton(
style: ButtonStyle(
backgroundColor: MaterialStatePropertyAll(
Theme.of(context).colorScheme.error,
),
),
),
onPressed: () async {
await _onLogout(context);
Navigator.of(context).maybePop();
},
).padded(16),
],
);
},
),
);
child: Text(
S.of(context)!.disconnect,
style: TextStyle(
color: Theme.of(context).colorScheme.onError,
),
),
onPressed: () async {
await _onLogout(context);
Navigator.of(context).maybePop();
},
).padded(16),
],
);
},
),
);
});
}
Future<void> _onLogout(BuildContext context) async {
try {
await context.read<AuthenticationCubit>().logout();
await context.read<GlobalAppSettings>();
await context.read<LabelRepository>().clear();
await context.read<SavedViewRepository>().clear();
await HydratedBloc.storage.clear();
} on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace);
}
}
Widget _buildAccountTile(UserAccount account, bool isActive) {
return ListTile(
selected: isActive,
title: Text(account.username),
subtitle: Text(account.serverUrl),
leading: CircleAvatar(
child: Text((account.fullName ?? account.username)
.split(" ")
.take(2)
.map((e) => e.substring(0, 1))
.map((e) => e.toUpperCase())
.join(" ")),
),
);
}
}

View File

@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_cancel_button.dart';
import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_confirm_button.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class SwitchAccountDialog extends StatelessWidget {
final String username;
final String serverUrl;
const SwitchAccountDialog({
super.key,
required this.username,
required this.serverUrl,
});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text("Switch account"),
content: Text("Do you want to switch to $serverUrl and log in as $username?"),
actions: [
DialogConfirmButton(
style: DialogConfirmButtonStyle.danger,
label: S.of(context)!.continueLabel, //TODO: INTL change labels
),
DialogCancelButton(),
],
);
}
}

View File

@@ -0,0 +1,136 @@
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_config.dart';
import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart';
import 'package:paperless_mobile/features/login/model/login_form_credentials.dart';
import 'package:paperless_mobile/features/login/model/user_account.dart';
import 'package:paperless_mobile/features/login/view/login_page.dart';
import 'package:paperless_mobile/features/settings/model/global_settings.dart';
import 'package:paperless_mobile/features/settings/view/dialogs/switch_account_dialog.dart';
import 'package:paperless_mobile/features/settings/view/pages/switching_accounts_page.dart';
import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart';
class ManageAccountsPage extends StatelessWidget {
const ManageAccountsPage({super.key});
@override
Widget build(BuildContext context) {
return Dialog.fullscreen(
child: Scaffold(
appBar: AppBar(
leading: CloseButton(),
title: Text("Manage Accounts"), //TODO: INTL
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => LoginPage(
onSubmit: (context, username, password, serverUrl, clientCertificate) async {
final userId = await context.read<AuthenticationCubit>().addAccount(
credentials: LoginFormCredentials(
username: username,
password: password,
),
clientCertificate: clientCertificate,
serverUrl: serverUrl,
//TODO: Ask user whether to enable biometric authentication
enableBiometricAuthentication: false,
);
final shoudSwitch = await showDialog(
context: context,
builder: (context) =>
SwitchAccountDialog(username: username, serverUrl: serverUrl),
) ??
false;
if (shoudSwitch) {
context.read<AuthenticationCubit>().switchAccount(userId);
}
},
submitText: "Add account",
),
),
);
},
label: Text("Add account"),
icon: Icon(Icons.person_add),
),
body: GlobalSettingsBuilder(
builder: (context, globalSettings) {
return ValueListenableBuilder(
valueListenable: Hive.box<UserAccount>(HiveBoxes.userAccount).listenable(),
builder: (context, box, _) {
final userIds = box.keys.toList().cast<String>();
return ListView.builder(
itemBuilder: (context, index) {
return _buildAccountTile(
context,
userIds[index],
box.get(userIds[index])!,
globalSettings,
);
},
itemCount: userIds.length,
);
},
);
},
),
),
);
}
Widget _buildAccountTile(
BuildContext context, String userId, UserAccount account, GlobalSettings settings) {
final theme = Theme.of(context);
return ListTile(
selected: userId == settings.currentLoggedInUser,
title: Text(account.username),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (account.fullName != null) Text(account.fullName!),
Text(account.serverUrl),
],
),
isThreeLine: true,
leading: CircleAvatar(
child: Text((account.fullName ?? account.username)
.split(" ")
.take(2)
.map((e) => e.substring(0, 1))
.map((e) => e.toUpperCase())
.join(" ")),
),
onTap: () async {
final navigator = Navigator.of(context);
if (settings.currentLoggedInUser == userId) return;
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => SwitchingAccountsPage(),
),
);
await context.read<AuthenticationCubit>().switchAccount(userId);
navigator.popUntil((route) => route.isFirst);
},
trailing: TextButton(
child: Text(
"Remove",
style: TextStyle(
color: theme.colorScheme.error,
),
),
onPressed: () async {
final shouldPop = userId == settings.currentLoggedInUser;
await context.read<AuthenticationCubit>().removeAccount(userId);
if (shouldPop) {
Navigator.pop(context);
}
},
),
);
}
}

View File

@@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
import 'package:flutter/src/widgets/framework.dart';
import 'package:flutter/src/widgets/placeholder.dart';
class SwitchingAccountsPage extends StatelessWidget {
const SwitchingAccountsPage({super.key});
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
return false;
},
child: Material(
child: Center(
child: Text("Switching accounts. Please wait..."),
),
),
);
}
}

View File

@@ -2,12 +2,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/bloc/paperless_server_information_cubit.dart';
import 'package:paperless_mobile/core/bloc/paperless_server_information_state.dart';
import 'package:paperless_mobile/features/settings/global_app_settings.dart';
import 'package:paperless_mobile/features/settings/cubit/application_settings_cubit.dart';
import 'package:paperless_mobile/features/settings/view/pages/application_settings_page.dart';
import 'package:paperless_mobile/features/settings/view/pages/security_settings_page.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:provider/provider.dart';
class SettingsPage extends StatelessWidget {
const SettingsPage({super.key});
@@ -18,14 +15,13 @@ class SettingsPage extends StatelessWidget {
appBar: AppBar(
title: Text(S.of(context)!.settings),
),
bottomNavigationBar: BlocBuilder<PaperlessServerInformationCubit,
PaperlessServerInformationState>(
bottomNavigationBar:
BlocBuilder<PaperlessServerInformationCubit, PaperlessServerInformationState>(
builder: (context, state) {
final info = state.information!;
return ListTile(
title: Text(
S.of(context)!.loggedInAs(info.username ?? 'unknown') +
"@${info.host}",
S.of(context)!.loggedInAs(info.username ?? 'unknown') + "@${info.host}",
style: Theme.of(context).textTheme.labelSmall,
textAlign: TextAlign.center,
),

View File

@@ -3,9 +3,8 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hive_flutter/adapters.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/features/login/services/authentication_service.dart';
import 'package:paperless_mobile/features/settings/cubit/application_settings_cubit.dart';
import 'package:paperless_mobile/features/settings/global_app_settings.dart';
import 'package:paperless_mobile/features/settings/user_app_settings.dart';
import 'package:paperless_mobile/features/settings/model/global_settings.dart';
import 'package:paperless_mobile/features/settings/model/user_settings.dart';
import 'package:paperless_mobile/features/settings/view/widgets/user_settings_builder.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:provider/provider.dart';

View File

@@ -7,8 +7,7 @@ import 'package:paperless_mobile/constants.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/core/translation/color_scheme_option_localization_mapper.dart';
import 'package:paperless_mobile/core/widgets/hint_card.dart';
import 'package:paperless_mobile/features/settings/cubit/application_settings_cubit.dart';
import 'package:paperless_mobile/features/settings/global_app_settings.dart';
import 'package:paperless_mobile/features/settings/model/global_settings.dart';
import 'package:paperless_mobile/features/settings/model/color_scheme_option.dart';
import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart';
import 'package:paperless_mobile/features/settings/view/widgets/radio_settings_dialog.dart';
@@ -37,8 +36,7 @@ class ColorSchemeOptionSetting extends StatelessWidget {
options: [
RadioOption(
value: ColorSchemeOption.classic,
label: translateColorSchemeOption(
context, ColorSchemeOption.classic),
label: translateColorSchemeOption(context, ColorSchemeOption.classic),
),
RadioOption(
value: ColorSchemeOption.dynamic,
@@ -71,8 +69,7 @@ class ColorSchemeOptionSetting extends StatelessWidget {
bool _isBelowAndroid12() {
if (Platform.isAndroid) {
final int version =
int.tryParse(androidInfo!.version.release ?? '0') ?? 0;
final int version = int.tryParse(androidInfo!.version.release ?? '0') ?? 0;
return version < 12;
}
return false;

View File

@@ -3,21 +3,18 @@ import 'package:flutter/src/widgets/framework.dart';
import 'package:flutter/src/widgets/placeholder.dart';
import 'package:hive_flutter/adapters.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/features/settings/global_app_settings.dart';
import 'package:paperless_mobile/features/settings/model/global_settings.dart';
class GlobalSettingsBuilder extends StatelessWidget {
final Widget Function(BuildContext context, GlobalAppSettings settings)
builder;
final Widget Function(BuildContext context, GlobalSettings settings) builder;
const GlobalSettingsBuilder({super.key, required this.builder});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable:
Hive.box<GlobalAppSettings>(HiveBoxes.globalSettings).listenable(),
valueListenable: Hive.box<GlobalSettings>(HiveBoxes.globalSettings).listenable(),
builder: (context, value, _) {
final settings = value.get(HiveBoxSingleValueKey.value)!;
final settings = value.getValue()!;
return builder(context, settings);
},
);

View File

@@ -1,8 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hive_flutter/adapters.dart';
import 'package:paperless_mobile/features/settings/cubit/application_settings_cubit.dart';
import 'package:paperless_mobile/features/settings/global_app_settings.dart';
import 'package:paperless_mobile/features/settings/model/global_settings.dart';
import 'package:paperless_mobile/features/settings/view/widgets/radio_settings_dialog.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart';
@@ -11,8 +10,7 @@ class LanguageSelectionSetting extends StatefulWidget {
const LanguageSelectionSetting({super.key});
@override
State<LanguageSelectionSetting> createState() =>
_LanguageSelectionSettingState();
State<LanguageSelectionSetting> createState() => _LanguageSelectionSettingState();
}
class _LanguageSelectionSettingState extends State<LanguageSelectionSetting> {

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/features/settings/cubit/application_settings_cubit.dart';
import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart';
import 'package:paperless_mobile/features/settings/view/widgets/radio_settings_dialog.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
@@ -14,8 +13,7 @@ class ThemeModeSetting extends StatelessWidget {
builder: (context, settings) {
return ListTile(
title: Text(S.of(context)!.appearance),
subtitle: Text(_mapThemeModeToLocalizedString(
settings.preferredThemeMode, context)),
subtitle: Text(_mapThemeModeToLocalizedString(settings.preferredThemeMode, context)),
onTap: () => showDialog<ThemeMode>(
context: context,
builder: (_) => RadioSettingsDialog<ThemeMode>(

View File

@@ -1,13 +1,13 @@
import 'package:flutter/material.dart';
import 'package:hive_flutter/adapters.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/features/settings/global_app_settings.dart';
import 'package:paperless_mobile/features/settings/user_app_settings.dart';
import 'package:paperless_mobile/features/settings/model/global_settings.dart';
import 'package:paperless_mobile/features/settings/model/user_settings.dart';
class UserSettingsBuilder extends StatelessWidget {
final Widget Function(
BuildContext context,
UserAppSettings? settings,
UserSettings? settings,
) builder;
const UserSettingsBuilder({
@@ -17,14 +17,11 @@ class UserSettingsBuilder extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<Box<UserAppSettings>>(
valueListenable:
Hive.box<UserAppSettings>(HiveBoxes.userSettings).listenable(),
return ValueListenableBuilder<Box<UserSettings>>(
valueListenable: Hive.box<UserSettings>(HiveBoxes.userSettings).listenable(),
builder: (context, value, _) {
final currentUser =
Hive.box<GlobalAppSettings>(HiveBoxes.globalSettings)
.get(HiveBoxSingleValueKey.value)
?.currentLoggedInUser;
Hive.box<GlobalSettings>(HiveBoxes.globalSettings).getValue()!.currentLoggedInUser;
if (currentUser != null) {
final settings = value.get(currentUser);
return builder(context, settings);