diff --git a/lib/core/interceptor/dio_http_error_interceptor.dart b/lib/core/interceptor/dio_http_error_interceptor.dart index 2d6a0ba..6c8fb1b 100644 --- a/lib/core/interceptor/dio_http_error_interceptor.dart +++ b/lib/core/interceptor/dio_http_error_interceptor.dart @@ -39,8 +39,6 @@ class DioHttpErrorInterceptor extends Interceptor { ), ); } - } else { - return handler.next(err); } } } diff --git a/lib/core/model/info_message_exception.dart b/lib/core/model/info_message_exception.dart new file mode 100644 index 0000000..817954e --- /dev/null +++ b/lib/core/model/info_message_exception.dart @@ -0,0 +1,12 @@ +import 'package:paperless_api/paperless_api.dart'; + +class InfoMessageException implements Exception { + final ErrorCode code; + final String? message; + final StackTrace? stackTrace; + InfoMessageException({ + required this.code, + this.message, + this.stackTrace, + }); +} diff --git a/lib/core/navigation/push_routes.dart b/lib/core/navigation/push_routes.dart index 3bd90c0..0dfad7c 100644 --- a/lib/core/navigation/push_routes.dart +++ b/lib/core/navigation/push_routes.dart @@ -43,6 +43,7 @@ Future pushSavedViewDetailsRoute( context.read(), context.read(), LocalUserAppState.current, + context.read(), savedView: savedView, ), child: SavedViewDetailsPage( diff --git a/lib/core/security/session_manager.dart b/lib/core/security/session_manager.dart index 8281f2a..8f2aba3 100644 --- a/lib/core/security/session_manager.dart +++ b/lib/core/security/session_manager.dart @@ -4,6 +4,7 @@ import 'package:dio/dio.dart'; import 'package:dio/io.dart'; import 'package:flutter/material.dart'; import 'package:paperless_mobile/core/interceptor/dio_http_error_interceptor.dart'; +import 'package:paperless_mobile/core/interceptor/dio_offline_interceptor.dart'; import 'package:paperless_mobile/core/interceptor/dio_unauthorized_interceptor.dart'; import 'package:paperless_mobile/core/interceptor/retry_on_connection_change_interceptor.dart'; import 'package:paperless_mobile/features/login/model/client_certificate.dart'; @@ -37,6 +38,7 @@ class SessionManager extends ValueNotifier { ...interceptors, DioUnauthorizedInterceptor(), DioHttpErrorInterceptor(), + DioOfflineInterceptor(), PrettyDioLogger( compact: true, responseBody: false, diff --git a/lib/core/service/connectivity_status_service.dart b/lib/core/service/connectivity_status_service.dart index 0908e48..6ce404b 100644 --- a/lib/core/service/connectivity_status_service.dart +++ b/lib/core/service/connectivity_status_service.dart @@ -7,6 +7,7 @@ import 'package:paperless_mobile/core/interceptor/server_reachability_error_inte import 'package:paperless_mobile/core/security/session_manager.dart'; import 'package:paperless_mobile/features/login/model/client_certificate.dart'; import 'package:paperless_mobile/features/login/model/reachability_status.dart'; +import 'package:rxdart/subjects.dart'; abstract class ConnectivityStatusService { Future isConnectedToInternet(); @@ -20,14 +21,19 @@ abstract class ConnectivityStatusService { class ConnectivityStatusServiceImpl implements ConnectivityStatusService { final Connectivity _connectivity; + final BehaviorSubject _connectivityState$ = BehaviorSubject(); - ConnectivityStatusServiceImpl(this._connectivity); + ConnectivityStatusServiceImpl(this._connectivity) { + _connectivityState$.addStream( + _connectivity.onConnectivityChanged + .map(_hasActiveInternetConnection) + .asBroadcastStream(), + ); + } @override Stream connectivityChanges() { - return _connectivity.onConnectivityChanged - .map(_hasActiveInternetConnection) - .asBroadcastStream(); + return _connectivityState$.asBroadcastStream(); } @override @@ -98,3 +104,31 @@ class ConnectivityStatusServiceImpl implements ConnectivityStatusService { return ReachabilityStatus.notReachable; } } + +class ConnectivityStatusServiceMock implements ConnectivityStatusService { + final bool isConnected; + + ConnectivityStatusServiceMock(this.isConnected); + @override + Stream connectivityChanges() { + return Stream.value(isConnected); + } + + @override + Future isConnectedToInternet() async { + return isConnected; + } + + @override + Future isPaperlessServerReachable(String serverAddress, + [ClientCertificate? clientCertificate]) async { + return isConnected + ? ReachabilityStatus.reachable + : ReachabilityStatus.notReachable; + } + + @override + Future isServerReachable(String serverAddress) async { + return isConnected; + } +} diff --git a/lib/core/service/file_service.dart b/lib/core/service/file_service.dart index 881452d..00145a8 100644 --- a/lib/core/service/file_service.dart +++ b/lib/core/service/file_service.dart @@ -25,7 +25,7 @@ class FileService { case PaperlessDirectoryType.temporary: return temporaryDirectory; case PaperlessDirectoryType.scans: - return scanDirectory; + return temporaryScansDirectory; case PaperlessDirectoryType.download: return downloadsDirectory; } @@ -52,8 +52,7 @@ class FileService { } else if (Platform.isIOS) { final appDir = await getApplicationDocumentsDirectory(); final dir = Directory('${appDir.path}/documents'); - dir.createSync(); - return dir; + return dir.create(recursive: true); } else { throw UnsupportedError("Platform not supported."); } @@ -72,33 +71,22 @@ class FileService { } else if (Platform.isIOS) { final appDir = await getApplicationDocumentsDirectory(); final dir = Directory('${appDir.path}/downloads'); - dir.createSync(); - return dir; + return dir.create(recursive: true); } else { throw UnsupportedError("Platform not supported."); } } - static Future get scanDirectory async { - if (Platform.isAndroid) { - final scanDir = await getExternalStorageDirectories( - type: StorageDirectory.dcim, - ); - return scanDir!.first; - } else if (Platform.isIOS) { - final appDir = await getApplicationDocumentsDirectory(); - final dir = Directory('${appDir.path}/scans'); - dir.createSync(); - return dir; - } else { - throw UnsupportedError("Platform not supported."); - } + static Future get temporaryScansDirectory async { + final tempDir = await temporaryDirectory; + final scansDir = Directory('${tempDir.path}/scans'); + return scansDir.create(recursive: true); } static Future clearUserData() async { - final scanDir = await scanDirectory; + final scanDir = await temporaryScansDirectory; final tempDir = await temporaryDirectory; - await scanDir?.delete(recursive: true); + await scanDir.delete(recursive: true); await tempDir.delete(recursive: true); } diff --git a/lib/core/translation/error_code_localization_mapper.dart b/lib/core/translation/error_code_localization_mapper.dart index c720d9e..ac1204e 100644 --- a/lib/core/translation/error_code_localization_mapper.dart +++ b/lib/core/translation/error_code_localization_mapper.dart @@ -75,5 +75,6 @@ String translateError(BuildContext context, ErrorCode code) { ErrorCode.loadTasksError => S.of(context)!.couldNotLoadTasks, ErrorCode.userNotFound => S.of(context)!.userNotFound, ErrorCode.updateSavedViewError => S.of(context)!.couldNotUpdateSavedView, + ErrorCode.userAlreadyExists => S.of(context)!.userAlreadyExists, }; } diff --git a/lib/features/document_details/view/pages/document_details_page.dart b/lib/features/document_details/view/pages/document_details_page.dart index aece9c1..b3a1d19 100644 --- a/lib/features/document_details/view/pages/document_details_page.dart +++ b/lib/features/document_details/view/pages/document_details_page.dart @@ -1,3 +1,4 @@ +import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -20,6 +21,7 @@ import 'package:paperless_mobile/features/documents/view/widgets/document_previe import 'package:paperless_mobile/features/similar_documents/cubit/similar_documents_cubit.dart'; import 'package:paperless_mobile/features/similar_documents/view/similar_documents_view.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; @@ -199,6 +201,7 @@ class _DocumentDetailsPageState extends State { context.read(), context.read(), context.read(), + context.read(), documentId: state.document.id, ), child: Padding( @@ -322,28 +325,45 @@ class _DocumentDetailsPageState extends State { return BottomAppBar( child: BlocBuilder( builder: (context, connectivityState) { - final isConnected = connectivityState.isConnected; final currentUser = context.watch(); - final canDelete = - isConnected && currentUser.paperlessUser.canDeleteDocuments; return Row( mainAxisAlignment: MainAxisAlignment.start, children: [ - IconButton( - tooltip: S.of(context)!.deleteDocumentTooltip, - icon: const Icon(Icons.delete), - onPressed: - canDelete ? () => _onDelete(state.document) : null, - ).paddedSymmetrically(horizontal: 4), - DocumentDownloadButton( - document: state.document, - enabled: isConnected, + ConnectivityAwareActionWrapper( + disabled: !currentUser.paperlessUser.canDeleteDocuments, + offlineBuilder: (context, child) { + return const IconButton( + icon: Icon(Icons.delete), + onPressed: null, + ).paddedSymmetrically(horizontal: 4); + }, + child: IconButton( + tooltip: S.of(context)!.deleteDocumentTooltip, + icon: const Icon(Icons.delete), + onPressed: () => _onDelete(state.document), + ).paddedSymmetrically(horizontal: 4), + ), + ConnectivityAwareActionWrapper( + offlineBuilder: (context, child) => + const DocumentDownloadButton( + document: null, + enabled: false, + ), + child: DocumentDownloadButton( + document: state.document, + ), + ), + ConnectivityAwareActionWrapper( + offlineBuilder: (context, child) => const IconButton( + icon: Icon(Icons.open_in_new), + onPressed: null, + ), + child: IconButton( + tooltip: S.of(context)!.openInSystemViewer, + icon: const Icon(Icons.open_in_new), + onPressed: _onOpenFileInSystemViewer, + ).paddedOnly(right: 4.0), ), - IconButton( - tooltip: S.of(context)!.openInSystemViewer, - icon: const Icon(Icons.open_in_new), - onPressed: isConnected ? _onOpenFileInSystemViewer : null, - ).paddedOnly(right: 4.0), DocumentShareButton(document: state.document), IconButton( tooltip: S.of(context)!.print, diff --git a/lib/features/document_details/view/widgets/document_share_button.dart b/lib/features/document_details/view/widgets/document_share_button.dart index 257ac6b..aaeb0c0 100644 --- a/lib/features/document_details/view/widgets/document_share_button.dart +++ b/lib/features/document_details/view/widgets/document_share_button.dart @@ -11,6 +11,7 @@ import 'package:paperless_mobile/features/document_details/cubit/document_detail import 'package:paperless_mobile/features/document_details/view/dialogs/select_file_type_dialog.dart'; import 'package:paperless_mobile/features/settings/model/file_download_type.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:paperless_mobile/helpers/permission_helpers.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -34,19 +35,25 @@ class _DocumentShareButtonState extends State { @override Widget build(BuildContext context) { - return IconButton( - tooltip: S.of(context)!.shareTooltip, - icon: _isDownloadPending - ? const SizedBox( - height: 16, - width: 16, - child: CircularProgressIndicator(), - ) - : const Icon(Icons.share), - onPressed: widget.document != null && widget.enabled - ? () => _onShare(widget.document!) - : null, - ).paddedOnly(right: 4); + return ConnectivityAwareActionWrapper( + offlineBuilder: (context, child) => const IconButton( + icon: Icon(Icons.share), + onPressed: null, + ), + child: IconButton( + tooltip: S.of(context)!.shareTooltip, + icon: _isDownloadPending + ? const SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator(), + ) + : const Icon(Icons.share), + onPressed: widget.document != null && widget.enabled + ? () => _onShare(widget.document!) + : null, + ).paddedOnly(right: 4), + ); } Future _onShare(DocumentModel document) async { diff --git a/lib/features/document_scan/cubit/document_scanner_cubit.dart b/lib/features/document_scan/cubit/document_scanner_cubit.dart index 3e0b9a5..de54d25 100644 --- a/lib/features/document_scan/cubit/document_scanner_cubit.dart +++ b/lib/features/document_scan/cubit/document_scanner_cubit.dart @@ -1,43 +1,71 @@ -import 'dart:developer'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/model/info_message_exception.dart'; import 'package:paperless_mobile/core/service/file_service.dart'; import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; +import 'package:rxdart/rxdart.dart'; -class DocumentScannerCubit extends Cubit> { +part 'document_scanner_state.dart'; + +class DocumentScannerCubit extends Cubit { final LocalNotificationService _notificationService; - DocumentScannerCubit(this._notificationService) : super(const []); + DocumentScannerCubit(this._notificationService) + : super(const InitialDocumentScannerState()); - void addScan(File file) => emit([...state, file]); - - void removeScan(int fileIndex) { - try { - state[fileIndex].deleteSync(); - final scans = [...state]; - scans.removeAt(fileIndex); - emit(scans); - } catch (_) { - throw const PaperlessApiException(ErrorCode.scanRemoveFailed); - } + Future initialize() async { + debugPrint("Restoring scans..."); + emit(const RestoringDocumentScannerState()); + final tempDir = await FileService.temporaryScansDirectory; + final allFiles = tempDir.list().whereType(); + final scans = + await allFiles.where((event) => event.path.endsWith(".jpeg")).toList(); + debugPrint("Restored ${scans.length} scans."); + emit( + scans.isEmpty + ? const InitialDocumentScannerState() + : LoadedDocumentScannerState(scans: scans), + ); } - void reset() { + void addScan(File file) async { + emit(LoadedDocumentScannerState( + scans: [...state.scans, file], + )); + } + + Future removeScan(File file) async { try { - for (final doc in state) { - doc.deleteSync(); - if (kDebugMode) { - log('[ScannerCubit]: Removed ${doc.path}'); - } - } + await file.delete(); + } catch (error, stackTrace) { + throw InfoMessageException( + code: ErrorCode.scanRemoveFailed, + message: error.toString(), + stackTrace: stackTrace, + ); + } + final scans = state.scans..remove(file); + emit( + scans.isEmpty + ? const InitialDocumentScannerState() + : LoadedDocumentScannerState(scans: scans), + ); + } + + Future reset() async { + try { + Future.wait([ + for (final file in state.scans) file.delete(), + ]); imageCache.clear(); - emit([]); } catch (_) { throw const PaperlessApiException(ErrorCode.scanRemoveFailed); + } finally { + emit(const InitialDocumentScannerState()); } } diff --git a/lib/features/document_scan/cubit/document_scanner_state.dart b/lib/features/document_scan/cubit/document_scanner_state.dart new file mode 100644 index 0000000..70f7b33 --- /dev/null +++ b/lib/features/document_scan/cubit/document_scanner_state.dart @@ -0,0 +1,30 @@ +part of 'document_scanner_cubit.dart'; + +sealed class DocumentScannerState { + final List scans; + + const DocumentScannerState({ + this.scans = const [], + }); +} + +class InitialDocumentScannerState extends DocumentScannerState { + const InitialDocumentScannerState(); +} + +class RestoringDocumentScannerState extends DocumentScannerState { + const RestoringDocumentScannerState({super.scans}); +} + +class LoadedDocumentScannerState extends DocumentScannerState { + const LoadedDocumentScannerState({super.scans}); +} + +class ErrorDocumentScannerState extends DocumentScannerState { + final String message; + + const ErrorDocumentScannerState({ + required this.message, + super.scans, + }); +} diff --git a/lib/features/document_scan/view/scanner_page.dart b/lib/features/document_scan/view/scanner_page.dart index 760e7b5..4ea62ca 100644 --- a/lib/features/document_scan/view/scanner_page.dart +++ b/lib/features/document_scan/view/scanner_page.dart @@ -10,7 +10,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:hive/hive.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/constants.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/database/tables/global_settings.dart'; import 'package:paperless_mobile/core/global/constants.dart'; @@ -25,6 +24,7 @@ import 'package:paperless_mobile/features/document_upload/view/document_upload_p import 'package:paperless_mobile/features/documents/view/pages/document_view.dart'; import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:paperless_mobile/helpers/permission_helpers.dart'; import 'package:paperless_mobile/routes/typed/branches/scanner_route.dart'; @@ -52,66 +52,54 @@ class _ScannerPageState extends State @override Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, connectedState) { - return BlocBuilder>( - builder: (context, state) { - return SafeArea( - top: true, - child: Scaffold( - drawer: const AppDrawer(), - floatingActionButton: FloatingActionButton( - heroTag: "fab_document_edit", - onPressed: () => _openDocumentScanner(context), - child: const Icon(Icons.add_a_photo_outlined), - ), - body: NestedScrollView( - floatHeaderSlivers: true, - headerSliverBuilder: (context, innerBoxIsScrolled) => [ - SliverOverlapAbsorber( - handle: searchBarHandle, - sliver: SliverSearchBar( - titleText: S.of(context)!.scanner, - ), - ), - SliverOverlapAbsorber( - handle: actionsHandle, - sliver: SliverPinnedHeader( - child: _buildActions(connectedState.isConnected), - ), - ), - ], - body: BlocBuilder>( - builder: (context, state) { - if (state.isEmpty) { - return SizedBox.expand( - child: Center( - child: _buildEmptyState( - connectedState.isConnected, - state, - ), - ), - ); - } else { - return _buildImageGrid(state); - } - }, - ), - ), + return SafeArea( + top: true, + child: Scaffold( + drawer: const AppDrawer(), + floatingActionButton: FloatingActionButton( + heroTag: "fab_document_edit", + onPressed: () => _openDocumentScanner(context), + child: const Icon(Icons.add_a_photo_outlined), + ), + body: NestedScrollView( + floatHeaderSlivers: true, + headerSliverBuilder: (context, innerBoxIsScrolled) => [ + SliverOverlapAbsorber( + handle: searchBarHandle, + sliver: SliverSearchBar( + titleText: S.of(context)!.scanner, ), - ); - }, - ); - }, + ), + SliverOverlapAbsorber( + handle: actionsHandle, + sliver: SliverPinnedHeader( + child: _buildActions(), + ), + ), + ], + body: BlocBuilder( + builder: (context, state) { + return switch (state) { + InitialDocumentScannerState() => _buildEmptyState(), + RestoringDocumentScannerState() => Center( + child: Text("Restoring..."), + ), + LoadedDocumentScannerState() => _buildImageGrid(state.scans), + ErrorDocumentScannerState() => Placeholder(), + }; + }, + ), + ), + ), ); } - Widget _buildActions(bool isConnected) { + Widget _buildActions() { return ColoredBox( color: Theme.of(context).colorScheme.background, child: SizedBox( height: kTextTabBarHeight, - child: BlocBuilder>( + child: BlocBuilder( builder: (context, state) { return RawScrollbar( padding: EdgeInsets.fromLTRB(16, 0, 16, 4), @@ -130,12 +118,12 @@ class _ScannerPageState extends State style: TextButton.styleFrom( padding: const EdgeInsets.fromLTRB(5, 10, 5, 10), ), - onPressed: state.isNotEmpty + onPressed: state.scans.isNotEmpty ? () => Navigator.of(context).push( MaterialPageRoute( builder: (context) => DocumentView( documentBytes: _assembleFileBytes( - state, + state.scans, forcePdf: true, ).then((file) => file.bytes), ), @@ -150,19 +138,32 @@ class _ScannerPageState extends State style: TextButton.styleFrom( padding: const EdgeInsets.fromLTRB(5, 10, 5, 10), ), - onPressed: state.isEmpty ? null : () => _reset(context), + onPressed: + state.scans.isEmpty ? null : () => _reset(context), icon: const Icon(Icons.delete_sweep_outlined), ), SizedBox(width: 8), - TextButton.icon( - label: Text(S.of(context)!.upload), - style: TextButton.styleFrom( - padding: const EdgeInsets.fromLTRB(5, 10, 5, 10), + ConnectivityAwareActionWrapper( + offlineBuilder: (context, child) { + return TextButton.icon( + label: Text(S.of(context)!.upload), + style: TextButton.styleFrom( + padding: const EdgeInsets.fromLTRB(5, 10, 5, 10), + ), + onPressed: null, + icon: const Icon(Icons.upload_outlined), + ); + }, + disabled: state.scans.isEmpty, + child: TextButton.icon( + label: Text(S.of(context)!.upload), + style: TextButton.styleFrom( + padding: const EdgeInsets.fromLTRB(5, 10, 5, 10), + ), + onPressed: () => + _onPrepareDocumentUpload(context, state.scans), + icon: const Icon(Icons.upload_outlined), ), - onPressed: state.isEmpty || !isConnected - ? null - : () => _onPrepareDocumentUpload(context), - icon: const Icon(Icons.upload_outlined), ), SizedBox(width: 8), TextButton.icon( @@ -170,7 +171,7 @@ class _ScannerPageState extends State style: TextButton.styleFrom( padding: const EdgeInsets.fromLTRB(5, 10, 5, 10), ), - onPressed: state.isEmpty ? null : _onSaveToFile, + onPressed: state.scans.isEmpty ? null : _onSaveToFile, icon: const Icon(Icons.save_alt_outlined), ), SizedBox(width: 12), @@ -192,7 +193,7 @@ class _ScannerPageState extends State final cubit = context.read(); final file = await _assembleFileBytes( forcePdf: true, - context.read().state, + context.read().state.scans, ); try { final globalSettings = @@ -249,9 +250,9 @@ class _ScannerPageState extends State context.read().addScan(file); } - void _onPrepareDocumentUpload(BuildContext context) async { + void _onPrepareDocumentUpload(BuildContext context, List scans) async { final file = await _assembleFileBytes( - context.read().state, + scans, forcePdf: Hive.box(HiveBoxes.globalSettings) .getValue()! .enforceSinglePagePdfUpload, @@ -269,10 +270,7 @@ class _ScannerPageState extends State } } - Widget _buildEmptyState(bool isConnected, List scans) { - if (scans.isNotEmpty) { - return _buildImageGrid(scans); - } + Widget _buildEmptyState() { return Center( child: Padding( padding: const EdgeInsets.all(8.0), @@ -288,9 +286,15 @@ class _ScannerPageState extends State onPressed: () => _openDocumentScanner(context), ), Text(S.of(context)!.or), - TextButton( - child: Text(S.of(context)!.uploadADocumentFromThisDevice), - onPressed: isConnected ? _onUploadFromFilesystem : null, + ConnectivityAwareActionWrapper( + offlineBuilder: (context, child) => TextButton( + child: Text(S.of(context)!.uploadADocumentFromThisDevice), + onPressed: null, + ), + child: TextButton( + child: Text(S.of(context)!.uploadADocumentFromThisDevice), + onPressed: _onUploadFromFilesystem, + ), ), ], ), @@ -318,7 +322,9 @@ class _ScannerPageState extends State file: scans[index], onDelete: () async { try { - context.read().removeScan(index); + context + .read() + .removeScan(scans[index]); } on PaperlessApiException catch (error, stackTrace) { showErrorMessage(context, error, stackTrace); } diff --git a/lib/features/document_search/cubit/document_search_cubit.dart b/lib/features/document_search/cubit/document_search_cubit.dart index b8ee125..09e4ffc 100644 --- a/lib/features/document_search/cubit/document_search_cubit.dart +++ b/lib/features/document_search/cubit/document_search_cubit.dart @@ -4,6 +4,7 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; +import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; import 'package:paperless_mobile/features/paged_document_view/cubit/document_paging_bloc_mixin.dart'; import 'package:paperless_mobile/features/paged_document_view/cubit/paged_documents_state.dart'; import 'package:paperless_mobile/features/settings/model/view_type.dart'; @@ -119,4 +120,9 @@ class DocumentSearchCubit extends Cubit @override Future onFilterUpdated(DocumentFilter filter) async {} + + @override + // TODO: implement connectivityStatusService + ConnectivityStatusService get connectivityStatusService => + throw UnimplementedError(); } diff --git a/lib/features/document_upload/cubit/document_upload_cubit.dart b/lib/features/document_upload/cubit/document_upload_cubit.dart index ae275a5..e1d5bec 100644 --- a/lib/features/document_upload/cubit/document_upload_cubit.dart +++ b/lib/features/document_upload/cubit/document_upload_cubit.dart @@ -1,11 +1,11 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; -import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; +import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; part 'document_upload_state.dart'; @@ -13,12 +13,12 @@ class DocumentUploadCubit extends Cubit { final PaperlessDocumentsApi _documentApi; final LabelRepository _labelRepository; - final Connectivity _connectivity; + final ConnectivityStatusService _connectivityStatusService; DocumentUploadCubit( this._labelRepository, this._documentApi, - this._connectivity, + this._connectivityStatusService, ) : super(const DocumentUploadState()) { _labelRepository.addListener( this, diff --git a/lib/features/documents/cubit/documents_cubit.dart b/lib/features/documents/cubit/documents_cubit.dart index 218b8a6..da1add1 100644 --- a/lib/features/documents/cubit/documents_cubit.dart +++ b/lib/features/documents/cubit/documents_cubit.dart @@ -7,6 +7,7 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; +import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; import 'package:paperless_mobile/features/paged_document_view/cubit/document_paging_bloc_mixin.dart'; import 'package:paperless_mobile/features/paged_document_view/cubit/paged_documents_state.dart'; import 'package:paperless_mobile/features/settings/model/view_type.dart'; @@ -20,6 +21,8 @@ class DocumentsCubit extends HydratedCubit final PaperlessDocumentsApi api; final LabelRepository _labelRepository; + @override + final ConnectivityStatusService connectivityStatusService; @override final DocumentChangedNotifier notifier; @@ -31,6 +34,7 @@ class DocumentsCubit extends HydratedCubit this.notifier, this._labelRepository, this._userState, + this.connectivityStatusService, ) : super(DocumentsState( filter: _userState.currentDocumentFilter, viewType: _userState.documentsPageViewType, diff --git a/lib/features/documents/view/pages/documents_page.dart b/lib/features/documents/view/pages/documents_page.dart index 9c1df84..d506060 100644 --- a/lib/features/documents/view/pages/documents_page.dart +++ b/lib/features/documents/view/pages/documents_page.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; +import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/app_drawer/view/app_drawer.dart'; import 'package:paperless_mobile/features/document_search/view/sliver_search_bar.dart'; @@ -333,12 +334,16 @@ class _DocumentsPageState extends State { slivers: [ SliverOverlapInjector(handle: searchBarHandle), SliverOverlapInjector(handle: savedViewsHandle), - BlocBuilder( - buildWhen: (previous, current) => - previous.filter != current.filter, - builder: (context, state) { - return SliverToBoxAdapter( - child: SavedViewsWidget( + SliverToBoxAdapter( + child: BlocBuilder( + buildWhen: (previous, current) => + previous.filter != current.filter, + builder: (context, state) { + final currentUser = context.watch(); + if (!currentUser.paperlessUser.canViewSavedViews) { + return const SizedBox.shrink(); + } + return SavedViewsWidget( controller: _savedViewsExpansionController, onViewSelected: (view) { final cubit = context.read(); @@ -372,9 +377,9 @@ class _DocumentsPageState extends State { } }, filter: state.filter, - ), - ); - }, + ); + }, + ), ), BlocBuilder( builder: (context, state) { diff --git a/lib/features/documents/view/widgets/document_preview.dart b/lib/features/documents/view/widgets/document_preview.dart index 576d04d..f99a139 100644 --- a/lib/features/documents/view/widgets/document_preview.dart +++ b/lib/features/documents/view/widgets/document_preview.dart @@ -2,6 +2,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart'; import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; import 'package:provider/provider.dart'; import 'package:shimmer/shimmer.dart'; @@ -28,17 +29,17 @@ class DocumentPreview extends StatelessWidget { @override Widget build(BuildContext context) { - return GestureDetector( - onTap: isClickable - ? () { - DocumentPreviewRoute($extra: document).push(context); - } - : null, - child: HeroMode( - enabled: enableHero, - child: Hero( - tag: "thumb_${document.id}", - child: _buildPreview(context), + return ConnectivityAwareActionWrapper( + child: GestureDetector( + onTap: isClickable + ? () => DocumentPreviewRoute($extra: document).push(context) + : null, + child: HeroMode( + enabled: enableHero, + child: Hero( + tag: "thumb_${document.id}", + child: _buildPreview(context), + ), ), ), ); diff --git a/lib/features/documents/view/widgets/saved_views/saved_views_widget.dart b/lib/features/documents/view/widgets/saved_views/saved_views_widget.dart index 3bf926f..0480d17 100644 --- a/lib/features/documents/view/widgets/saved_views/saved_views_widget.dart +++ b/lib/features/documents/view/widgets/saved_views/saved_views_widget.dart @@ -6,6 +6,7 @@ import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/documents/view/widgets/saved_views/saved_view_chip.dart'; import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart'; import 'package:paperless_mobile/routes/typed/branches/saved_views_route.dart'; class SavedViewsWidget extends StatefulWidget { @@ -146,15 +147,17 @@ class _SavedViewsWidgetState extends State final isSelected = (widget.filter.selectedView ?? -1) == view.id; - return SavedViewChip( - view: view, - onViewSelected: widget.onViewSelected, - selected: isSelected, - hasChanged: isSelected && - view.toDocumentFilter() != - widget.filter, - onUpdateView: widget.onUpdateView, - onDeleteView: widget.onDeleteView, + return ConnectivityAwareActionWrapper( + child: SavedViewChip( + view: view, + onViewSelected: widget.onViewSelected, + selected: isSelected, + hasChanged: isSelected && + view.toDocumentFilter() != + widget.filter, + onUpdateView: widget.onUpdateView, + onDeleteView: widget.onDeleteView, + ), ); }, separatorBuilder: (context, index) => @@ -178,12 +181,14 @@ class _SavedViewsWidgetState extends State alignment: Alignment.centerRight, child: Tooltip( message: S.of(context)!.createFromCurrentFilter, - child: TextButton.icon( - onPressed: () { - CreateSavedViewRoute(widget.filter).push(context); - }, - icon: const Icon(Icons.add), - label: Text(S.of(context)!.newView), + child: ConnectivityAwareActionWrapper( + child: TextButton.icon( + onPressed: () { + CreateSavedViewRoute(widget.filter).push(context); + }, + icon: const Icon(Icons.add), + label: Text(S.of(context)!.newView), + ), ), ).padded(4), ), diff --git a/lib/features/documents/view/widgets/search/document_filter_panel.dart b/lib/features/documents/view/widgets/search/document_filter_panel.dart index 8cdb5af..191d922 100644 --- a/lib/features/documents/view/widgets/search/document_filter_panel.dart +++ b/lib/features/documents/view/widgets/search/document_filter_panel.dart @@ -6,6 +6,7 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/features/documents/view/pages/documents_page.dart'; import 'package:paperless_mobile/features/documents/view/widgets/search/document_filter_form.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart'; enum DateRangeSelection { before, after } diff --git a/lib/features/documents/view/widgets/sort_documents_button.dart b/lib/features/documents/view/widgets/sort_documents_button.dart index 61b15a6..ec94f63 100644 --- a/lib/features/documents/view/widgets/sort_documents_button.dart +++ b/lib/features/documents/view/widgets/sort_documents_button.dart @@ -5,6 +5,7 @@ import 'package:paperless_mobile/core/translation/sort_field_localization_mapper import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart'; import 'package:paperless_mobile/features/documents/view/widgets/search/sort_field_selection_bottom_sheet.dart'; import 'package:paperless_mobile/features/labels/cubit/label_cubit.dart'; +import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart'; class SortDocumentsButton extends StatelessWidget { final bool enabled; @@ -20,55 +21,65 @@ class SortDocumentsButton extends StatelessWidget { if (state.filter.sortField == null) { return const SizedBox.shrink(); } - print(state.filter.sortField); - return TextButton.icon( - icon: Icon(state.filter.sortOrder == SortOrder.ascending - ? Icons.arrow_upward - : Icons.arrow_downward), - label: Text(translateSortField(context, state.filter.sortField)), - onPressed: enabled - ? () { - showModalBottomSheet( - elevation: 2, - context: context, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16), - ), - ), - builder: (_) => BlocProvider.value( - value: context.read(), - child: MultiBlocProvider( - providers: [ - BlocProvider( - create: (context) => LabelCubit(context.read()), - ), - ], - child: SortFieldSelectionBottomSheet( - initialSortField: state.filter.sortField, - initialSortOrder: state.filter.sortOrder, - onSubmit: (field, order) { - return context - .read() - .updateCurrentFilter( - (filter) => filter.copyWith( - sortField: field, - sortOrder: order, - ), - ); - }, - correspondents: state.correspondents, - documentTypes: state.documentTypes, - storagePaths: state.storagePaths, - tags: state.tags, + final icon = Icon(state.filter.sortOrder == SortOrder.ascending + ? Icons.arrow_upward + : Icons.arrow_downward); + final label = Text(translateSortField(context, state.filter.sortField)); + return ConnectivityAwareActionWrapper( + offlineBuilder: (context, child) { + return TextButton.icon( + icon: icon, + label: label, + onPressed: null, + ); + }, + child: TextButton.icon( + icon: icon, + label: label, + onPressed: enabled + ? () { + showModalBottomSheet( + elevation: 2, + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), ), ), - ), - ); - } - : null, + builder: (_) => BlocProvider.value( + value: context.read(), + child: MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => LabelCubit(context.read()), + ), + ], + child: SortFieldSelectionBottomSheet( + initialSortField: state.filter.sortField, + initialSortOrder: state.filter.sortOrder, + onSubmit: (field, order) { + return context + .read() + .updateCurrentFilter( + (filter) => filter.copyWith( + sortField: field, + sortOrder: order, + ), + ); + }, + correspondents: state.correspondents, + documentTypes: state.documentTypes, + storagePaths: state.storagePaths, + tags: state.tags, + ), + ), + ), + ); + } + : null, + ), ); }, ); diff --git a/lib/features/home/view/home_shell_widget.dart b/lib/features/home/view/home_shell_widget.dart index 2419878..e73e302 100644 --- a/lib/features/home/view/home_shell_widget.dart +++ b/lib/features/home/view/home_shell_widget.dart @@ -160,11 +160,13 @@ class HomeShellWidget extends StatelessWidget { Hive.box( HiveBoxes.localUserAppState) .get(currentUserId)!, + context.read(), )..initialize(), ), Provider( create: (context) => - DocumentScannerCubit(context.read()), + DocumentScannerCubit(context.read()) + ..initialize(), ), Provider( create: (context) { @@ -173,6 +175,7 @@ class HomeShellWidget extends StatelessWidget { context.read(), context.read(), context.read(), + context.read(), ); if (currentLocalUser .paperlessUser.canViewDocuments && diff --git a/lib/features/inbox/cubit/inbox_cubit.dart b/lib/features/inbox/cubit/inbox_cubit.dart index 3356fd5..5bd59a2 100644 --- a/lib/features/inbox/cubit/inbox_cubit.dart +++ b/lib/features/inbox/cubit/inbox_cubit.dart @@ -7,6 +7,7 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/repository/label_repository_state.dart'; +import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; import 'package:paperless_mobile/features/paged_document_view/cubit/paged_documents_state.dart'; import 'package:paperless_mobile/features/paged_document_view/cubit/document_paging_bloc_mixin.dart'; @@ -18,7 +19,8 @@ class InboxCubit extends HydratedCubit final LabelRepository _labelRepository; final PaperlessDocumentsApi _documentsApi; - + @override + final ConnectivityStatusService connectivityStatusService; @override final DocumentChangedNotifier notifier; @@ -32,6 +34,7 @@ class InboxCubit extends HydratedCubit this._statsApi, this._labelRepository, this.notifier, + this.connectivityStatusService, ) : super(InboxState( labels: _labelRepository.state, )) { diff --git a/lib/features/inbox/view/pages/inbox_page.dart b/lib/features/inbox/view/pages/inbox_page.dart index ef65bfa..02784f3 100644 --- a/lib/features/inbox/view/pages/inbox_page.dart +++ b/lib/features/inbox/view/pages/inbox_page.dart @@ -5,6 +5,7 @@ import 'package:intl/intl.dart'; import 'package:paperless_api/paperless_api.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/service/connectivity_status_service.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/core/widgets/hint_card.dart'; @@ -17,6 +18,7 @@ import 'package:paperless_mobile/features/inbox/view/widgets/inbox_empty_widget. import 'package:paperless_mobile/features/inbox/view/widgets/inbox_item.dart'; import 'package:paperless_mobile/features/paged_document_view/view/document_paging_view_mixin.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; class InboxPage extends StatefulWidget { @@ -74,45 +76,50 @@ class _InboxPageState extends State context.watch().paperlessUser.canEditDocuments; return Scaffold( drawer: const AppDrawer(), - floatingActionButton: BlocBuilder( - builder: (context, state) { - if (!state.hasLoaded || state.documents.isEmpty || !canEditDocument) { - return const SizedBox.shrink(); - } - return FloatingActionButton.extended( - extendedPadding: _showExtendedFab - ? null - : const EdgeInsets.symmetric(horizontal: 16), - heroTag: "inbox_page_fab", - label: AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - transitionBuilder: (child, animation) { - return FadeTransition( - opacity: animation, - child: SizeTransition( - sizeFactor: animation, - axis: Axis.horizontal, - child: child, - ), - ); - }, - child: _showExtendedFab - ? Row( - children: [ - const Icon(Icons.done_all), - Text(S.of(context)!.allSeen), - ], - ) - : const Icon(Icons.done_all), - ), - onPressed: state.hasLoaded && state.documents.isNotEmpty - ? () => _onMarkAllAsSeen( - state.documents, - state.inboxTags, - ) - : null, - ); - }, + floatingActionButton: ConnectivityAwareActionWrapper( + offlineBuilder: (context, child) => const SizedBox.shrink(), + child: BlocBuilder( + builder: (context, state) { + if (!state.hasLoaded || + state.documents.isEmpty || + !canEditDocument) { + return const SizedBox.shrink(); + } + return FloatingActionButton.extended( + extendedPadding: _showExtendedFab + ? null + : const EdgeInsets.symmetric(horizontal: 16), + heroTag: "inbox_page_fab", + label: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + transitionBuilder: (child, animation) { + return FadeTransition( + opacity: animation, + child: SizeTransition( + sizeFactor: animation, + axis: Axis.horizontal, + child: child, + ), + ); + }, + child: _showExtendedFab + ? Row( + children: [ + const Icon(Icons.done_all), + Text(S.of(context)!.allSeen), + ], + ) + : const Icon(Icons.done_all), + ), + onPressed: state.hasLoaded && state.documents.isNotEmpty + ? () => _onMarkAllAsSeen( + state.documents, + state.inboxTags, + ) + : null, + ); + }, + ), ), body: SafeArea( top: true, @@ -268,6 +275,12 @@ class _InboxPageState extends State showSnackBar(context, S.of(context)!.missingPermissions); return false; } + final isConnectedToInternet = + await context.read().isConnectedToInternet(); + if (!isConnectedToInternet) { + showSnackBar(context, S.of(context)!.youAreCurrentlyOffline); + return false; + } try { final removedTags = await context.read().removeFromInbox(doc); showSnackBar( diff --git a/lib/features/inbox/view/widgets/inbox_item.dart b/lib/features/inbox/view/widgets/inbox_item.dart index 656088f..70fa034 100644 --- a/lib/features/inbox/view/widgets/inbox_item.dart +++ b/lib/features/inbox/view/widgets/inbox_item.dart @@ -1,10 +1,8 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; -import 'package:paperless_mobile/core/navigation/push_routes.dart'; import 'package:paperless_mobile/core/widgets/shimmer_placeholder.dart'; import 'package:paperless_mobile/core/workarounds/colored_chip.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; @@ -16,6 +14,7 @@ import 'package:paperless_mobile/features/inbox/cubit/inbox_cubit.dart'; import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart'; import 'package:paperless_mobile/features/labels/view/widgets/label_text.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart'; import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; class InboxItemPlaceholder extends StatelessWidget { @@ -228,7 +227,9 @@ class _InboxItemState extends State { ), LimitedBox( maxHeight: 56, - child: _buildActions(context), + child: ConnectivityAwareActionWrapper( + child: _buildActions(context), + ), ), ], ).paddedOnly(left: 8, top: 8, bottom: 8), diff --git a/lib/features/labels/view/pages/labels_page.dart b/lib/features/labels/view/pages/labels_page.dart index 896fcea..213299e 100644 --- a/lib/features/labels/view/pages/labels_page.dart +++ b/lib/features/labels/view/pages/labels_page.dart @@ -13,6 +13,7 @@ import 'package:paperless_mobile/features/document_search/view/sliver_search_bar import 'package:paperless_mobile/features/labels/cubit/label_cubit.dart'; import 'package:paperless_mobile/features/labels/view/widgets/label_tab_view.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart'; import 'package:paperless_mobile/routes/typed/branches/labels_route.dart'; class LabelsPage extends StatefulWidget { @@ -66,36 +67,37 @@ class _LabelsPageState extends State .getValue()! .loggedInUserId; final user = box.get(currentUserId)!.paperlessUser; - + final fabLabel = [ + S.of(context)!.addCorrespondent, + S.of(context)!.addDocumentType, + S.of(context)!.addTag, + S.of(context)!.addStoragePath, + ][_currentIndex]; return BlocBuilder( builder: (context, connectedState) { return SafeArea( child: Scaffold( drawer: const AppDrawer(), - floatingActionButton: FloatingActionButton.extended( - heroTag: "inbox_page_fab", - label: Text( - [ - S.of(context)!.addCorrespondent, - S.of(context)!.addDocumentType, - S.of(context)!.addTag, - S.of(context)!.addStoragePath, + floatingActionButton: ConnectivityAwareActionWrapper( + offlineBuilder: (context, child) => const SizedBox.shrink(), + child: FloatingActionButton.extended( + heroTag: "inbox_page_fab", + label: Text(fabLabel), + icon: Icon(Icons.add), + onPressed: [ + if (user.canViewCorrespondents) + () => CreateLabelRoute(LabelType.correspondent) + .push(context), + if (user.canViewDocumentTypes) + () => CreateLabelRoute(LabelType.documentType) + .push(context), + if (user.canViewTags) + () => CreateLabelRoute(LabelType.tag).push(context), + if (user.canViewStoragePaths) + () => CreateLabelRoute(LabelType.storagePath) + .push(context), ][_currentIndex], ), - icon: Icon(Icons.add), - onPressed: [ - if (user.canViewCorrespondents) - () => CreateLabelRoute(LabelType.correspondent) - .push(context), - if (user.canViewDocumentTypes) - () => CreateLabelRoute(LabelType.documentType) - .push(context), - if (user.canViewTags) - () => CreateLabelRoute(LabelType.tag).push(context), - if (user.canViewStoragePaths) - () => CreateLabelRoute(LabelType.storagePath) - .push(context), - ][_currentIndex], ), body: NestedScrollView( floatHeaderSlivers: true, diff --git a/lib/features/labels/view/widgets/label_tab_view.dart b/lib/features/labels/view/widgets/label_tab_view.dart index d99533e..d02aee6 100644 --- a/lib/features/labels/view/widgets/label_tab_view.dart +++ b/lib/features/labels/view/widgets/label_tab_view.dart @@ -44,7 +44,7 @@ class LabelTabView extends StatelessWidget { return BlocBuilder( builder: (context, connectivityState) { if (!connectivityState.isConnected) { - return const OfflineWidget(); + return const SliverFillRemaining(child: OfflineWidget()); } final sortedLabels = labels.values.toList()..sort(); if (labels.isEmpty) { diff --git a/lib/features/landing/view/landing_page.dart b/lib/features/landing/view/landing_page.dart index da6e340..e453853 100644 --- a/lib/features/landing/view/landing_page.dart +++ b/lib/features/landing/view/landing_page.dart @@ -22,6 +22,7 @@ class LandingPage extends StatefulWidget { class _LandingPageState extends State { final _searchBarHandle = SliverOverlapAbsorberHandle(); + @override Widget build(BuildContext context) { final currentUser = context.watch().paperlessUser; @@ -121,7 +122,6 @@ class _LandingPageState extends State { Widget _buildStatisticsCard(BuildContext context) { final currentUser = context.read().paperlessUser; - return ExpansionCard( initiallyExpanded: false, title: Text( diff --git a/lib/features/linked_documents/cubit/linked_documents_cubit.dart b/lib/features/linked_documents/cubit/linked_documents_cubit.dart index 39e3c0d..c7fbeba 100644 --- a/lib/features/linked_documents/cubit/linked_documents_cubit.dart +++ b/lib/features/linked_documents/cubit/linked_documents_cubit.dart @@ -3,6 +3,7 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; +import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; import 'package:paperless_mobile/features/paged_document_view/cubit/paged_documents_state.dart'; import 'package:paperless_mobile/features/paged_document_view/cubit/document_paging_bloc_mixin.dart'; import 'package:paperless_mobile/features/settings/model/view_type.dart'; @@ -14,7 +15,8 @@ class LinkedDocumentsCubit extends HydratedCubit with DocumentPagingBlocMixin { @override final PaperlessDocumentsApi api; - + @override + final ConnectivityStatusService connectivityStatusService; @override final DocumentChangedNotifier notifier; @@ -25,6 +27,7 @@ class LinkedDocumentsCubit extends HydratedCubit this.api, this.notifier, this._labelRepository, + this.connectivityStatusService, ) : super(LinkedDocumentsState(filter: filter)) { updateFilter(filter: filter); _labelRepository.addListener( diff --git a/lib/features/login/cubit/authentication_cubit.dart b/lib/features/login/cubit/authentication_cubit.dart index 8e8b56d..cc695f9 100644 --- a/lib/features/login/cubit/authentication_cubit.dart +++ b/lib/features/login/cubit/authentication_cubit.dart @@ -1,9 +1,11 @@ +import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:dio/dio.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'; @@ -12,25 +14,28 @@ 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/model/info_message_exception.dart'; import 'package:paperless_mobile/core/security/session_manager.dart'; +import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; 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/services/authentication_service.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; -part 'authentication_cubit.freezed.dart'; part 'authentication_state.dart'; class AuthenticationCubit extends Cubit { final LocalAuthenticationService _localAuthService; final PaperlessApiFactory _apiFactory; final SessionManager _sessionManager; + final ConnectivityStatusService _connectivityService; AuthenticationCubit( this._localAuthService, this._apiFactory, this._sessionManager, - ) : super(const AuthenticationState.unauthenticated()); + this._connectivityService, + ) : super(const UnauthenticatedState()); Future login({ required LoginFormCredentials credentials, @@ -51,8 +56,6 @@ class AuthenticationCubit extends Cubit { _sessionManager, ); - final apiVersion = await _getApiVersion(_sessionManager.client); - // Mark logged in user as currently active user. final globalSettings = Hive.box(HiveBoxes.globalSettings).getValue()!; @@ -60,7 +63,7 @@ class AuthenticationCubit extends Cubit { await globalSettings.save(); emit( - AuthenticationState.authenticated( + AuthenticatedState( localUserId: localUserId, ), ); @@ -72,11 +75,11 @@ class AuthenticationCubit extends Cubit { /// Switches to another account if it exists. Future switchAccount(String localUserId) async { - emit(const AuthenticationState.switchingAccounts()); + emit(const SwitchingAccountsState()); final globalSettings = Hive.box(HiveBoxes.globalSettings).getValue()!; if (globalSettings.loggedInUserId == localUserId) { - emit(AuthenticationState.authenticated(localUserId: localUserId)); + emit(AuthenticatedState(localUserId: localUserId)); return; } final userAccountBox = @@ -125,7 +128,7 @@ class AuthenticationCubit extends Cubit { apiVersion, ); - emit(AuthenticationState.authenticated( + emit(AuthenticatedState( localUserId: localUserId, )); }); @@ -182,7 +185,7 @@ class AuthenticationCubit extends Cubit { "There is nothing to restore.", ); // If there is nothing to restore, we can quit here. - emit(const AuthenticationState.unauthenticated()); + emit(const UnauthenticatedState()); return; } final localUserAccountBox = @@ -203,7 +206,7 @@ class AuthenticationCubit extends Cubit { final localAuthSuccess = await _localAuthService.authenticateLocalUser(authenticationMesage); if (!localAuthSuccess) { - emit(const AuthenticationState.requriresLocalAuthentication()); + emit(const RequiresLocalAuthenticationState()); _debugPrintMessage( "restoreSessionState", "User could not be authenticated.", @@ -239,14 +242,17 @@ class AuthenticationCubit extends Cubit { "User should be authenticated but no authentication information was found.", ); } + _debugPrintMessage( "restoreSessionState", "Authentication credentials successfully retrieved.", ); + _debugPrintMessage( "restoreSessionState", "Updating current session state...", ); + _sessionManager.updateSettings( clientCertificate: authentication.clientCertificate, authToken: authentication.token, @@ -256,18 +262,32 @@ class AuthenticationCubit extends Cubit { "restoreSessionState", "Current session state successfully updated.", ); + final hasInternetConnection = + await _connectivityService.isConnectedToInternet(); + if (hasInternetConnection) { + _debugPrintMessage( + "restoreSessionMState", + "Updating server user...", + ); + final apiVersion = await _getApiVersion(_sessionManager.client); + await _updateRemoteUser( + _sessionManager, + localUserAccount, + apiVersion, + ); + _debugPrintMessage( + "restoreSessionMState", + "Successfully updated server user.", + ); + } else { + _debugPrintMessage( + "restoreSessionMState", + "Skipping update of server user (no internet connection).", + ); + } + + emit(AuthenticatedState(localUserId: localUserId)); - final apiVersion = await _getApiVersion(_sessionManager.client); - await _updateRemoteUser( - _sessionManager, - localUserAccount, - apiVersion, - ); - emit( - AuthenticationState.authenticated( - localUserId: localUserId, - ), - ); _debugPrintMessage( "restoreSessionState", "Session was successfully restored.", @@ -285,7 +305,7 @@ class AuthenticationCubit extends Cubit { globalSettings.loggedInUserId = null; await globalSettings.save(); - emit(const AuthenticationState.unauthenticated()); + emit(const UnauthenticatedState()); _debugPrintMessage( "logout", "User successfully logged out.", @@ -353,7 +373,7 @@ class AuthenticationCubit extends Cubit { "_addUser", "An error occurred! The user $localUserId already exists.", ); - throw Exception("User already exists!"); + throw InfoMessageException(code: ErrorCode.userAlreadyExists); } final apiVersion = await _getApiVersion(sessionManager.client); _debugPrintMessage( diff --git a/lib/features/login/cubit/authentication_state.dart b/lib/features/login/cubit/authentication_state.dart index 12530aa..db4455c 100644 --- a/lib/features/login/cubit/authentication_state.dart +++ b/lib/features/login/cubit/authentication_state.dart @@ -1,19 +1,32 @@ part of 'authentication_cubit.dart'; -@freezed -class AuthenticationState with _$AuthenticationState { - const AuthenticationState._(); +sealed class AuthenticationState { + const AuthenticationState(); - const factory AuthenticationState.unauthenticated() = _Unauthenticated; - const factory AuthenticationState.requriresLocalAuthentication() = - _RequiresLocalAuthentication; - const factory AuthenticationState.authenticated({ - required String localUserId, - }) = _Authenticated; - const factory AuthenticationState.switchingAccounts() = _SwitchingAccounts; - - bool get isAuthenticated => maybeWhen( - authenticated: (_) => true, - orElse: () => false, - ); + bool get isAuthenticated => + switch (this) { AuthenticatedState() => true, _ => false }; +} + +class UnauthenticatedState extends AuthenticationState { + const UnauthenticatedState(); +} + +class RequiresLocalAuthenticationState extends AuthenticationState { + const RequiresLocalAuthenticationState(); +} + +class AuthenticatedState extends AuthenticationState { + final String localUserId; + + const AuthenticatedState({ + required this.localUserId, + }); +} + +class SwitchingAccountsState extends AuthenticationState { + const SwitchingAccountsState(); +} + +class AuthenticationErrorState extends AuthenticationState { + const AuthenticationErrorState(); } diff --git a/lib/features/login/view/add_account_page.dart b/lib/features/login/view/add_account_page.dart index 72ed029..7e1bdc6 100644 --- a/lib/features/login/view/add_account_page.dart +++ b/lib/features/login/view/add_account_page.dart @@ -8,6 +8,7 @@ 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/features/login/model/client_certificate.dart'; import 'package:paperless_mobile/features/login/model/client_certificate_form_model.dart'; @@ -153,6 +154,8 @@ class _AddAccountPageState extends State { showErrorMessage(context, error); } on ServerMessageException catch (error) { showLocalizedError(context, error.message); + } on InfoMessageException catch (error) { + showInfoMessage(context, error); } catch (error) { showGenericError(context, error); } diff --git a/lib/features/login/view/widgets/login_pages/server_login_page.dart b/lib/features/login/view/widgets/login_pages/server_login_page.dart index df64d69..1951aa3 100644 --- a/lib/features/login/view/widgets/login_pages/server_login_page.dart +++ b/lib/features/login/view/widgets/login_pages/server_login_page.dart @@ -52,10 +52,11 @@ class _ServerLoginPageState extends State { Text( S.of(context)!.loginRequiredPermissionsHint, style: Theme.of(context).textTheme.bodySmall?.apply( - color: Theme.of(context) - .colorScheme - .onBackground - .withOpacity(0.6)), + color: Theme.of(context) + .colorScheme + .onBackground + .withOpacity(0.6), + ), ).padded(16), ], ), @@ -64,11 +65,16 @@ class _ServerLoginPageState extends State { mainAxisAlignment: MainAxisAlignment.end, children: [ FilledButton( - onPressed: () async { - setState(() => _isLoginLoading = true); - await widget.onSubmit(); - setState(() => _isLoginLoading = false); - }, + onPressed: !_isLoginLoading + ? () async { + setState(() => _isLoginLoading = true); + try { + await widget.onSubmit(); + } finally { + setState(() => _isLoginLoading = false); + } + } + : null, child: Text(S.of(context)!.signIn), ) ], diff --git a/lib/features/paged_document_view/cubit/document_paging_bloc_mixin.dart b/lib/features/paged_document_view/cubit/document_paging_bloc_mixin.dart index 33b43b5..1c68313 100644 --- a/lib/features/paged_document_view/cubit/document_paging_bloc_mixin.dart +++ b/lib/features/paged_document_view/cubit/document_paging_bloc_mixin.dart @@ -2,6 +2,8 @@ import 'package:collection/collection.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; +import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; +import 'package:rxdart/streams.dart'; import 'paged_documents_state.dart'; @@ -11,13 +13,16 @@ import 'paged_documents_state.dart'; /// mixin DocumentPagingBlocMixin on BlocBase { + ConnectivityStatusService get connectivityStatusService; PaperlessDocumentsApi get api; DocumentChangedNotifier get notifier; Future onFilterUpdated(DocumentFilter filter); Future loadMore() async { - if (state.isLastPageLoaded) { + final hasConnection = + await connectivityStatusService.isConnectedToInternet(); + if (state.isLastPageLoaded || !hasConnection) { return; } emit(state.copyWithPaged(isLoading: true)); @@ -47,6 +52,32 @@ mixin DocumentPagingBlocMixin Future updateFilter({ final DocumentFilter filter = const DocumentFilter(), }) async { + final hasConnection = + await connectivityStatusService.isConnectedToInternet(); + if (!hasConnection) { + // Just filter currently loaded documents + final filteredDocuments = state.value + .expand((page) => page.results) + .where((doc) => filter.matches(doc)) + .toList(); + emit(state.copyWithPaged(isLoading: true)); + + emit( + state.copyWithPaged( + filter: filter, + value: [ + PagedSearchResult( + results: filteredDocuments, + count: filteredDocuments.length, + next: null, + previous: null, + ) + ], + hasLoaded: true, + ), + ); + return; + } try { emit(state.copyWithPaged(isLoading: true)); final result = await api.findAll(filter.copyWith(page: 1)); diff --git a/lib/features/saved_view_details/cubit/saved_view_details_cubit.dart b/lib/features/saved_view_details/cubit/saved_view_details_cubit.dart index e965211..33af9fa 100644 --- a/lib/features/saved_view_details/cubit/saved_view_details_cubit.dart +++ b/lib/features/saved_view_details/cubit/saved_view_details_cubit.dart @@ -3,6 +3,7 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; +import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; import 'package:paperless_mobile/features/paged_document_view/cubit/paged_documents_state.dart'; import 'package:paperless_mobile/features/paged_document_view/cubit/document_paging_bloc_mixin.dart'; import 'package:paperless_mobile/features/settings/model/view_type.dart'; @@ -15,7 +16,8 @@ class SavedViewDetailsCubit extends Cubit final PaperlessDocumentsApi api; final LabelRepository _labelRepository; - + @override + final ConnectivityStatusService connectivityStatusService; @override final DocumentChangedNotifier notifier; @@ -27,7 +29,8 @@ class SavedViewDetailsCubit extends Cubit this.api, this.notifier, this._labelRepository, - this._userState, { + this._userState, + this.connectivityStatusService, { required this.savedView, int initialCount = 25, }) : super( diff --git a/lib/features/saved_view_details/cubit/saved_view_preview_cubit.dart b/lib/features/saved_view_details/cubit/saved_view_preview_cubit.dart index 19658f7..cffd03d 100644 --- a/lib/features/saved_view_details/cubit/saved_view_preview_cubit.dart +++ b/lib/features/saved_view_details/cubit/saved_view_preview_cubit.dart @@ -1,6 +1,7 @@ import 'package:bloc/bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; part 'saved_view_preview_state.dart'; part 'saved_view_preview_cubit.freezed.dart'; @@ -8,11 +9,21 @@ part 'saved_view_preview_cubit.freezed.dart'; class SavedViewPreviewCubit extends Cubit { final PaperlessDocumentsApi _api; final SavedView view; - SavedViewPreviewCubit(this._api, this.view) - : super(const SavedViewPreviewState.initial()); + final ConnectivityStatusService _connectivityStatusService; + SavedViewPreviewCubit( + this._api, + this._connectivityStatusService, { + required this.view, + }) : super(const InitialSavedViewPreviewState()); Future initialize() async { - emit(const SavedViewPreviewState.loading()); + final isConnected = + await _connectivityStatusService.isConnectedToInternet(); + if (!isConnected) { + emit(const OfflineSavedViewPreviewState()); + return; + } + emit(const LoadingSavedViewPreviewState()); try { final documents = await _api.findAll( view.toDocumentFilter().copyWith( @@ -20,9 +31,9 @@ class SavedViewPreviewCubit extends Cubit { pageSize: 5, ), ); - emit(SavedViewPreviewState.loaded(documents: documents.results)); + emit(LoadedSavedViewPreviewState(documents: documents.results)); } catch (e) { - emit(const SavedViewPreviewState.error()); + emit(const ErrorSavedViewPreviewState()); } } } diff --git a/lib/features/saved_view_details/cubit/saved_view_preview_state.dart b/lib/features/saved_view_details/cubit/saved_view_preview_state.dart index 49e4995..a0de113 100644 --- a/lib/features/saved_view_details/cubit/saved_view_preview_state.dart +++ b/lib/features/saved_view_details/cubit/saved_view_preview_state.dart @@ -1,11 +1,29 @@ part of 'saved_view_preview_cubit.dart'; -@freezed -class SavedViewPreviewState with _$SavedViewPreviewState { - const factory SavedViewPreviewState.initial() = _Initial; - const factory SavedViewPreviewState.loading() = _Loading; - const factory SavedViewPreviewState.loaded({ - required List documents, - }) = _Loaded; - const factory SavedViewPreviewState.error() = _Error; +sealed class SavedViewPreviewState { + const SavedViewPreviewState(); +} + +class InitialSavedViewPreviewState extends SavedViewPreviewState { + const InitialSavedViewPreviewState(); +} + +class LoadingSavedViewPreviewState extends SavedViewPreviewState { + const LoadingSavedViewPreviewState(); +} + +class LoadedSavedViewPreviewState extends SavedViewPreviewState { + final List documents; + + const LoadedSavedViewPreviewState({ + required this.documents, + }); +} + +class ErrorSavedViewPreviewState extends SavedViewPreviewState { + const ErrorSavedViewPreviewState(); +} + +class OfflineSavedViewPreviewState extends SavedViewPreviewState { + const OfflineSavedViewPreviewState(); } diff --git a/lib/features/saved_view_details/view/saved_view_preview.dart b/lib/features/saved_view_details/view/saved_view_preview.dart index 38a1488..2ebb459 100644 --- a/lib/features/saved_view_details/view/saved_view_preview.dart +++ b/lib/features/saved_view_details/view/saved_view_preview.dart @@ -22,8 +22,11 @@ class SavedViewPreview extends StatelessWidget { @override Widget build(BuildContext context) { return Provider( - create: (context) => - SavedViewPreviewCubit(context.read(), savedView)..initialize(), + create: (context) => SavedViewPreviewCubit( + context.read(), + context.read(), + view: savedView, + )..initialize(), builder: (context, child) { return ExpansionCard( initiallyExpanded: expanded, @@ -33,34 +36,40 @@ class SavedViewPreview extends StatelessWidget { children: [ BlocBuilder( builder: (context, state) { - return state.maybeWhen( - loaded: (documents) { - if (documents.isEmpty) { - return Text(S.of(context)!.noDocumentsFound).padded(); - } else { - return Column( - children: [ - for (final document in documents) - DocumentListItem( - document: document, - isLabelClickable: false, - isSelected: false, - isSelectionActive: false, - onTap: (document) { - DocumentDetailsRoute($extra: document) - .push(context); - }, - onSelected: null, - ), - ], - ); - } - }, - error: () => Text(S.of(context)!.couldNotLoadSavedViews), - orElse: () => const Center( - child: CircularProgressIndicator(), - ).paddedOnly(top: 8, bottom: 24), - ); + return switch (state) { + LoadedSavedViewPreviewState(documents: var documents) => + Builder( + builder: (context) { + if (documents.isEmpty) { + return Text(S.of(context)!.noDocumentsFound) + .padded(); + } else { + return Column( + children: [ + for (final document in documents) + DocumentListItem( + document: document, + isLabelClickable: false, + isSelected: false, + isSelectionActive: false, + onTap: (document) { + DocumentDetailsRoute($extra: document) + .push(context); + }, + onSelected: null, + ), + ], + ); + } + }, + ), + ErrorSavedViewPreviewState() => + Text(S.of(context)!.couldNotLoadSavedViews).padded(16), + OfflineSavedViewPreviewState() => + Text(S.of(context)!.youAreCurrentlyOffline).padded(16), + _ => const CircularProgressIndicator() + .paddedOnly(top: 8, bottom: 24), + }; }, ), Row( diff --git a/lib/features/similar_documents/cubit/similar_documents_cubit.dart b/lib/features/similar_documents/cubit/similar_documents_cubit.dart index 6e24b98..4ec4653 100644 --- a/lib/features/similar_documents/cubit/similar_documents_cubit.dart +++ b/lib/features/similar_documents/cubit/similar_documents_cubit.dart @@ -2,6 +2,7 @@ import 'package:bloc/bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; +import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; import 'package:paperless_mobile/features/paged_document_view/cubit/document_paging_bloc_mixin.dart'; import 'package:paperless_mobile/features/paged_document_view/cubit/paged_documents_state.dart'; @@ -10,7 +11,8 @@ part 'similar_documents_state.dart'; class SimilarDocumentsCubit extends Cubit with DocumentPagingBlocMixin { final int documentId; - + @override + final ConnectivityStatusService connectivityStatusService; @override final PaperlessDocumentsApi api; @@ -22,7 +24,8 @@ class SimilarDocumentsCubit extends Cubit SimilarDocumentsCubit( this.api, this.notifier, - this._labelRepository, { + this._labelRepository, + this.connectivityStatusService, { required this.documentId, }) : super(const SimilarDocumentsState(filter: DocumentFilter())) { notifier.addListener( diff --git a/lib/helpers/connectivity_aware_action_wrapper.dart b/lib/helpers/connectivity_aware_action_wrapper.dart new file mode 100644 index 0000000..f69e922 --- /dev/null +++ b/lib/helpers/connectivity_aware_action_wrapper.dart @@ -0,0 +1,63 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/helpers/message_helpers.dart'; + +typedef OfflineBuilder = Widget Function(BuildContext context, Widget? child); + +class ConnectivityAwareActionWrapper extends StatelessWidget { + final OfflineBuilder offlineBuilder; + final Widget child; + final bool disabled; + + static Widget disabledBuilder(BuildContext context, Widget? child) { + return ColorFiltered( + colorFilter: const ColorFilter.matrix([ + 0.2126, 0.7152, 0.0722, 0, 0, // + 0.2126, 0.7152, 0.0722, 0, 0, + 0.2126, 0.7152, 0.0722, 0, 0, + 0, 0, 0, 1, 0, + ]), + child: child, + ); + } + + /// + /// Wrapper widget which is used to disable an actionable [child] + /// (like buttons, chips etc.) which require a connection to the internet. + /// + /// + const ConnectivityAwareActionWrapper({ + super.key, + this.offlineBuilder = ConnectivityAwareActionWrapper.disabledBuilder, + required this.child, + this.disabled = false, + }); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: context.read().connectivityChanges(), + builder: (context, snapshot) { + final disableButton = + !snapshot.hasData || snapshot.data == false || disabled; + if (disableButton) { + return GestureDetector( + onTap: () { + HapticFeedback.heavyImpact(); + showSnackBar(context, S.of(context)!.youAreCurrentlyOffline); + }, + child: AbsorbPointer( + child: offlineBuilder(context, child), + ), + ); + } + return child; + }, + ); + } +} diff --git a/lib/helpers/message_helpers.dart b/lib/helpers/message_helpers.dart index b3020fd..39f532b 100644 --- a/lib/helpers/message_helpers.dart +++ b/lib/helpers/message_helpers.dart @@ -2,6 +2,7 @@ import 'dart:developer'; import 'package:flutter/material.dart'; import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/model/info_message_exception.dart'; import 'package:paperless_mobile/core/translation/error_code_localization_mapper.dart'; class SnackBarActionConfig { @@ -108,3 +109,15 @@ void showErrorMessage( time: DateTime.now(), ); } + +void showInfoMessage( + BuildContext context, + InfoMessageException error, [ + StackTrace? stackTrace, +]) { + showSnackBar( + context, + translateError(context, error.code), + details: error.message, + ); +} diff --git a/lib/l10n/intl_ca.arb b/lib/l10n/intl_ca.arb index e84be1e..b631de0 100644 --- a/lib/l10n/intl_ca.arb +++ b/lib/l10n/intl_ca.arb @@ -969,5 +969,9 @@ "showAll": "Show all", "@showAll": { "description": "Button label shown on a saved view preview to open this view in the documents page" + }, + "userAlreadyExists": "This user already exists.", + "@userAlreadyExists": { + "description": "Error message shown when the user tries to add an already existing account." } } \ No newline at end of file diff --git a/lib/l10n/intl_cs.arb b/lib/l10n/intl_cs.arb index 265d2db..82f70c3 100644 --- a/lib/l10n/intl_cs.arb +++ b/lib/l10n/intl_cs.arb @@ -969,5 +969,9 @@ "showAll": "Show all", "@showAll": { "description": "Button label shown on a saved view preview to open this view in the documents page" + }, + "userAlreadyExists": "This user already exists.", + "@userAlreadyExists": { + "description": "Error message shown when the user tries to add an already existing account." } } \ No newline at end of file diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 730b4b9..8a31711 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -969,5 +969,9 @@ "showAll": "Alle anzeigen", "@showAll": { "description": "Button label shown on a saved view preview to open this view in the documents page" + }, + "userAlreadyExists": "Dieser Nutzer existiert bereits.", + "@userAlreadyExists": { + "description": "Error message shown when the user tries to add an already existing account." } } \ No newline at end of file diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index f3acf6b..1e00854 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -969,5 +969,9 @@ "showAll": "Show all", "@showAll": { "description": "Button label shown on a saved view preview to open this view in the documents page" + }, + "userAlreadyExists": "This user already exists.", + "@userAlreadyExists": { + "description": "Error message shown when the user tries to add an already existing account." } } \ No newline at end of file diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index faf5a53..6096a3f 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -969,5 +969,9 @@ "showAll": "Show all", "@showAll": { "description": "Button label shown on a saved view preview to open this view in the documents page" + }, + "userAlreadyExists": "This user already exists.", + "@userAlreadyExists": { + "description": "Error message shown when the user tries to add an already existing account." } } \ No newline at end of file diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index ad032a2..308916e 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -969,5 +969,9 @@ "showAll": "Show all", "@showAll": { "description": "Button label shown on a saved view preview to open this view in the documents page" + }, + "userAlreadyExists": "This user already exists.", + "@userAlreadyExists": { + "description": "Error message shown when the user tries to add an already existing account." } } \ No newline at end of file diff --git a/lib/l10n/intl_pl.arb b/lib/l10n/intl_pl.arb index d024a27..cf000d6 100644 --- a/lib/l10n/intl_pl.arb +++ b/lib/l10n/intl_pl.arb @@ -969,5 +969,9 @@ "showAll": "Show all", "@showAll": { "description": "Button label shown on a saved view preview to open this view in the documents page" + }, + "userAlreadyExists": "This user already exists.", + "@userAlreadyExists": { + "description": "Error message shown when the user tries to add an already existing account." } } \ No newline at end of file diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index edd90c2..8d82ccf 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -969,5 +969,9 @@ "showAll": "Show all", "@showAll": { "description": "Button label shown on a saved view preview to open this view in the documents page" + }, + "userAlreadyExists": "This user already exists.", + "@userAlreadyExists": { + "description": "Error message shown when the user tries to add an already existing account." } } \ No newline at end of file diff --git a/lib/l10n/intl_tr.arb b/lib/l10n/intl_tr.arb index dd1b874..f7795c7 100644 --- a/lib/l10n/intl_tr.arb +++ b/lib/l10n/intl_tr.arb @@ -969,5 +969,9 @@ "showAll": "Show all", "@showAll": { "description": "Button label shown on a saved view preview to open this view in the documents page" + }, + "userAlreadyExists": "This user already exists.", + "@userAlreadyExists": { + "description": "Error message shown when the user tries to add an already existing account." } } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index a39e7da..416bb00 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,7 +6,6 @@ import 'package:device_info_plus/device_info_plus.dart'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart'; @@ -29,16 +28,17 @@ import 'package:paperless_mobile/core/exception/server_message_exception.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory_impl.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/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/security/session_manager.dart'; import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; +import 'package:paperless_mobile/core/translation/error_code_localization_mapper.dart'; import 'package:paperless_mobile/features/login/cubit/authentication_cubit.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/features/settings/view/widgets/global_settings_builder.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/routes/navigation_keys.dart'; -import 'package:paperless_mobile/routes/routes.dart'; import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; import 'package:paperless_mobile/routes/typed/branches/inbox_route.dart'; import 'package:paperless_mobile/routes/typed/branches/labels_route.dart'; @@ -103,17 +103,19 @@ void main() async { await findSystemLocale(); packageInfo = await PackageInfo.fromPlatform(); + if (Platform.isAndroid) { androidInfo = await DeviceInfoPlugin().androidInfo; } if (Platform.isIOS) { iosInfo = await DeviceInfoPlugin().iosInfo; } - final connectivity = Connectivity(); - final localAuthentication = LocalAuthentication(); - final connectivityStatusService = - ConnectivityStatusServiceImpl(connectivity); - final localAuthService = LocalAuthenticationService(localAuthentication); + final connectivityStatusService = ConnectivityStatusServiceImpl( + Connectivity(), + ); + final localAuthService = LocalAuthenticationService( + LocalAuthentication(), + ); HydratedBloc.storage = await HydratedStorage.build( storageDirectory: await getApplicationDocumentsDirectory(), @@ -145,8 +147,12 @@ void main() async { }); final apiFactory = PaperlessApiFactoryImpl(sessionManager); - final authenticationCubit = - AuthenticationCubit(localAuthService, apiFactory, sessionManager); + final authenticationCubit = AuthenticationCubit( + localAuthService, + apiFactory, + sessionManager, + connectivityStatusService, + ); await authenticationCubit.restoreSessionState(); runApp( @@ -154,7 +160,6 @@ void main() async { providers: [ ChangeNotifierProvider.value(value: sessionManager), Provider.value(value: localAuthService), - Provider.value(value: connectivity), Provider.value( value: connectivityStatusService), Provider.value( @@ -171,6 +176,7 @@ void main() async { ), ); }, (error, stack) { + // Catches all unexpected/uncaught errors and prints them to the console. String message = switch (error) { PaperlessApiException e => e.details ?? error.toString(), ServerMessageException e => e.message, @@ -271,12 +277,22 @@ class _GoRouterShellState extends State { Widget build(BuildContext context) { return BlocListener( listener: (context, state) { - state.when( - unauthenticated: () => _router.goNamed(R.login), - requriresLocalAuthentication: () => _router.goNamed(R.verifyIdentity), - switchingAccounts: () => _router.goNamed(R.switchingAccounts), - authenticated: (localUserId) => _router.goNamed(R.landing), - ); + switch (state) { + case UnauthenticatedState(): + const LoginRoute().go(context); + break; + + case RequiresLocalAuthenticationState(): + const VerifyIdentityRoute().go(context); + break; + case SwitchingAccountsState(): + const SwitchingAccountsRoute().go(context); + break; + case AuthenticatedState(): + const LandingRoute().go(context); + break; + case AuthenticationErrorState(): + } }, child: GlobalSettingsBuilder( builder: (context, settings) { diff --git a/lib/routes/typed/branches/labels_route.dart b/lib/routes/typed/branches/labels_route.dart index c70fc5d..44c0a6e 100644 --- a/lib/routes/typed/branches/labels_route.dart +++ b/lib/routes/typed/branches/labels_route.dart @@ -100,6 +100,7 @@ class LinkedDocumentsRoute extends GoRouteData { context.read(), context.read(), context.read(), + context.read(), ), child: const LinkedDocumentsPage(), ); diff --git a/packages/mock_server/lib/mock_server.dart b/packages/mock_server/lib/mock_server.dart index f82dc3d..84992cd 100644 --- a/packages/mock_server/lib/mock_server.dart +++ b/packages/mock_server/lib/mock_server.dart @@ -1,13 +1,13 @@ library mock_server; -export 'response_delay_generator.dart'; +export 'response_delay_factory.dart'; import 'dart:convert'; import 'dart:math'; import 'package:http/http.dart' as http; import 'package:logging/logging.dart'; import 'package:mock_server/english_words.dart'; -import 'package:mock_server/response_delay_generator.dart'; +import 'package:mock_server/response_delay_factory.dart'; import 'package:shelf/shelf.dart'; import 'package:shelf/shelf_io.dart' as shelf_io; import 'package:shelf_router/shelf_router.dart' as shelf_router; @@ -22,15 +22,17 @@ class LocalMockApiServer { static get baseUrl => 'http://$host:$port/'; - final DelayGenerator _delayGenerator; + final ResponseDelayFactory _delayGenerator; late shelf_router.Router app; Future> loadFixture(String name) async { - var fixture = await rootBundle.loadString('packages/mock_server/fixtures/$name.json'); + var fixture = + await rootBundle.loadString('packages/mock_server/fixtures/$name.json'); return json.decode(fixture); } - LocalMockApiServer([this._delayGenerator = const ZeroDelayGenerator()]) { + LocalMockApiServer( + [this._delayGenerator = const ZeroResponseDelayFactory()]) { app = shelf_router.Router(); Map createdTags = {}; @@ -44,7 +46,8 @@ class LocalMockApiServer { log.info('Responding to /api/token/'); var body = await req.bodyJsonMap(); if (body?['username'] == 'admin' && body?['password'] == 'test') { - return JsonMockResponse.ok({'token': 'testToken'}, _delayGenerator.nextDelay()); + return JsonMockResponse.ok( + {'token': 'testToken'}, _delayGenerator.nextDelay()); } else { return Response.unauthorized('Unauthorized'); } @@ -149,9 +152,13 @@ class LocalMockApiServer { app.delete('/api/tags//', (Request req, String tagId) async { log.info('Responding to PUT /api/tags//'); - (createdTags['results'] as List).removeWhere((element) => element['id'] == tagId); + (createdTags['results'] as List) + .removeWhere((element) => element['id'] == tagId); return Response(204, - body: null, headers: {'Content-Type': 'application/json'}, encoding: null, context: null); + body: null, + headers: {'Content-Type': 'application/json'}, + encoding: null, + context: null); }); app.get('/api/storage_paths/', (Request req) async { @@ -180,7 +187,8 @@ class LocalMockApiServer { app.get('/api/documents//thumb/', (Request req, String docId) async { log.info('Responding to /api/documents//thumb/'); - var thumb = await rootBundle.load('packages/mock_server/fixtures/lorem-ipsum.png'); + var thumb = await rootBundle + .load('packages/mock_server/fixtures/lorem-ipsum.png'); try { var resp = Response.ok( http.ByteStream.fromBytes(thumb.buffer.asInt8List()), @@ -192,14 +200,16 @@ class LocalMockApiServer { } }); - app.get('/api/documents//metadata/', (Request req, String docId) async { + app.get('/api/documents//metadata/', + (Request req, String docId) async { log.info('Responding to /api/documents//metadata/'); var data = await loadFixture('metadata'); return JsonMockResponse.ok(data, _delayGenerator.nextDelay()); }); //This is not yet used in the app - app.get('/api/documents//suggestions/', (Request req, String docId) async { + app.get('/api/documents//suggestions/', + (Request req, String docId) async { log.info('Responding to /api/documents//suggestions/'); var data = await loadFixture('suggestions'); return JsonMockResponse.ok(data, _delayGenerator.nextDelay()); @@ -235,7 +245,10 @@ class LocalMockApiServer { final term = req.url.queryParameters["term"] ?? ''; final limit = int.parse(req.url.queryParameters["limit"] ?? '5'); return JsonMockResponse.ok( - mostFrequentWords.where((element) => element.startsWith(term)).take(limit).toList(), + mostFrequentWords + .where((element) => element.startsWith(term)) + .take(limit) + .toList(), _delayGenerator.nextDelay(), ); }); diff --git a/packages/mock_server/lib/response_delay_generator.dart b/packages/mock_server/lib/response_delay_factory.dart similarity index 60% rename from packages/mock_server/lib/response_delay_generator.dart rename to packages/mock_server/lib/response_delay_factory.dart index 347c534..1dffc82 100644 --- a/packages/mock_server/lib/response_delay_generator.dart +++ b/packages/mock_server/lib/response_delay_factory.dart @@ -1,10 +1,10 @@ import 'dart:math'; -abstract interface class DelayGenerator { +abstract interface class ResponseDelayFactory { Duration nextDelay(); } -class RandomDelayGenerator implements DelayGenerator { +class RandomResponseDelayFactory implements ResponseDelayFactory { /// Minimum allowed response delay final Duration minDelay; @@ -12,7 +12,7 @@ class RandomDelayGenerator implements DelayGenerator { final Duration maxDelay; final Random _random = Random(); - RandomDelayGenerator(this.minDelay, this.maxDelay); + RandomResponseDelayFactory(this.minDelay, this.maxDelay); @override Duration nextDelay() { @@ -25,10 +25,10 @@ class RandomDelayGenerator implements DelayGenerator { } } -class ConstantDelayGenerator implements DelayGenerator { +class ConstantResponseDelayFactory implements ResponseDelayFactory { final Duration delay; - const ConstantDelayGenerator(this.delay); + const ConstantResponseDelayFactory(this.delay); @override Duration nextDelay() { @@ -36,8 +36,8 @@ class ConstantDelayGenerator implements DelayGenerator { } } -class ZeroDelayGenerator implements DelayGenerator { - const ZeroDelayGenerator(); +class ZeroResponseDelayFactory implements ResponseDelayFactory { + const ZeroResponseDelayFactory(); @override Duration nextDelay() { diff --git a/packages/paperless_api/lib/src/models/paged_search_result.dart b/packages/paperless_api/lib/src/models/paged_search_result.dart index d727af3..79898d0 100644 --- a/packages/paperless_api/lib/src/models/paged_search_result.dart +++ b/packages/paperless_api/lib/src/models/paged_search_result.dart @@ -51,8 +51,8 @@ class PagedSearchResult extends Equatable { const PagedSearchResult({ required this.count, - required this.next, - required this.previous, + this.next, + this.previous, required this.results, }); diff --git a/packages/paperless_api/lib/src/models/paperless_api_exception.dart b/packages/paperless_api/lib/src/models/paperless_api_exception.dart index adaa874..136de68 100644 --- a/packages/paperless_api/lib/src/models/paperless_api_exception.dart +++ b/packages/paperless_api/lib/src/models/paperless_api_exception.dart @@ -67,5 +67,6 @@ enum ErrorCode { uiSettingsLoadFailed, loadTasksError, userNotFound, + userAlreadyExists, updateSavedViewError; }