diff --git a/lib/core/global/os_error_codes.dart b/lib/core/global/os_error_codes.dart new file mode 100644 index 0000000..2de7294 --- /dev/null +++ b/lib/core/global/os_error_codes.dart @@ -0,0 +1,8 @@ +enum OsErrorCodes { + serverUnreachable(101), + hostNotFound(7), + invalidClientCertConfig(318767212); + + const OsErrorCodes(this.code); + final int code; +} diff --git a/lib/core/interceptor/dio_http_error_interceptor.dart b/lib/core/interceptor/dio_http_error_interceptor.dart index 0283770..7e67790 100644 --- a/lib/core/interceptor/dio_http_error_interceptor.dart +++ b/lib/core/interceptor/dio_http_error_interceptor.dart @@ -11,22 +11,23 @@ class DioHttpErrorInterceptor extends Interceptor { // try to parse contained error message, otherwise return response final dynamic data = err.response?.data; if (data is Map) { - _handlePaperlessValidationError(data, handler, err); + return _handlePaperlessValidationError(data, handler, err); } else if (data is String) { - _handlePlainError(data, handler, err); + return _handlePlainError(data, handler, err); } } else if (err.error is SocketException) { - // Offline - handler.reject( - DioError( - error: const PaperlessServerException(ErrorCode.deviceOffline), - requestOptions: err.requestOptions, - type: DioErrorType.connectTimeout, - ), - ); - } else { - handler.reject(err); + final ex = err.error as SocketException; + if (ex.osError?.errorCode == _OsErrorCodes.serverUnreachable.code) { + return handler.reject( + DioError( + error: const PaperlessServerException(ErrorCode.deviceOffline), + requestOptions: err.requestOptions, + type: DioErrorType.connectTimeout, + ), + ); + } } + return handler.reject(err); } void _handlePaperlessValidationError( @@ -73,3 +74,11 @@ class DioHttpErrorInterceptor extends Interceptor { } } } + +enum _OsErrorCodes { + serverUnreachable(101), + hostNotFound(7); + + const _OsErrorCodes(this.code); + final int code; +} diff --git a/lib/core/interceptor/server_reachability_error_interceptor.dart b/lib/core/interceptor/server_reachability_error_interceptor.dart new file mode 100644 index 0000000..bfc4183 --- /dev/null +++ b/lib/core/interceptor/server_reachability_error_interceptor.dart @@ -0,0 +1,60 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:paperless_mobile/core/global/os_error_codes.dart'; +import 'package:paperless_mobile/features/login/model/reachability_status.dart'; + +class ServerReachabilityErrorInterceptor extends Interceptor { + static const _missingClientCertText = "No required SSL certificate was sent"; + + @override + void onError(DioError err, ErrorInterceptorHandler handler) { + if (err.response?.statusCode == 400) { + final message = err.response?.data; + if (message is String && message.contains(_missingClientCertText)) { + return _rejectWithStatus( + ReachabilityStatus.missingClientCertificate, + err, + handler, + ); + } + } + if (err.type == DioErrorType.connectTimeout) { + return _rejectWithStatus( + ReachabilityStatus.connectionTimeout, + err, + handler, + ); + } + final error = err.error; + if (error is SocketException) { + final code = error.osError?.errorCode; + if (code == OsErrorCodes.serverUnreachable.code || + code == OsErrorCodes.hostNotFound.code) { + return _rejectWithStatus( + ReachabilityStatus.unknownHost, + err, + handler, + ); + } + } + return _rejectWithStatus( + ReachabilityStatus.notReachable, + err, + handler, + ); + } +} + +void _rejectWithStatus( + ReachabilityStatus reachabilityStatus, + DioError err, + ErrorInterceptorHandler handler, +) { + handler.reject(DioError( + error: reachabilityStatus, + requestOptions: err.requestOptions, + response: err.response, + type: DioErrorType.other, + )); +} diff --git a/lib/core/security/session_manager.dart b/lib/core/security/session_manager.dart index 39c2c2c..bb57078 100644 --- a/lib/core/security/session_manager.dart +++ b/lib/core/security/session_manager.dart @@ -26,7 +26,6 @@ class SessionManager { (client) => client..badCertificateCallback = (cert, host, port) => true; dio.interceptors.addAll([ ...interceptors, - DioHttpErrorInterceptor(), PrettyDioLogger( compact: true, responseBody: false, diff --git a/lib/core/service/connectivity_status_service.dart b/lib/core/service/connectivity_status_service.dart index f7ed264..dca7eae 100644 --- a/lib/core/service/connectivity_status_service.dart +++ b/lib/core/service/connectivity_status_service.dart @@ -1,8 +1,12 @@ +import 'dart:developer'; import 'dart:io'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:dio/adapter.dart'; import 'package:dio/dio.dart'; +import 'package:paperless_mobile/core/global/os_error_codes.dart'; +import 'package:paperless_mobile/core/interceptor/server_reachability_error_interceptor.dart'; +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'; @@ -63,51 +67,30 @@ class ConnectivityStatusServiceImpl implements ConnectivityStatusService { if (!RegExp(r"^https?://.*").hasMatch(serverAddress)) { return ReachabilityStatus.unknown; } - late SecurityContext context = SecurityContext(); try { - if (clientCertificate != null) { - context - ..usePrivateKeyBytes( - clientCertificate.bytes, - password: clientCertificate.passphrase, - ) - ..useCertificateChainBytes( - clientCertificate.bytes, - password: clientCertificate.passphrase, - ) - ..setTrustedCertificatesBytes( - clientCertificate.bytes, - password: clientCertificate.passphrase, - ); - } + SessionManager manager = + SessionManager([ServerReachabilityErrorInterceptor()]) + ..updateSettings(clientCertificate: clientCertificate) + ..client.options.connectTimeout = 5000 + ..client.options.receiveTimeout = 5000; - final adapter = DefaultHttpClientAdapter() - ..onHttpClientCreate = (client) => HttpClient(context: context) - ..badCertificateCallback = - (X509Certificate cert, String host, int port) => true; - final Dio dio = Dio()..httpClientAdapter = adapter; - - final response = await dio.get('$serverAddress/api/'); + final response = await manager.client.get('$serverAddress/api/'); if (response.statusCode == 200) { return ReachabilityStatus.reachable; } return ReachabilityStatus.notReachable; } on DioError catch (error) { - if (error.error is String) { - if (error.response?.data is String) { - if ((error.response!.data as String) - .contains("No required SSL certificate was sent")) { - return ReachabilityStatus.missingClientCertificate; - } - } + if (error.type == DioErrorType.other && + error.error is ReachabilityStatus) { + return error.error as ReachabilityStatus; } - return ReachabilityStatus.notReachable; } on TlsException catch (error) { - if (error.osError?.errorCode == 318767212) { - //INCORRECT_PASSWORD for certificate + final code = error.osError?.errorCode; + if (code == OsErrorCodes.invalidClientCertConfig.code) { + // Missing client cert passphrase return ReachabilityStatus.invalidClientCertificateConfiguration; } - return ReachabilityStatus.notReachable; } + return ReachabilityStatus.notReachable; } } diff --git a/lib/core/widgets/documents_list_loading_widget.dart b/lib/core/widgets/documents_list_loading_widget.dart index f1acb1f..6f0f920 100644 --- a/lib/core/widgets/documents_list_loading_widget.dart +++ b/lib/core/widgets/documents_list_loading_widget.dart @@ -1,6 +1,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:shimmer/shimmer.dart'; class DocumentsListLoadingWidget extends StatelessWidget { @@ -19,86 +20,69 @@ class DocumentsListLoadingWidget extends StatelessWidget { @override Widget build(BuildContext context) { - return SizedBox( - height: MediaQuery.of(context).size.height, - width: double.infinity, - child: Column( - mainAxisSize: MainAxisSize.max, - children: [ - Expanded( - child: Shimmer.fromColors( - baseColor: Theme.of(context).brightness == Brightness.light - ? Colors.grey[300]! - : Colors.grey[900]!, - highlightColor: Theme.of(context).brightness == Brightness.light - ? Colors.grey[100]! - : Colors.grey[600]!, - child: Column( - children: [ - ...above, - Expanded( - child: ListView.builder( - physics: const NeverScrollableScrollPhysics(), - itemBuilder: (context, index) { - final r = Random(index); - final tagCount = r.nextInt(tags.length + 1); - final correspondentLength = correspondentLengths[ - r.nextInt(correspondentLengths.length - 1)]; - final titleLength = - titleLengths[r.nextInt(titleLengths.length - 1)]; - return ListTile( - isThreeLine: true, - leading: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Container( - color: Colors.white, - height: 50, - width: 35, - ), - ), - title: Container( - padding: const EdgeInsets.symmetric(vertical: 2.0), - width: correspondentLength, - height: fontSize, - color: Colors.white, - ), - subtitle: Padding( - padding: const EdgeInsets.symmetric(vertical: 2.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - Container( - padding: - const EdgeInsets.symmetric(vertical: 2.0), - height: fontSize, - width: titleLength, - color: Colors.white, - ), - Wrap( - spacing: 2.0, - children: List.generate( - tagCount, - (index) => InputChip( - label: Text(tags[r.nextInt(tags.length)]), - ), - ), - ), - ], - ), - ), - ); - }, - itemCount: 25, + return ListView( + children: [ + ...above, + ...List.generate(25, (idx) { + final r = Random(idx); + final tagCount = r.nextInt(tags.length + 1); + final correspondentLength = + correspondentLengths[r.nextInt(correspondentLengths.length - 1)]; + final titleLength = titleLengths[r.nextInt(titleLengths.length - 1)]; + return Shimmer.fromColors( + baseColor: Theme.of(context).brightness == Brightness.light + ? Colors.grey[300]! + : Colors.grey[900]!, + highlightColor: Theme.of(context).brightness == Brightness.light + ? Colors.grey[100]! + : Colors.grey[600]!, + child: ListTile( + contentPadding: const EdgeInsets.all(8), + dense: true, + isThreeLine: true, + leading: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Container( + color: Colors.white, + height: 50, + width: 35, + ), + ), + title: Container( + padding: const EdgeInsets.symmetric(vertical: 2.0), + width: correspondentLength, + height: fontSize, + color: Colors.white, + ), + subtitle: Padding( + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Container( + padding: const EdgeInsets.symmetric(vertical: 2.0), + height: fontSize, + width: titleLength, + color: Colors.white, ), - ), - ...below, - ], + Wrap( + spacing: 2.0, + children: List.generate( + tagCount, + (index) => InputChip( + label: Text(tags[r.nextInt(tags.length)]), + ), + ), + ).paddedOnly(top: 4), + ], + ), ), ), - ), - ], - ), + ); + }).toList(), + ...below, + ], ); } } diff --git a/lib/core/widgets/hint_card.dart b/lib/core/widgets/hint_card.dart new file mode 100644 index 0000000..44047a1 --- /dev/null +++ b/lib/core/widgets/hint_card.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/generated/l10n.dart'; + +class HintCard extends StatelessWidget { + final String hintText; + final double elevation; + final VoidCallback onHintAcknowledged; + final bool show; + const HintCard({ + super.key, + required this.hintText, + required this.onHintAcknowledged, + this.elevation = 1, + required this.show, + }); + + @override + Widget build(BuildContext context) { + return AnimatedCrossFade( + sizeCurve: Curves.elasticOut, + crossFadeState: + show ? CrossFadeState.showFirst : CrossFadeState.showSecond, + secondChild: const SizedBox.shrink(), + duration: const Duration(milliseconds: 500), + firstChild: Card( + elevation: elevation, + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.tips_and_updates_outlined, + color: Theme.of(context).hintColor, + ).padded(), + Align( + alignment: Alignment.center, + child: Text( + hintText, + softWrap: true, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + Align( + alignment: Alignment.bottomRight, + child: TextButton( + child: Text(S.of(context).genericAcknowledgeLabel), + onPressed: onHintAcknowledged, + ), + ), + ], + ).padded(), + ).padded(), + ); + } +} 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 59c35b9..6494e4f 100644 --- a/lib/features/document_details/view/pages/document_details_page.dart +++ b/lib/features/document_details/view/pages/document_details_page.dart @@ -8,6 +8,7 @@ import 'package:intl/intl.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; import 'package:paperless_mobile/core/widgets/highlighted_text.dart'; +import 'package:paperless_mobile/core/widgets/hint_card.dart'; import 'package:paperless_mobile/core/widgets/offline_widget.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/document_details/bloc/document_details_cubit.dart'; @@ -77,7 +78,7 @@ class _DocumentDetailsPageState extends State { color: Colors.white, ), ), - badgeColor: Theme.of(context).colorScheme.error, + badgeColor: Colors.red, //TODO: Wait for stable version of m3, then use AlignmentDirectional.topEnd ); }, @@ -190,18 +191,16 @@ class _DocumentDetailsPageState extends State { children: [ _buildDocumentOverview( state.document, - widget.titleAndContentQueryString, ), _buildDocumentContentView( state.document, - widget.titleAndContentQueryString, state, ), _buildDocumentMetaDataView( state.document, ), - ].padded(), - ); + ], + ).paddedSymmetrically(horizontal: 8); }, ), ), @@ -216,23 +215,34 @@ class _DocumentDetailsPageState extends State { Navigator.push( context, MaterialPageRoute( - builder: (context) => BlocProvider.value( - value: EditDocumentCubit( - document, - documentsApi: context.read(), - correspondentRepository: context.read(), - documentTypeRepository: context.read(), - storagePathRepository: context.read(), - tagRepository: context.read(), - ), + builder: (_) => MultiBlocProvider( + providers: [ + BlocProvider.value( + value: EditDocumentCubit( + document, + documentsApi: context.read(), + correspondentRepository: context.read(), + documentTypeRepository: context.read(), + storagePathRepository: context.read(), + tagRepository: context.read(), + ), + ), + BlocProvider.value( + value: cubit, + ), + ], child: BlocListener( listenWhen: (previous, current) => previous.document != current.document, listener: (context, state) { cubit.replaceDocument(state.document); }, - child: DocumentEditPage( - suggestions: cubit.state.suggestions, + child: BlocBuilder( + builder: (context, state) { + return DocumentEditPage( + suggestions: state.suggestions, + ); + }, ), ), ), @@ -273,8 +283,9 @@ class _DocumentDetailsPageState extends State { .documentArchiveSerialNumberPropertyLongLabel, content: document.archiveSerialNumber != null ? Text(document.archiveSerialNumber.toString()) - : OutlinedButton( - child: Text(S + : TextButton.icon( + icon: const Icon(Icons.archive), + label: Text(S .of(context) .documentDetailsPageAssignAsnButtonLabel), onPressed: widget.allowEdit @@ -321,38 +332,46 @@ class _DocumentDetailsPageState extends State { Widget _buildDocumentContentView( DocumentModel document, - String? match, DocumentDetailsState state, ) { - return ListView( - children: [ - HighlightedText( - text: (state.isFullContentLoaded - ? state.fullContent - : document.content) ?? - "", - highlights: match == null ? [] : match.split(" "), - style: Theme.of(context).textTheme.bodyMedium, - caseSensitive: false, - ), - if (!state.isFullContentLoaded && (document.content ?? '').isNotEmpty) - TextButton( - child: Text("Show full content ..."), - onPressed: () { - context.read().loadFullContent(); - }, + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + HighlightedText( + text: (state.isFullContentLoaded + ? state.fullContent + : document.content) ?? + "", + highlights: widget.titleAndContentQueryString != null + ? widget.titleAndContentQueryString!.split(" ") + : [], + style: Theme.of(context).textTheme.bodyMedium, + caseSensitive: false, ), - ], - ).paddedOnly(top: 8); + if (!state.isFullContentLoaded && (document.content ?? '').isNotEmpty) + Align( + alignment: Alignment.bottomCenter, + child: TextButton( + child: + Text(S.of(context).documentDetailsPageLoadFullContentLabel), + onPressed: () { + context.read().loadFullContent(); + }, + ), + ), + ], + ).padded(8).paddedOnly(top: 14), + ); } - Widget _buildDocumentOverview(DocumentModel document, String? match) { + Widget _buildDocumentOverview(DocumentModel document) { return ListView( children: [ _DetailsItem( content: HighlightedText( text: document.title, - highlights: match?.split(" ") ?? [], + highlights: widget.titleAndContentQueryString?.split(" ") ?? [], style: Theme.of(context).textTheme.bodyLarge, ), label: S.of(context).documentTitlePropertyLabel, diff --git a/lib/features/documents/view/pages/document_edit_page.dart b/lib/features/documents/view/pages/document_edit_page.dart index 823a921..df2913d 100644 --- a/lib/features/documents/view/pages/document_edit_page.dart +++ b/lib/features/documents/view/pages/document_edit_page.dart @@ -54,7 +54,7 @@ class _DocumentEditPageState extends State { floatingActionButton: FloatingActionButton.extended( onPressed: () => _onSubmit(state.document), icon: const Icon(Icons.save), - label: Text(S.of(context).genericActionSaveLabel), + label: Text(S.of(context).genericActionUpdateLabel), ), appBar: AppBar( title: Text(S.of(context).documentEditPageTitle), @@ -75,31 +75,56 @@ class _DocumentEditPageState extends State { ), child: FormBuilder( key: _formKey, - child: ListView(children: [ - _buildTitleFormField(state.document.title).padded(), - _buildCreatedAtFormField(state.document.created).padded(), - _buildDocumentTypeFormField( - state.document.documentType, - state.documentTypes, - ).padded(), - _buildCorrespondentFormField( - state.document.correspondent, - state.correspondents, - ).padded(), - _buildStoragePathFormField( - state.document.storagePath, - state.storagePaths, - ).padded(), - TagFormField( - initialValue: - IdsTagsQuery.included(state.document.tags.toList()), - notAssignedSelectable: false, - anyAssignedSelectable: false, - excludeAllowed: false, - name: fkTags, - selectableOptions: state.tags, - ).padded(), - ]), + child: ListView( + children: [ + _buildTitleFormField(state.document.title).padded(), + _buildCreatedAtFormField(state.document.created).padded(), + _buildDocumentTypeFormField( + state.document.documentType, + state.documentTypes, + ).padded(), + _buildCorrespondentFormField( + state.document.correspondent, + state.correspondents, + ).padded(), + _buildStoragePathFormField( + state.document.storagePath, + state.storagePaths, + ).padded(), + TagFormField( + initialValue: + IdsTagsQuery.included(state.document.tags.toList()), + notAssignedSelectable: false, + anyAssignedSelectable: false, + excludeAllowed: false, + name: fkTags, + selectableOptions: state.tags, + suggestions: widget.suggestions.hasSuggestedTags + ? _buildSuggestionsSkeleton( + suggestions: widget.suggestions.storagePaths, + itemBuilder: (context, itemData) => ActionChip( + label: Text(state.tags[itemData]!.name), + onPressed: () { + final currentTags = _formKey.currentState + ?.fields[fkTags] as TagsQuery; + if (currentTags is IdsTagsQuery) { + _formKey.currentState?.fields[fkTags] + ?.didChange((IdsTagsQuery.fromIds( + [...currentTags.ids, itemData]))); + } else { + _formKey.currentState?.fields[fkTags] + ?.didChange( + (IdsTagsQuery.fromIds([itemData]))); + } + }, + ), + ) + : null, + ).padded(), + const SizedBox( + height: 64), // Prevent tags from being hidden by fab + ], + ), ), )); }, @@ -267,7 +292,7 @@ class _DocumentEditPageState extends State { _buildSuggestionsSkeleton( suggestions: widget.suggestions.dates, itemBuilder: (context, itemData) => ActionChip( - label: Text(DateFormat.yMd().format(itemData)), + label: Text(DateFormat.yMMMd().format(itemData)), onPressed: () => _formKey.currentState?.fields[fkCreatedDate] ?.didChange(itemData), ), diff --git a/lib/features/documents/view/pages/documents_page.dart b/lib/features/documents/view/pages/documents_page.dart index 2f7200c..547f4a1 100644 --- a/lib/features/documents/view/pages/documents_page.dart +++ b/lib/features/documents/view/pages/documents_page.dart @@ -14,6 +14,7 @@ import 'package:paperless_mobile/features/documents/view/widgets/documents_empty import 'package:paperless_mobile/features/documents/view/widgets/list/adaptive_documents_view.dart'; import 'package:paperless_mobile/features/documents/view/widgets/new_items_loading_widget.dart'; import 'package:paperless_mobile/features/documents/view/widgets/search/document_filter_panel.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/selection/bulk_delete_confirmation_dialog.dart'; import 'package:paperless_mobile/features/documents/view/widgets/sort_documents_button.dart'; import 'package:paperless_mobile/features/home/view/widget/info_drawer.dart'; import 'package:paperless_mobile/features/labels/bloc/providers/labels_bloc_provider.dart'; @@ -95,156 +96,196 @@ class _DocumentsPageState extends State { @override Widget build(BuildContext context) { - return BlocConsumer( + return BlocListener( listenWhen: (previous, current) => - previous != ConnectivityState.connected && - current == ConnectivityState.connected, + !previous.isSuccess && current.isSuccess, listener: (context, state) { - try { - context.read().reload(); - } on PaperlessServerException catch (error, stackTrace) { - showErrorMessage(context, error, stackTrace); - } - }, - builder: (context, connectivityState) { - const linearProgressIndicatorHeight = 4.0; - return Scaffold( - drawer: BlocProvider.value( - value: context.read(), - child: InfoDrawer( - afterInboxClosed: () => context.read().reload(), - ), + showSnackBar( + context, + S.of(context).documentsPageNewDocumentAvailableText, + action: SnackBarActionConfig( + label: S + .of(context) + .documentUploadProcessingSuccessfulReloadActionText, + onPressed: () { + context.read().acknowledgeCurrentTask(); + context.read().reload(); + }, ), - appBar: PreferredSize( - preferredSize: const Size.fromHeight( - kToolbarHeight + linearProgressIndicatorHeight, + duration: const Duration(seconds: 10), + ); + }, + child: BlocConsumer( + listenWhen: (previous, current) => + previous != ConnectivityState.connected && + current == ConnectivityState.connected, + listener: (context, state) { + try { + context.read().reload(); + } on PaperlessServerException catch (error, stackTrace) { + showErrorMessage(context, error, stackTrace); + } + }, + builder: (context, connectivityState) { + const linearProgressIndicatorHeight = 4.0; + return Scaffold( + drawer: BlocProvider.value( + value: context.read(), + child: InfoDrawer( + afterInboxClosed: () => context.read().reload(), + ), ), - child: BlocBuilder( - builder: (context, state) { - return AppBar( - title: Text( - "${S.of(context).documentsPageTitle} (${_formatDocumentCount(state.count)})", - ), - actions: [ - const SortDocumentsButton(), - BlocBuilder( - builder: (context, settingsState) => IconButton( - icon: Icon( - settingsState.preferredViewType == ViewType.grid - ? Icons.list - : Icons.grid_view_rounded, - ), - onPressed: () { - // Reset saved view widget position as scroll offset will be reset anyway. - setState(() { - _offset = 0; - _last = 0; - }); - final cubit = - context.read(); - cubit.setViewType( - cubit.state.preferredViewType.toggle()); - }, + appBar: PreferredSize( + preferredSize: const Size.fromHeight( + kToolbarHeight + linearProgressIndicatorHeight, + ), + child: BlocBuilder( + builder: (context, state) { + if (state.selection.isEmpty) { + return AppBar( + title: Text( + "${S.of(context).documentsPageTitle} (${_formatDocumentCount(state.count)})", ), + actions: [ + const SortDocumentsButton(), + BlocBuilder( + builder: (context, settingsState) => IconButton( + icon: Icon( + settingsState.preferredViewType == ViewType.grid + ? Icons.list + : Icons.grid_view_rounded, + ), + onPressed: () { + // Reset saved view widget position as scroll offset will be reset anyway. + setState(() { + _offset = 0; + _last = 0; + }); + final cubit = + context.read(); + cubit.setViewType( + cubit.state.preferredViewType.toggle()); + }, + ), + ), + ], + bottom: PreferredSize( + preferredSize: const Size.fromHeight( + linearProgressIndicatorHeight), + child: state.isLoading + ? const LinearProgressIndicator() + : const SizedBox(height: 4.0), + ), + ); + } else { + return AppBar( + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => + context.read().resetSelection(), + ), + title: Text( + '${state.selection.length} ${S.of(context).documentsSelectedText}'), + actions: [ + IconButton( + icon: const Icon(Icons.delete), + onPressed: () => _onDelete(context, state), + ), + ], + ); + } + }, + ), + ), + floatingActionButton: BlocBuilder( + builder: (context, state) { + final appliedFiltersCount = state.filter.appliedFiltersCount; + return b.Badge( + position: b.BadgePosition.topEnd(top: -12, end: -6), + showBadge: appliedFiltersCount > 0, + badgeContent: Text( + '$appliedFiltersCount', + style: const TextStyle( + color: Colors.white, ), - ], - bottom: PreferredSize( - preferredSize: - const Size.fromHeight(linearProgressIndicatorHeight), - child: state.isLoading - ? const LinearProgressIndicator() - : const SizedBox(height: 4.0), + ), + animationType: b.BadgeAnimationType.fade, + badgeColor: Colors.red, + child: FloatingActionButton( + child: const Icon(Icons.filter_alt_outlined), + onPressed: _openDocumentFilter, ), ); }, ), - ), - floatingActionButton: BlocBuilder( - builder: (context, state) { - final appliedFiltersCount = state.filter.appliedFiltersCount; - return b.Badge( - position: b.BadgePosition.topEnd(top: -12, end: -6), - showBadge: appliedFiltersCount > 0, - badgeContent: Text( - '$appliedFiltersCount', - style: const TextStyle( - color: Colors.white, - ), - ), - animationType: b.BadgeAnimationType.fade, - badgeColor: Theme.of(context).colorScheme.error, - child: FloatingActionButton( - child: const Icon(Icons.filter_alt_outlined), - onPressed: _openDocumentFilter, - ), - ); - }, - ), - resizeToAvoidBottomInset: true, - body: WillPopScope( - onWillPop: () async { - if (context.read().state.selection.isNotEmpty) { - context.read().resetSelection(); - } - return false; - }, - child: RefreshIndicator( - onRefresh: _onRefresh, - notificationPredicate: (_) => connectivityState.isConnected, - child: BlocBuilder( - builder: (context, taskState) { - return Stack( - children: [ - _buildBody(connectivityState), - Positioned( - left: 0, - right: 0, - top: _offset, - child: BlocBuilder( - builder: (context, state) { - return ColoredBox( - color: Theme.of(context).colorScheme.background, - child: SavedViewSelectionWidget( - height: _savedViewWidgetHeight, - currentFilter: state.filter, - enabled: state.selection.isEmpty && - connectivityState.isConnected, - ), - ); - }, + resizeToAvoidBottomInset: true, + body: WillPopScope( + onWillPop: () async { + if (context.read().state.selection.isNotEmpty) { + context.read().resetSelection(); + } + return false; + }, + child: RefreshIndicator( + onRefresh: _onRefresh, + notificationPredicate: (_) => connectivityState.isConnected, + child: BlocBuilder( + builder: (context, taskState) { + return Stack( + children: [ + _buildBody(connectivityState), + Positioned( + left: 0, + right: 0, + top: _offset, + child: BlocBuilder( + builder: (context, state) { + return ColoredBox( + color: Theme.of(context).colorScheme.background, + child: SavedViewSelectionWidget( + height: _savedViewWidgetHeight, + currentFilter: state.filter, + enabled: state.selection.isEmpty && + connectivityState.isConnected, + ), + ); + }, + ), ), - ), - if (taskState.task != null && - taskState.isSuccess && - !taskState.task!.acknowledged) - _buildNewDocumentAvailableButton(context), - ], - ); - }, + ], + ); + }, + ), ), ), - ), - ); - }, + ); + }, + ), ); } - Align _buildNewDocumentAvailableButton(BuildContext context) { - return Align( - alignment: Alignment.bottomLeft, - child: FilledButton( - style: ButtonStyle( - backgroundColor: - MaterialStatePropertyAll(Theme.of(context).colorScheme.error), - ), - child: Text("New document available!"), - onPressed: () { - context.read().acknowledgeCurrentTask(); - context.read().reload(); - }, - ).paddedOnly(bottom: 24, left: 24), - ); + void _onDelete(BuildContext context, DocumentsState documentsState) async { + final shouldDelete = await showDialog( + context: context, + builder: (context) => + BulkDeleteConfirmationDialog(state: documentsState), + ) ?? + false; + if (shouldDelete) { + try { + await context + .read() + .bulkRemove(documentsState.selection); + showSnackBar( + context, + S.of(context).documentsPageBulkDeleteSuccessfulText, + ); + context.read().resetSelection(); + } on PaperlessServerException catch (error, stackTrace) { + showErrorMessage(context, error, stackTrace); + } + } } void _openDocumentFilter() async { diff --git a/lib/features/documents/view/widgets/list/adaptive_documents_view.dart b/lib/features/documents/view/widgets/list/adaptive_documents_view.dart index d5fe813..0fd7854 100644 --- a/lib/features/documents/view/widgets/list/adaptive_documents_view.dart +++ b/lib/features/documents/view/widgets/list/adaptive_documents_view.dart @@ -42,6 +42,7 @@ class AdaptiveDocumentsView extends StatelessWidget { Widget build(BuildContext context) { return CustomScrollView( controller: scrollController, + physics: const AlwaysScrollableScrollPhysics(), slivers: [ SliverToBoxAdapter(child: beforeItems), if (viewType == ViewType.list) _buildListView() else _buildGridView(), diff --git a/lib/features/documents/view/widgets/list/document_list_item.dart b/lib/features/documents/view/widgets/list/document_list_item.dart index bec41fd..e73c97b 100644 --- a/lib/features/documents/view/widgets/list/document_list_item.dart +++ b/lib/features/documents/view/widgets/list/document_list_item.dart @@ -38,7 +38,6 @@ class DocumentListItem extends StatelessWidget { Widget build(BuildContext context) { return SizedBox( child: ListTile( - trailing: Text("${document.id}"), dense: true, selected: isSelected, onTap: () => _onTap(), diff --git a/lib/features/edit_label/view/edit_label_page.dart b/lib/features/edit_label/view/edit_label_page.dart index a04aae5..dafb8b8 100644 --- a/lib/features/edit_label/view/edit_label_page.dart +++ b/lib/features/edit_label/view/edit_label_page.dart @@ -65,7 +65,7 @@ class EditLabelForm extends StatelessWidget { initialValue: label, fromJsonT: fromJsonT, submitButtonConfig: SubmitButtonConfig( - icon: const Icon(Icons.done), + icon: const Icon(Icons.save), label: Text(S.of(context).genericActionUpdateLabel), onSubmit: context.read>().update, ), diff --git a/lib/features/home/view/widget/info_drawer.dart b/lib/features/home/view/widget/info_drawer.dart index a43d0eb..7a615d9 100644 --- a/lib/features/home/view/widget/info_drawer.dart +++ b/lib/features/home/view/widget/info_drawer.dart @@ -20,10 +20,8 @@ import 'package:paperless_mobile/features/settings/bloc/application_settings_cub import 'package:paperless_mobile/features/settings/view/settings_page.dart'; import 'package:paperless_mobile/generated/l10n.dart'; import 'package:paperless_mobile/util.dart'; -import 'package:provider/provider.dart'; import 'package:url_launcher/link.dart'; import 'package:url_launcher/url_launcher_string.dart'; -import 'package:collection/collection.dart'; class InfoDrawer extends StatefulWidget { final VoidCallback? afterInboxClosed; @@ -115,151 +113,167 @@ class _InfoDrawerState extends State { ), child: Drawer( shape: const RoundedRectangleBorder( - borderRadius: const BorderRadius.only( + borderRadius: BorderRadius.only( topRight: Radius.circular(16.0), bottomRight: Radius.circular(16.0), ), ), - child: ListView( - children: [ - DrawerHeader( - padding: const EdgeInsets.only( - top: 8, - left: 8, - bottom: 0, - right: 8, - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Image.asset( - 'assets/logos/paperless_logo_white.png', - height: 32, - width: 32, - color: - Theme.of(context).colorScheme.onPrimaryContainer, - ).paddedOnly(right: 8.0), - Text( - S.of(context).appTitleText, - style: Theme.of(context) - .textTheme - .headlineSmall - ?.copyWith( - color: Theme.of(context) - .colorScheme - .onPrimaryContainer, - ), - ), - ], - ), - Align( - alignment: Alignment.bottomRight, - child: BlocBuilder( - builder: (context, state) { - if (!state.isLoaded) { - return Container(); - } - final info = state.information!; - return Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - ListTile( - contentPadding: EdgeInsets.zero, - dense: true, - title: Text( - S.of(context).appDrawerHeaderLoggedInAsText + - (info.username ?? '?'), - style: Theme.of(context).textTheme.bodyMedium, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.end, - maxLines: 1, - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - state.information!.host ?? '', - style: Theme.of(context) - .textTheme - .bodyMedium, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.end, - maxLines: 1, - ), - Text( - '${S.of(context).serverInformationPaperlessVersionText} ${info.version} (API v${info.apiVersion})', - style: - Theme.of(context).textTheme.bodySmall, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.end, - maxLines: 1, - ), - ], - ), - isThreeLine: true, - ), - ], - ); - }, - ), - ), - ], - ), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer, - ), + child: Theme( + data: Theme.of(context).copyWith( + listTileTheme: ListTileThemeData( + tileColor: Colors.transparent, ), - ...[ - ListTile( - title: Text(S.of(context).bottomNavInboxPageLabel), - leading: const Icon(Icons.inbox), - onTap: () => _onOpenInbox(), - shape: listtTileShape, - ), - ListTile( - leading: const Icon(Icons.settings), - shape: listtTileShape, - title: Text( - S.of(context).appDrawerSettingsLabel, + ), + child: ListView( + children: [ + DrawerHeader( + padding: const EdgeInsets.only( + top: 8, + left: 8, + bottom: 0, + right: 8, ), - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => BlocProvider.value( - value: context.read(), - child: const SettingsPage(), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Image.asset( + 'assets/logos/paperless_logo_white.png', + height: 32, + width: 32, + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + ).paddedOnly(right: 8.0), + Text( + S.of(context).appTitleText, + style: Theme.of(context) + .textTheme + .headlineSmall + ?.copyWith( + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + ), + ), + ], + ), + Align( + alignment: Alignment.bottomRight, + child: BlocBuilder( + builder: (context, state) { + if (!state.isLoaded) { + return Container(); + } + final info = state.information!; + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + ListTile( + contentPadding: EdgeInsets.zero, + dense: true, + title: Text( + S + .of(context) + .appDrawerHeaderLoggedInAsText + + (info.username ?? '?'), + style: + Theme.of(context).textTheme.bodyMedium, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + maxLines: 1, + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + state.information!.host ?? '', + style: Theme.of(context) + .textTheme + .bodyMedium, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + maxLines: 1, + ), + Text( + '${S.of(context).serverInformationPaperlessVersionText} ${info.version} (API v${info.apiVersion})', + style: Theme.of(context) + .textTheme + .bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + maxLines: 1, + ), + ], + ), + isThreeLine: true, + ), + ], + ); + }, + ), + ), + ], + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + ), + ), + ...[ + ListTile( + title: Text(S.of(context).bottomNavInboxPageLabel), + leading: const Icon(Icons.inbox), + onTap: () => _onOpenInbox(), + shape: listtTileShape, + ), + ListTile( + leading: const Icon(Icons.settings), + shape: listtTileShape, + title: Text( + S.of(context).appDrawerSettingsLabel, + ), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => BlocProvider.value( + value: context.read(), + child: const SettingsPage(), + ), ), ), ), - ), - ListTile( - leading: const Icon(Icons.bug_report), - title: Text(S.of(context).appDrawerReportBugLabel), - onTap: () { - launchUrlString( - 'https://github.com/astubenbord/paperless-mobile/issues/new'); - }, - shape: listtTileShape, - ), - ListTile( - title: Text(S.of(context).appDrawerAboutLabel), - leading: Icon(Icons.info_outline_rounded), - onTap: _onShowAboutDialog, - shape: listtTileShape, - ), - ListTile( - leading: const Icon(Icons.logout), - title: Text(S.of(context).appDrawerLogoutLabel), - shape: listtTileShape, - onTap: () { - _onLogout(); - }, - ) + const Divider( + indent: 16, + endIndent: 16, + ), + ListTile( + leading: const Icon(Icons.bug_report), + title: Text(S.of(context).appDrawerReportBugLabel), + onTap: () { + launchUrlString( + 'https://github.com/astubenbord/paperless-mobile/issues/new'); + }, + shape: listtTileShape, + ), + ListTile( + title: Text(S.of(context).appDrawerAboutLabel), + leading: Icon(Icons.info_outline_rounded), + onTap: _onShowAboutDialog, + shape: listtTileShape, + ), + ListTile( + leading: const Icon(Icons.logout), + title: Text(S.of(context).appDrawerLogoutLabel), + shape: listtTileShape, + onTap: () { + _onLogout(); + }, + ) + ], ], - ], + ), ), ), ), @@ -295,11 +309,10 @@ class _InfoDrawerState extends State { create: (context) => InboxCubit( context.read>(), context.read(), - )..loadInbox(), + )..initializeInbox(), child: const InboxPage(), ), ), - maintainState: false, ), ); widget.afterInboxClosed?.call(); diff --git a/lib/features/inbox/bloc/inbox_cubit.dart b/lib/features/inbox/bloc/inbox_cubit.dart index f1b507a..eb78c68 100644 --- a/lib/features/inbox/bloc/inbox_cubit.dart +++ b/lib/features/inbox/bloc/inbox_cubit.dart @@ -1,10 +1,11 @@ import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/repository/state/impl/tag_repository_state.dart'; import 'package:paperless_mobile/features/inbox/bloc/state/inbox_state.dart'; -class InboxCubit extends Cubit { +class InboxCubit extends HydratedCubit { final LabelRepository _tagsRepository; final PaperlessDocumentsApi _documentsApi; @@ -14,17 +15,20 @@ class InboxCubit extends Cubit { /// /// Fetches inbox tag ids and loads the inbox items (documents). /// - Future loadInbox() async { + Future initializeInbox() async { + if (state.isLoaded) return; final inboxTags = await _tagsRepository.findAll().then( (tags) => tags.where((t) => t.isInboxTag ?? false).map((t) => t.id!), ); if (inboxTags.isEmpty) { // no inbox tags = no inbox items. - return emit(const InboxState( - isLoaded: true, - inboxItems: [], - inboxTags: [], - )); + return emit( + state.copyWith( + isLoaded: true, + inboxItems: [], + inboxTags: [], + ), + ); } final inboxDocuments = await _documentsApi .findAll(DocumentFilter( @@ -32,7 +36,7 @@ class InboxCubit extends Cubit { sortField: SortField.added, )) .then((psr) => psr.results); - final newState = InboxState( + final newState = state.copyWith( isLoaded: true, inboxItems: inboxDocuments, inboxTags: inboxTags, @@ -57,9 +61,8 @@ class InboxCubit extends Cubit { ), ); emit( - InboxState( + state.copyWith( isLoaded: true, - inboxTags: state.inboxTags, inboxItems: state.inboxItems.where((doc) => doc.id != document.id), ), ); @@ -79,14 +82,11 @@ class InboxCubit extends Cubit { overwriteTags: true, ); await _documentsApi.update(updatedDoc); - emit( - InboxState( - isLoaded: true, - inboxItems: [...state.inboxItems, updatedDoc] - ..sort((d1, d2) => d2.added.compareTo(d1.added)), - inboxTags: state.inboxTags, - ), - ); + emit(state.copyWith( + isLoaded: true, + inboxItems: [...state.inboxItems, updatedDoc] + ..sort((d1, d2) => d2.added.compareTo(d1.added)), + )); } /// @@ -99,12 +99,40 @@ class InboxCubit extends Cubit { state.inboxTags, ), ); - emit( - InboxState( - isLoaded: true, - inboxTags: state.inboxTags, - inboxItems: [], - ), - ); + emit(state.copyWith( + isLoaded: true, + inboxItems: [], + )); + } + + void replaceUpdatedDocument(DocumentModel document) { + if (document.tags.any((id) => state.inboxTags.contains(id))) { + // If replaced document still has inbox tag assigned: + emit(state.copyWith( + inboxItems: + state.inboxItems.map((e) => e.id == document.id ? document : e), + )); + } else { + // Remove tag from inbox. + emit( + state.copyWith( + inboxItems: + state.inboxItems.where((element) => element.id != document.id)), + ); + } + } + + void acknowledgeHint() { + emit(state.copyWith(isHintAcknowledged: true)); + } + + @override + InboxState fromJson(Map json) { + return InboxState.fromJson(json); + } + + @override + Map toJson(InboxState state) { + return state.toJson(); } } diff --git a/lib/features/inbox/bloc/state/inbox_state.dart b/lib/features/inbox/bloc/state/inbox_state.dart index 8dd2cac..782b8de 100644 --- a/lib/features/inbox/bloc/state/inbox_state.dart +++ b/lib/features/inbox/bloc/state/inbox_state.dart @@ -1,17 +1,54 @@ import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; import 'package:paperless_api/paperless_api.dart'; +import 'package:json_annotation/json_annotation.dart'; +part 'inbox_state.g.dart'; + +@JsonSerializable() class InboxState with EquatableMixin { + @JsonKey(ignore: true) final bool isLoaded; + + @JsonKey(ignore: true) final Iterable inboxTags; + + @JsonKey(ignore: true) final Iterable inboxItems; + final bool isHintAcknowledged; + const InboxState({ this.isLoaded = false, this.inboxTags = const [], this.inboxItems = const [], + this.isHintAcknowledged = false, }); @override - List get props => [isLoaded, inboxTags, inboxItems]; + List get props => [ + isLoaded, + inboxTags, + inboxItems, + isHintAcknowledged, + ]; + + InboxState copyWith({ + bool? isLoaded, + Iterable? inboxTags, + Iterable? inboxItems, + bool? isHintAcknowledged, + }) { + return InboxState( + isLoaded: isLoaded ?? this.isLoaded, + inboxItems: inboxItems ?? this.inboxItems, + inboxTags: inboxTags ?? this.inboxTags, + isHintAcknowledged: isHintAcknowledged ?? this.isHintAcknowledged, + ); + } + + factory InboxState.fromJson(Map json) => + _$InboxStateFromJson(json); + + Map toJson() => _$InboxStateToJson(this); } diff --git a/lib/features/inbox/bloc/state/inbox_state.g.dart b/lib/features/inbox/bloc/state/inbox_state.g.dart new file mode 100644 index 0000000..da3dfdb --- /dev/null +++ b/lib/features/inbox/bloc/state/inbox_state.g.dart @@ -0,0 +1,16 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'inbox_state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +InboxState _$InboxStateFromJson(Map json) => InboxState( + isHintAcknowledged: json['isHintAcknowledged'] as bool? ?? false, + ); + +Map _$InboxStateToJson(InboxState instance) => + { + 'isHintAcknowledged': instance.isHintAcknowledged, + }; diff --git a/lib/features/inbox/view/pages/inbox_page.dart b/lib/features/inbox/view/pages/inbox_page.dart index c3aa3c2..f4563f7 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/date_symbol_data_local.dart'; import 'package:intl/intl.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/widgets/documents_list_loading_widget.dart'; +import 'package:paperless_mobile/core/widgets/hint_card.dart'; import 'package:paperless_mobile/extensions/dart_extensions.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/inbox/bloc/inbox_cubit.dart'; @@ -113,7 +114,6 @@ class _InboxPageState extends State { delegate: SliverChildBuilderDelegate( childCount: entry.value.length, (context, index) => _buildListItem( - context, entry.value[index], ), ), @@ -124,7 +124,7 @@ class _InboxPageState extends State { .toList(); return RefreshIndicator( - onRefresh: () => context.read().loadInbox(), + onRefresh: () => context.read().initializeInbox(), child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -132,11 +132,12 @@ class _InboxPageState extends State { child: CustomScrollView( slivers: [ SliverToBoxAdapter( - child: Text( - S.of(context).inboxPageUsageHintText, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall, - ).padded(), + child: HintCard( + show: !state.isHintAcknowledged, + hintText: S.of(context).inboxPageUsageHintText, + onHintAcknowledged: () => + context.read().acknowledgeHint(), + ), ), ...slivers ], @@ -150,7 +151,7 @@ class _InboxPageState extends State { ); } - Widget _buildListItem(BuildContext context, DocumentModel doc) { + Widget _buildListItem(DocumentModel doc) { return Dismissible( direction: DismissDirection.endToStart, background: Row( @@ -170,7 +171,12 @@ class _InboxPageState extends State { ).padded(), confirmDismiss: (_) => _onItemDismissed(doc), key: UniqueKey(), - child: InboxItem(document: doc), + child: InboxItem( + document: doc, + onDocumentUpdated: (document) { + context.read().replaceUpdatedDocument(document); + }, + ), ); } diff --git a/lib/features/inbox/view/widgets/inbox_empty_widget.dart b/lib/features/inbox/view/widgets/inbox_empty_widget.dart index b23fce3..bf79d2a 100644 --- a/lib/features/inbox/view/widgets/inbox_empty_widget.dart +++ b/lib/features/inbox/view/widgets/inbox_empty_widget.dart @@ -16,7 +16,7 @@ class InboxEmptyWidget extends StatelessWidget { Widget build(BuildContext context) { return RefreshIndicator( key: _emptyStateRefreshIndicatorKey, - onRefresh: () => context.read().loadInbox(), + onRefresh: () => context.read().initializeInbox(), child: Center( child: Column( mainAxisSize: MainAxisSize.max, diff --git a/lib/features/inbox/view/widgets/inbox_item.dart b/lib/features/inbox/view/widgets/inbox_item.dart index 0e2865d..be56639 100644 --- a/lib/features/inbox/view/widgets/inbox_item.dart +++ b/lib/features/inbox/view/widgets/inbox_item.dart @@ -6,16 +6,18 @@ import 'package:paperless_mobile/core/repository/provider/label_repositories_pro import 'package:paperless_mobile/features/document_details/bloc/document_details_cubit.dart'; import 'package:paperless_mobile/features/document_details/view/pages/document_details_page.dart'; import 'package:paperless_mobile/features/documents/view/widgets/document_preview.dart'; +import 'package:paperless_mobile/features/inbox/bloc/inbox_cubit.dart'; import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart'; class InboxItem extends StatelessWidget { static const _a4AspectRatio = 1 / 1.4142; - + final void Function(DocumentModel model) onDocumentUpdated; final DocumentModel document; const InboxItem({ super.key, required this.document, + required this.onDocumentUpdated, }); @override @@ -45,23 +47,27 @@ class InboxItem extends StatelessWidget { ), ], ), - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => BlocProvider( - create: (context) => DocumentDetailsCubit( - context.read(), - document, - ), - child: const LabelRepositoriesProvider( - child: DocumentDetailsPage( - allowEdit: false, - isLabelClickable: false, + onTap: () async { + final returnedDocument = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => BlocProvider( + create: (context) => DocumentDetailsCubit( + context.read(), + document, + ), + child: const LabelRepositoriesProvider( + child: DocumentDetailsPage( + isLabelClickable: false, + ), ), ), ), - ), - ), + ); + if (returnedDocument != null) { + onDocumentUpdated(returnedDocument); + } + }, ); } } diff --git a/lib/features/labels/storage_path/view/widgets/storage_path_autofill_form_builder_field.dart b/lib/features/labels/storage_path/view/widgets/storage_path_autofill_form_builder_field.dart index 6d48559..b415fcf 100644 --- a/lib/features/labels/storage_path/view/widgets/storage_path_autofill_form_builder_field.dart +++ b/lib/features/labels/storage_path/view/widgets/storage_path_autofill_form_builder_field.dart @@ -63,7 +63,8 @@ class _StoragePathAutofillFormBuilderFieldState ), Wrap( alignment: WrapAlignment.start, - spacing: 8.0, + spacing: 4.0, + runSpacing: 4.0, children: [ InputChip( label: Text( diff --git a/lib/features/labels/tags/view/widgets/tags_form_field.dart b/lib/features/labels/tags/view/widgets/tags_form_field.dart index f1ef6e9..e5f6139 100644 --- a/lib/features/labels/tags/view/widgets/tags_form_field.dart +++ b/lib/features/labels/tags/view/widgets/tags_form_field.dart @@ -1,3 +1,5 @@ +import 'dart:developer'; + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; @@ -5,6 +7,7 @@ import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/repository/state/impl/tag_repository_state.dart'; +import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/edit_label/view/impl/add_tag_page.dart'; import 'package:paperless_mobile/generated/l10n.dart'; @@ -16,6 +19,7 @@ class TagFormField extends StatefulWidget { final bool anyAssignedSelectable; final bool excludeAllowed; final Map selectableOptions; + final Widget? suggestions; const TagFormField({ super.key, @@ -26,6 +30,7 @@ class TagFormField extends StatefulWidget { this.anyAssignedSelectable = true, this.excludeAllowed = true, required this.selectableOptions, + this.suggestions, }); @override @@ -37,7 +42,7 @@ class _TagFormFieldState extends State { static const _anyAssignedId = -2; late final TextEditingController _textEditingController; - bool _showCreationSuffixIcon = true; + bool _showCreationSuffixIcon = false; bool _showClearSuffixIcon = false; @override @@ -46,14 +51,19 @@ class _TagFormFieldState extends State { _textEditingController = TextEditingController() ..addListener(() { setState(() { - _showCreationSuffixIcon = widget.selectableOptions.values - .where( - (item) => item.name.toLowerCase().startsWith( - _textEditingController.text.toLowerCase(), - ), + _showCreationSuffixIcon = widget.selectableOptions.values.where( + (item) { + log(item.name + .toLowerCase() + .startsWith( + _textEditingController.text.toLowerCase(), ) - .isEmpty || - _textEditingController.text.isEmpty; + .toString()); + return item.name.toLowerCase().startsWith( + _textEditingController.text.toLowerCase(), + ); + }, + ).isEmpty; }); setState( () => _showClearSuffixIcon = _textEditingController.text.isNotEmpty, @@ -124,23 +134,33 @@ class _TagFormFieldState extends State { getImmediateSuggestions: true, animationStart: 1, itemBuilder: (context, data) { - if (data == _onlyNotAssignedId) { - return ListTile( - title: Text(S.of(context).labelNotAssignedText), - ); - } else if (data == _anyAssignedId) { - return ListTile( - title: Text(S.of(context).labelAnyAssignedText), - ); + late String? title; + switch (data) { + case _onlyNotAssignedId: + title = S.of(context).labelNotAssignedText; + break; + case _anyAssignedId: + title = S.of(context).labelAnyAssignedText; + break; + default: + title = widget.selectableOptions[data]?.name; } - final tag = widget.selectableOptions[data]!; + + final tag = widget.selectableOptions[data]; return ListTile( - leading: Icon( - Icons.circle, - color: tag.color, + dense: true, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), ), + style: ListTileStyle.list, + leading: data != _onlyNotAssignedId && data != _anyAssignedId + ? Icon( + Icons.circle, + color: tag?.color, + ) + : null, title: Text( - tag.name, + title ?? '', style: TextStyle( color: Theme.of(context).colorScheme.onBackground), ), @@ -165,10 +185,11 @@ class _TagFormFieldState extends State { direction: AxisDirection.up, ), if (field.value is OnlyNotAssignedTagsQuery) ...[ - _buildNotAssignedTag(field) + _buildNotAssignedTag(field).padded() ] else if (field.value is AnyAssignedTagsQuery) ...[ - _buildAnyAssignedTag(field) + _buildAnyAssignedTag(field).padded() ] else ...[ + if (widget.suggestions != null) widget.suggestions!, // field.value is IdsTagsQuery Wrap( alignment: WrapAlignment.start, @@ -183,7 +204,7 @@ class _TagFormFieldState extends State { ), ) .toList(), - ), + ).padded(), ] ], ); @@ -266,6 +287,7 @@ class _TagFormFieldState extends State { tag.name, style: TextStyle( color: tag.textColor, + decorationColor: tag.textColor, decoration: !isIncludedTag ? TextDecoration.lineThrough : null, decorationThickness: 2.0, ), diff --git a/lib/features/linked_documents_preview/view/pages/linked_documents_page.dart b/lib/features/linked_documents_preview/view/pages/linked_documents_page.dart index 7c6f36e..bdba0c6 100644 --- a/lib/features/linked_documents_preview/view/pages/linked_documents_page.dart +++ b/lib/features/linked_documents_preview/view/pages/linked_documents_page.dart @@ -25,9 +25,6 @@ class _LinkedDocumentsPageState extends State { ), body: BlocBuilder( builder: (context, state) { - if (!state.isLoaded) { - return const DocumentsListLoadingWidget(); - } return Column( children: [ Text( @@ -35,37 +32,41 @@ class _LinkedDocumentsPageState extends State { textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall, ), - Expanded( - child: ListView.builder( - itemBuilder: (context, index) { - return DocumentListItem( - isLabelClickable: false, - document: state.documents!.results.elementAt(index), - onTap: (doc) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => BlocProvider( - create: (context) => DocumentDetailsCubit( - context.read(), - state.documents!.results.elementAt(index), - ), - child: const DocumentDetailsPage( - isLabelClickable: false, - allowEdit: false, + if (!state.isLoaded) + Expanded(child: const DocumentsListLoadingWidget()) + else + Expanded( + child: ListView.builder( + itemCount: state.documents?.results.length, + itemBuilder: (context, index) { + return DocumentListItem( + isLabelClickable: false, + document: state.documents!.results.elementAt(index), + onTap: (doc) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => BlocProvider( + create: (context) => DocumentDetailsCubit( + context.read(), + state.documents!.results.elementAt(index), + ), + child: const DocumentDetailsPage( + isLabelClickable: false, + allowEdit: false, + ), ), ), - ), - ); - }, - isSelected: false, - isAtLeastOneSelected: false, - isTagSelectedPredicate: (_) => false, - onTagSelected: (int tag) {}, - ); - }, + ); + }, + isSelected: false, + isAtLeastOneSelected: false, + isTagSelectedPredicate: (_) => false, + onTagSelected: (int tag) {}, + ); + }, + ), ), - ), ], ); }, diff --git a/lib/features/login/model/reachability_status.dart b/lib/features/login/model/reachability_status.dart index c34b803..0370294 100644 --- a/lib/features/login/model/reachability_status.dart +++ b/lib/features/login/model/reachability_status.dart @@ -5,4 +5,5 @@ enum ReachabilityStatus { unknownHost, missingClientCertificate, invalidClientCertificateConfiguration, + connectionTimeout; } diff --git a/lib/features/login/view/widgets/form_fields/server_address_form_field.dart b/lib/features/login/view/widgets/form_fields/server_address_form_field.dart index ee5b0d7..adee81e 100644 --- a/lib/features/login/view/widgets/form_fields/server_address_form_field.dart +++ b/lib/features/login/view/widgets/form_fields/server_address_form_field.dart @@ -17,6 +17,20 @@ class ServerAddressFormField extends StatefulWidget { } class _ServerAddressFormFieldState extends State { + bool _canClear = false; + + @override + void initState() { + super.initState(); + _textEditingController.addListener(() { + if (_textEditingController.text.isNotEmpty) { + setState(() { + _canClear = true; + }); + } + }); + } + final TextEditingController _textEditingController = TextEditingController(); @override @@ -25,12 +39,30 @@ class _ServerAddressFormFieldState extends State { key: const ValueKey('login-server-address'), controller: _textEditingController, name: ServerAddressFormField.fkServerAddress, - validator: FormBuilderValidators.required( - errorText: S.of(context).loginPageServerUrlValidatorMessageRequiredText, - ), + autovalidateMode: AutovalidateMode.onUserInteraction, + validator: FormBuilderValidators.compose([ + FormBuilderValidators.required( + errorText: + S.of(context).loginPageServerUrlValidatorMessageRequiredText, + ), + FormBuilderValidators.match( + r"https?://.*", + errorText: + S.of(context).loginPageServerUrlValidatorMessageMissingSchemeText, + ) + ]), decoration: InputDecoration( hintText: "http://192.168.1.50:8000", labelText: S.of(context).loginPageServerUrlFieldLabel, + suffixIcon: _canClear + ? IconButton( + icon: Icon(Icons.clear), + color: Theme.of(context).iconTheme.color, + onPressed: () { + _textEditingController.clear(); + }, + ) + : null, ), onSubmitted: (value) { if (value == null) return; diff --git a/lib/features/login/view/widgets/login_pages/server_connection_page.dart b/lib/features/login/view/widgets/login_pages/server_connection_page.dart index d985ced..ca00c0a 100644 --- a/lib/features/login/view/widgets/login_pages/server_connection_page.dart +++ b/lib/features/login/view/widgets/login_pages/server_connection_page.dart @@ -24,28 +24,38 @@ class ServerConnectionPage extends StatefulWidget { } class _ServerConnectionPageState extends State { + bool _isCheckingConnection = false; ReachabilityStatus _reachabilityStatus = ReachabilityStatus.unknown; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( + toolbarHeight: kToolbarHeight - 4, title: Text(S.of(context).loginPageTitle), + bottom: PreferredSize( + child: _isCheckingConnection + ? const LinearProgressIndicator() + : const SizedBox(height: 4.0), + preferredSize: const Size.fromHeight(4.0), + ), ), resizeToAvoidBottomInset: true, - body: Column( - children: [ - ServerAddressFormField( - onDone: (address) { - _updateReachability(); - }, - ).padded(), - ClientCertificateFormField( - onChanged: (_) => _updateReachability(), - ).padded(), - _buildStatusIndicator(), - ], - ).padded(), + body: SingleChildScrollView( + child: Column( + children: [ + ServerAddressFormField( + onDone: (address) { + _updateReachability(address); + }, + ).padded(), + ClientCertificateFormField( + onChanged: (_) => _updateReachability(), + ).padded(), + _buildStatusIndicator(), + ], + ).padded(), + ), bottomNavigationBar: BottomAppBar( child: Row( mainAxisAlignment: MainAxisAlignment.end, @@ -62,20 +72,30 @@ class _ServerConnectionPageState extends State { ); } - Future _updateReachability() async { + Future _updateReachability([String? address]) async { + setState(() { + _isCheckingConnection = true; + }); final status = await context .read() .isPaperlessServerReachable( - widget.formBuilderKey.currentState! - .getRawValue(ServerAddressFormField.fkServerAddress), + address ?? + widget.formBuilderKey.currentState! + .getRawValue(ServerAddressFormField.fkServerAddress), widget.formBuilderKey.currentState?.getRawValue( ClientCertificateFormField.fkClientCertificate, ), ); - setState(() => _reachabilityStatus = status); + setState(() { + _isCheckingConnection = false; + _reachabilityStatus = status; + }); } Widget _buildStatusIndicator() { + if (_isCheckingConnection) { + return const ListTile(); + } Color errorColor = Theme.of(context).colorScheme.error; switch (_reachabilityStatus) { case ReachabilityStatus.unknown: @@ -112,6 +132,12 @@ class _ServerConnectionPageState extends State { .loginPageReachabilityInvalidClientCertificateConfigurationText, errorColor, ); + case ReachabilityStatus.connectionTimeout: + return _buildIconText( + Icons.close, + S.of(context).loginPageReachabilityConnectionTimeoutText, + errorColor, + ); } } diff --git a/lib/features/notifications/services/local_notification_service.dart b/lib/features/notifications/services/local_notification_service.dart index 753ab66..c1ef3d5 100644 --- a/lib/features/notifications/services/local_notification_service.dart +++ b/lib/features/notifications/services/local_notification_service.dart @@ -91,15 +91,16 @@ class LocalNotificationService { progress: progress, actions: status == TaskStatus.success ? [ - AndroidNotificationAction( - NotificationResponseAction.openCreatedDocument.name, - "Open", - showsUserInterface: true, - ), - AndroidNotificationAction( - NotificationResponseAction.acknowledgeCreatedDocument.name, - "Acknowledge", - ), + //TODO: Implement once moved to new routing + // AndroidNotificationAction( + // NotificationResponseAction.openCreatedDocument.name, + // "Open", + // showsUserInterface: true, + // ), + // AndroidNotificationAction( + // NotificationResponseAction.acknowledgeCreatedDocument.name, + // "Acknowledge", + // ), ] : [], ), diff --git a/lib/features/scan/view/scanner_page.dart b/lib/features/scan/view/scanner_page.dart index d287f02..ecb09e1 100644 --- a/lib/features/scan/view/scanner_page.dart +++ b/lib/features/scan/view/scanner_page.dart @@ -17,6 +17,7 @@ import 'package:paperless_mobile/core/repository/state/impl/document_type_reposi import 'package:paperless_mobile/core/repository/state/impl/tag_repository_state.dart'; import 'package:paperless_mobile/core/service/file_service.dart'; import 'package:paperless_mobile/core/store/local_vault.dart'; +import 'package:paperless_mobile/core/widgets/hint_card.dart'; import 'package:paperless_mobile/core/widgets/offline_banner.dart'; import 'package:paperless_mobile/features/document_upload/cubit/document_upload_cubit.dart'; import 'package:paperless_mobile/features/document_upload/view/document_upload_preparation_page.dart'; diff --git a/lib/l10n/intl_cs.arb b/lib/l10n/intl_cs.arb index 923ebb0..6193e66 100644 --- a/lib/l10n/intl_cs.arb +++ b/lib/l10n/intl_cs.arb @@ -60,6 +60,8 @@ "@documentDeleteSuccessMessage": {}, "documentDetailsPageAssignAsnButtonLabel": "Přiřadit", "@documentDetailsPageAssignAsnButtonLabel": {}, + "documentDetailsPageLoadFullContentLabel": "", + "@documentDetailsPageLoadFullContentLabel": {}, "documentDetailsPageSimilarDocumentsLabel": "Podobné dokumenty", "@documentDetailsPageSimilarDocumentsLabel": {}, "documentDetailsPageTabContentLabel": "Obsah", @@ -154,6 +156,8 @@ "@documentsPageEmptyStateNothingHereText": {}, "documentsPageEmptyStateOopsText": "Ajaj.", "@documentsPageEmptyStateOopsText": {}, + "documentsPageNewDocumentAvailableText": "", + "@documentsPageNewDocumentAvailableText": {}, "documentsPageOrderByLabel": "Řadit dle", "@documentsPageOrderByLabel": {}, "documentsPageSelectionBulkDeleteDialogContinueText": "Tuto akci nelze vrátit zpět. Opravdu chcete pokračovat?", @@ -336,6 +340,8 @@ "count": {} } }, + "genericAcknowledgeLabel": "", + "@genericAcknowledgeLabel": {}, "genericActionCancelLabel": "Zrušit", "@genericActionCancelLabel": {}, "genericActionCreateLabel": "Vytvořit", @@ -442,6 +448,8 @@ "@loginPagePasswordFieldLabel": {}, "loginPagePasswordValidatorMessageText": "Heslo nesmí být prázdné.", "@loginPagePasswordValidatorMessageText": {}, + "loginPageReachabilityConnectionTimeoutText": "", + "@loginPageReachabilityConnectionTimeoutText": {}, "loginPageReachabilityInvalidClientCertificateConfigurationText": "", "@loginPageReachabilityInvalidClientCertificateConfigurationText": {}, "loginPageReachabilityMissingClientCertificateText": "", @@ -456,6 +464,8 @@ "@loginPageServerUrlFieldLabel": {}, "loginPageServerUrlValidatorMessageInvalidAddressText": "", "@loginPageServerUrlValidatorMessageInvalidAddressText": {}, + "loginPageServerUrlValidatorMessageMissingSchemeText": "", + "@loginPageServerUrlValidatorMessageMissingSchemeText": {}, "loginPageServerUrlValidatorMessageRequiredText": "Adresa serveru nesmí být prázdná.", "@loginPageServerUrlValidatorMessageRequiredText": {}, "loginPageSignInButtonLabel": "", diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 7605ba8..ea678ff 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -60,6 +60,8 @@ "@documentDeleteSuccessMessage": {}, "documentDetailsPageAssignAsnButtonLabel": "Zuweisen", "@documentDetailsPageAssignAsnButtonLabel": {}, + "documentDetailsPageLoadFullContentLabel": "Lade gesamten Inhalt", + "@documentDetailsPageLoadFullContentLabel": {}, "documentDetailsPageSimilarDocumentsLabel": "Similar Documents", "@documentDetailsPageSimilarDocumentsLabel": {}, "documentDetailsPageTabContentLabel": "Inhalt", @@ -154,6 +156,8 @@ "@documentsPageEmptyStateNothingHereText": {}, "documentsPageEmptyStateOopsText": "Ups.", "@documentsPageEmptyStateOopsText": {}, + "documentsPageNewDocumentAvailableText": "Neues Dokument verfügbar!", + "@documentsPageNewDocumentAvailableText": {}, "documentsPageOrderByLabel": "Sortiere nach", "@documentsPageOrderByLabel": {}, "documentsPageSelectionBulkDeleteDialogContinueText": "Diese Aktion ist unwiderruflich. Möchtest Du trotzdem fortfahren?", @@ -336,6 +340,8 @@ "count": {} } }, + "genericAcknowledgeLabel": "Verstanden!", + "@genericAcknowledgeLabel": {}, "genericActionCancelLabel": "Abbrechen", "@genericActionCancelLabel": {}, "genericActionCreateLabel": "Erstellen", @@ -372,7 +378,7 @@ "@inboxPageNoNewDocumentsText": {}, "inboxPageTodayText": "Heute", "@inboxPageTodayText": {}, - "inboxPageUndoRemoveText": "UNDO", + "inboxPageUndoRemoveText": "Undo", "@inboxPageUndoRemoveText": {}, "inboxPageUnseenText": "ungesehen", "@inboxPageUnseenText": {}, @@ -442,6 +448,8 @@ "@loginPagePasswordFieldLabel": {}, "loginPagePasswordValidatorMessageText": "Passwort darf nicht leer sein.", "@loginPagePasswordValidatorMessageText": {}, + "loginPageReachabilityConnectionTimeoutText": "Zeitüberschreitung der Verbindung.", + "@loginPageReachabilityConnectionTimeoutText": {}, "loginPageReachabilityInvalidClientCertificateConfigurationText": "Inkorrekte oder fehlende Zertifikatspassphrase.", "@loginPageReachabilityInvalidClientCertificateConfigurationText": {}, "loginPageReachabilityMissingClientCertificateText": "Ein Client-Zertifikat wurde erwartet aber nicht gesendet. Bitte stelle ein Zertifikat zur Verfügung.", @@ -450,12 +458,14 @@ "@loginPageReachabilityNotReachableText": {}, "loginPageReachabilitySuccessText": "Verbindung erfolgreich hergestellt.", "@loginPageReachabilitySuccessText": {}, - "loginPageReachabilityUnresolvedHostText": "Der Host konnte nicht aufgelöst werden. Bitte überprüfe die Server-Adresse.", + "loginPageReachabilityUnresolvedHostText": "Der Host konnte nicht aufgelöst werden. Bitte überprüfe die Server-Adresse und deine Internetverbindung.", "@loginPageReachabilityUnresolvedHostText": {}, "loginPageServerUrlFieldLabel": "Server-Adresse", "@loginPageServerUrlFieldLabel": {}, "loginPageServerUrlValidatorMessageInvalidAddressText": "Ungültige Adresse.", "@loginPageServerUrlValidatorMessageInvalidAddressText": {}, + "loginPageServerUrlValidatorMessageMissingSchemeText": "Server-Adresse muss ein Schema enthalten.", + "@loginPageServerUrlValidatorMessageMissingSchemeText": {}, "loginPageServerUrlValidatorMessageRequiredText": "Server-Addresse darf nicht leer sein.", "@loginPageServerUrlValidatorMessageRequiredText": {}, "loginPageSignInButtonLabel": "Anmelden", diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 30b3f3e..d2c3b6f 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -60,6 +60,8 @@ "@documentDeleteSuccessMessage": {}, "documentDetailsPageAssignAsnButtonLabel": "Assign", "@documentDetailsPageAssignAsnButtonLabel": {}, + "documentDetailsPageLoadFullContentLabel": "Load full content", + "@documentDetailsPageLoadFullContentLabel": {}, "documentDetailsPageSimilarDocumentsLabel": "Similar Documents", "@documentDetailsPageSimilarDocumentsLabel": {}, "documentDetailsPageTabContentLabel": "Content", @@ -154,6 +156,8 @@ "@documentsPageEmptyStateNothingHereText": {}, "documentsPageEmptyStateOopsText": "Oops.", "@documentsPageEmptyStateOopsText": {}, + "documentsPageNewDocumentAvailableText": "New document available!", + "@documentsPageNewDocumentAvailableText": {}, "documentsPageOrderByLabel": "Order By", "@documentsPageOrderByLabel": {}, "documentsPageSelectionBulkDeleteDialogContinueText": "This action is irreversible. Do you wish to proceed anyway?", @@ -336,6 +340,8 @@ "count": {} } }, + "genericAcknowledgeLabel": "Got it!", + "@genericAcknowledgeLabel": {}, "genericActionCancelLabel": "Cancel", "@genericActionCancelLabel": {}, "genericActionCreateLabel": "Create", @@ -372,7 +378,7 @@ "@inboxPageNoNewDocumentsText": {}, "inboxPageTodayText": "Today", "@inboxPageTodayText": {}, - "inboxPageUndoRemoveText": "UNDO", + "inboxPageUndoRemoveText": "Undo", "@inboxPageUndoRemoveText": {}, "inboxPageUnseenText": "unseen", "@inboxPageUnseenText": {}, @@ -442,6 +448,8 @@ "@loginPagePasswordFieldLabel": {}, "loginPagePasswordValidatorMessageText": "Password must not be empty.", "@loginPagePasswordValidatorMessageText": {}, + "loginPageReachabilityConnectionTimeoutText": "Connection timed out.", + "@loginPageReachabilityConnectionTimeoutText": {}, "loginPageReachabilityInvalidClientCertificateConfigurationText": "Incorrect or missing client certificate passphrase.", "@loginPageReachabilityInvalidClientCertificateConfigurationText": {}, "loginPageReachabilityMissingClientCertificateText": "A client certificate was expected but not sent. Please provide a certificate.", @@ -450,12 +458,14 @@ "@loginPageReachabilityNotReachableText": {}, "loginPageReachabilitySuccessText": "Connection successfully established.", "@loginPageReachabilitySuccessText": {}, - "loginPageReachabilityUnresolvedHostText": "Host could not be resolved. Please check the server address.", + "loginPageReachabilityUnresolvedHostText": "Host could not be resolved. Please check the server address and your internet connection. ", "@loginPageReachabilityUnresolvedHostText": {}, "loginPageServerUrlFieldLabel": "Server Address", "@loginPageServerUrlFieldLabel": {}, "loginPageServerUrlValidatorMessageInvalidAddressText": "Invalid address.", "@loginPageServerUrlValidatorMessageInvalidAddressText": {}, + "loginPageServerUrlValidatorMessageMissingSchemeText": "Server address must include a scheme.", + "@loginPageServerUrlValidatorMessageMissingSchemeText": {}, "loginPageServerUrlValidatorMessageRequiredText": "Server address must not be empty.", "@loginPageServerUrlValidatorMessageRequiredText": {}, "loginPageSignInButtonLabel": "Sign In", diff --git a/lib/main.dart b/lib/main.dart index 73aa4b8..548b554 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -14,6 +14,7 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/bloc/bloc_changes_observer.dart'; import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; import 'package:paperless_mobile/core/bloc/paperless_server_information_cubit.dart'; +import 'package:paperless_mobile/core/interceptor/dio_http_error_interceptor.dart'; import 'package:paperless_mobile/core/interceptor/language_header.interceptor.dart'; import 'package:paperless_mobile/core/repository/impl/correspondent_repository_impl.dart'; import 'package:paperless_mobile/core/repository/impl/document_type_repository_impl.dart'; @@ -76,7 +77,10 @@ void main() async { appSettingsCubit.state.preferredLocaleSubtag, ); // Manages security context, required for self signed client certificates - final sessionManager = SessionManager([languageHeaderInterceptor]); + final sessionManager = SessionManager([ + DioHttpErrorInterceptor(), + languageHeaderInterceptor, + ]); // Initialize Paperless APIs final authApi = PaperlessAuthenticationApiImpl(sessionManager.client); @@ -219,6 +223,9 @@ class _PaperlessMobileEntrypointState extends State { chipTheme: ChipThemeData( backgroundColor: Colors.lightGreen[50], ), + listTileTheme: const ListTileThemeData( + tileColor: Colors.transparent, + ), ); final _darkTheme = ThemeData( @@ -241,6 +248,9 @@ class _PaperlessMobileEntrypointState extends State { chipTheme: ChipThemeData( backgroundColor: Colors.green[900], ), + listTileTheme: const ListTileThemeData( + tileColor: Colors.transparent, + ), ); @override diff --git a/lib/util.dart b/lib/util.dart index c6395d8..6b0f4fe 100644 --- a/lib/util.dart +++ b/lib/util.dart @@ -33,30 +33,32 @@ void showSnackBar( String message, { String? details, SnackBarActionConfig? action, + Duration duration = const Duration(seconds: 5), }) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( SnackBar( - content: RichText( - maxLines: 5, - text: TextSpan( - text: message, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onInverseSurface, + content: (details != null) + ? RichText( + maxLines: 5, + text: TextSpan( + text: message, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onInverseSurface, + ), + children: [ + TextSpan( + text: "\n$details", + style: const TextStyle( + fontStyle: FontStyle.italic, + fontSize: 10, + ), + ), + ], ), - children: [ - if (details != null) - TextSpan( - text: "\n$details", - style: const TextStyle( - fontStyle: FontStyle.italic, - fontSize: 10, - ), - ), - ], - ), - ), + ) + : Text(message), action: action != null ? SnackBarAction( label: action.label, @@ -64,7 +66,7 @@ void showSnackBar( textColor: Theme.of(context).colorScheme.onInverseSurface, ) : null, - duration: const Duration(seconds: 5), + duration: duration, ), ); } diff --git a/pubspec.yaml b/pubspec.yaml index 102b7c9..e035d1b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.4.1+12 +version: 1.5.0+13 environment: sdk: '>=3.0.0-35.0.dev <4.0.0'