Merge branch 'feature/reworked-settings-and-user-management' into development

This commit is contained in:
Anton Stubenbord
2023-04-27 12:25:19 +02:00
245 changed files with 15398 additions and 4713 deletions

View File

@@ -11,6 +11,8 @@ on:
default: "alpha"
type: choice
options:
- internal
- promote_to_alpha
- alpha
- promote_to_beta
- beta
@@ -30,26 +32,16 @@ jobs:
with:
channel: stable
- run: flutter doctor -v
# Setup app
# Clone repository
- name: Checkout Paperless mobile, get packages and run code generators
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c
- run: |
pushd scripts
bash install_dependencies.sh
popd
# Setup Ruby, Bundler, and Gemfile dependencies
- name: Setup Fastlane
uses: ruby/setup-ruby@8df78e55761745aad83acaf3ff12976382356e6d
with:
ruby-version: "2.6"
bundler-cache: true
working-directory: android
# Extract secrets into corresponding files
- name: Configure Keystore
run: |
echo "$PLAY_STORE_UPLOAD_KEY" | base64 --decode > app/upload-keystore.jks
echo "$RELEASE_KEYSTORE" > upload-keystore.jks.asc
gpg --batch --passphrase "$RELEASE_KEYSTORE_PASSPHRASE" -d -o app/upload-keystore.jks upload-keystore.jks.asc
echo "storeFile=upload-keystore.jks" >> key.properties
echo "keyAlias=$KEYSTORE_KEY_ALIAS" >> key.properties
echo "storePassword=$KEYSTORE_STORE_PASSWORD" >> key.properties
@@ -59,11 +51,28 @@ jobs:
KEYSTORE_KEY_ALIAS: ${{ secrets.KEYSTORE_KEY_ALIAS }}
KEYSTORE_KEY_PASSWORD: ${{ secrets.KEYSTORE_KEY_PASSWORD }}
KEYSTORE_STORE_PASSWORD: ${{ secrets.KEYSTORE_STORE_PASSWORD }}
RELEASE_KEYSTORE: ${{ secrets.RELEASE_KEYSTORE }}
RELEASE_KEYSTORE_PASSPHRASE: ${{ secrets.RELEASE_KEYSTORE_PASSPHRASE }}
working-directory: android
# Run codegen
- name: Run Codegen
run: |
bash install_dependencies.sh
working-directory: scripts
# Setup Ruby, Bundler, and Gemfile dependencies
- name: Setup Fastlane
uses: ruby/setup-ruby@8df78e55761745aad83acaf3ff12976382356e6d
with:
ruby-version: "2.6"
bundler-cache: true
working-directory: android
# Build and deploy with Fastlane (by default, to alpha track) 🚀.
# Naturally, promote_to_production only deploys.
- run: bundle exec fastlane ${{ github.event.inputs.lane || 'alpha' }}
env:
PLAY_STORE_CREDENTIALS: ${{ secrets.PLAY_STORE_CREDENTIALS }}
working-directory: android
working-directory: android

View File

@@ -28,6 +28,6 @@ subprojects {
project.evaluationDependsOn(':app')
}
task clean(type: Delete) {
tasks.register("clean", Delete) {
delete rootProject.buildDir
}

View File

@@ -21,6 +21,27 @@ platform :android do
gradle(task: "test")
end
desc "Submit a new internal build to Google Play"
lane :internal do
sh "flutter build appbundle -v"
upload_to_play_store(
track: 'internal',
aab: '../build/app/outputs/bundle/release/app-release.aab',
json_key_data: ENV['PLAY_STORE_CREDENTIALS'],
release_status: "draft",
)
end
desc "Promote internal track to alpha"
lane :promote_to_alpha do
upload_to_play_store(
track: 'internal',
track_promote_to: 'alpha',
json_key_data: ENV['PLAY_STORE_CREDENTIALS'],
)
end
desc "Submit a new alpha build to Google Play"
lane :alpha do
sh "flutter build appbundle -v"
@@ -28,6 +49,7 @@ platform :android do
track: 'alpha',
aab: '../build/app/outputs/bundle/release/app-release.aab',
json_key_data: ENV['PLAY_STORE_CREDENTIALS'],
release_status: "draft",
)
end
@@ -36,7 +58,6 @@ platform :android do
upload_to_play_store(
track: 'alpha',
track_promote_to: 'beta',
skip_upload_changelogs: true,
json_key_data: ENV['PLAY_STORE_CREDENTIALS'],
)
end
@@ -48,6 +69,7 @@ platform :android do
track: 'beta',
aab: '../build/app/outputs/bundle/release/app-release.aab',
json_key_data: ENV['PLAY_STORE_CREDENTIALS'],
release_status: "draft",
)
end
@@ -56,7 +78,6 @@ platform :android do
upload_to_play_store(
track: 'beta',
track_promote_to: 'production',
skip_upload_changelogs: true,
json_key_data: ENV['PLAY_STORE_CREDENTIALS'],
)
end
@@ -68,6 +89,7 @@ platform :android do
track: 'production',
aab: '../build/app/outputs/bundle/release/app-release.aab',
json_key_data: ENV['PLAY_STORE_CREDENTIALS'],
release_status: "draft",
)
end
end

View File

@@ -0,0 +1,4 @@
Diese Version ist eine Betaversion und enthält neue Features und Umstrukturierungen. Deshalb wird empfohlen, die App neu zu installieren!
* Neues Feature: Massenbearbeitung von Dokumenten.
* Neues Feature: Unterstützung für mehrere Accounts und mehrere Instanzen mit schnellem Wechsel zwischen diesen (Achtung: Die in paperless-ngx 1.14.0 neu hinzugefügten Berechtigungen sind hier noch nicht enthalten!)
* Bugfixes

View File

@@ -0,0 +1,4 @@
This version is a beta and contains new features and some restructurings. Therefore it is highly recommended to perform a fresh installation of the app.
* New feature: Document bulk edits
* New feature: Support for multiple accounts and multiple instances with quick switching between them (Note: This does not yet include the new multi-user features introduced in paperless-ngx 1.14.0!)
* Bugfixes

View File

@@ -54,6 +54,8 @@ PODS:
- FMDB (2.7.5):
- FMDB/standard (= 2.7.5)
- FMDB/standard (2.7.5)
- in_app_review (0.2.0):
- Flutter
- integration_test (0.0.1):
- Flutter
- local_auth_ios (0.0.1):
@@ -99,16 +101,17 @@ DEPENDENCIES:
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- local_auth_ios (from `.symlinks/plugins/local_auth_ios/ios`)
- open_filex (from `.symlinks/plugins/open_filex/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/ios`)
- pdfx (from `.symlinks/plugins/pdfx/ios`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/ios`)
- sqflite (from `.symlinks/plugins/sqflite/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
@@ -142,6 +145,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_native_splash/ios"
fluttertoast:
:path: ".symlinks/plugins/fluttertoast/ios"
in_app_review:
:path: ".symlinks/plugins/in_app_review/ios"
integration_test:
:path: ".symlinks/plugins/integration_test/ios"
local_auth_ios:
@@ -151,7 +156,7 @@ EXTERNAL SOURCES:
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
:path: ".symlinks/plugins/path_provider_foundation/ios"
pdfx:
:path: ".symlinks/plugins/pdfx/ios"
permission_handler_apple:
@@ -161,7 +166,7 @@ EXTERNAL SOURCES:
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
:path: ".symlinks/plugins/shared_preferences_foundation/ios"
sqflite:
:path: ".symlinks/plugins/sqflite/ios"
url_launcher_ios:
@@ -180,6 +185,7 @@ SPEC CHECKSUMS:
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
fluttertoast: eb263d302cc92e04176c053d2385237e9f43fad0
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
in_app_review: 318597b3a06c22bb46dc454d56828c85f444f99d
integration_test: a1e7d09bd98eca2fc37aefd79d4f41ad37bdbbe5
local_auth_ios: 0d333dde7780f669e66f19d2ff6005f3ea84008d
open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4
@@ -195,7 +201,7 @@ SPEC CHECKSUMS:
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: ae1517e5e344f5544fb090b079e11f399dfbe4d2
url_launcher_ios: fb12c43172927bb5cf75aeebd073f883801f1993
WeScan: fed582f6c38014d529afb5aa9ffd1bad38fc72b7
PODFILE CHECKSUM: 7daa35cc908d9fba025075df27cb57a1ba1ebf13

View File

@@ -1,8 +0,0 @@
import 'package:flutter_bloc/flutter_bloc.dart';
class BlocChangesObserver extends BlocObserver {
@override
void onChange(BlocBase bloc, Change change) {
super.onChange(bloc, change);
}
}

View File

@@ -1,19 +0,0 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/bloc/paperless_server_information_state.dart';
class PaperlessServerInformationCubit
extends Cubit<PaperlessServerInformationState> {
final PaperlessServerStatsApi _api;
PaperlessServerInformationCubit(this._api)
: super(PaperlessServerInformationState());
Future<void> updateInformtion() async {
final information = await _api.getServerInformation();
emit(PaperlessServerInformationState(
isLoaded: true,
information: information,
));
}
}

View File

@@ -0,0 +1,17 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/bloc/server_information_state.dart';
class ServerInformationCubit extends Cubit<ServerInformationState> {
final PaperlessServerStatsApi _api;
ServerInformationCubit(this._api) : super(ServerInformationState());
Future<void> updateInformation() async {
final information = await _api.getServerInformation();
emit(ServerInformationState(
isLoaded: true,
information: information,
));
}
}

View File

@@ -1,10 +1,10 @@
import 'package:paperless_api/paperless_api.dart';
class PaperlessServerInformationState {
class ServerInformationState {
final bool isLoaded;
final PaperlessServerInformationModel? information;
PaperlessServerInformationState({
ServerInformationState({
this.isLoaded = false,
this.information,
});

View File

@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:hive_flutter/adapters.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
class ThemeModeAdapter extends TypeAdapter<ThemeMode> {
@override
final int typeId = HiveTypeIds.themeMode;
@override
ThemeMode read(BinaryReader reader) {
switch (reader.readByte()) {
case 0:
return ThemeMode.system;
case 1:
return ThemeMode.dark;
case 2:
return ThemeMode.light;
default:
return ThemeMode.system;
}
}
@override
void write(BinaryWriter writer, ThemeMode obj) {
switch (obj) {
case ThemeMode.system:
writer.writeByte(0);
break;
case ThemeMode.light:
writer.writeByte(1);
break;
case ThemeMode.dark:
writer.writeByte(2);
break;
}
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ThemeModeAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,59 @@
import 'package:hive_flutter/adapters.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/config/hive/custom_adapters/theme_mode_adapter.dart';
import 'package:paperless_mobile/core/database/tables/global_settings.dart';
import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart';
import 'package:paperless_mobile/core/database/tables/user_credentials.dart';
import 'package:paperless_mobile/features/login/model/authentication_information.dart';
import 'package:paperless_mobile/features/login/model/client_certificate.dart';
import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
import 'package:paperless_mobile/features/settings/model/color_scheme_option.dart';
import 'package:paperless_mobile/core/database/tables/local_user_settings.dart';
import 'package:paperless_mobile/features/settings/model/view_type.dart';
class HiveBoxes {
HiveBoxes._();
static const globalSettings = 'globalSettings';
static const authentication = 'authentication';
static const localUserCredentials = 'localUserCredentials';
static const localUserAccount = 'localUserAccount';
static const localUserAppState = 'localUserAppState';
static const localUserSettings = 'localUserSettings';
}
class HiveTypeIds {
HiveTypeIds._();
static const globalSettings = 0;
static const localUserSettings = 1;
static const themeMode = 2;
static const colorSchemeOption = 3;
static const authentication = 4;
static const clientCertificate = 5;
static const localUserCredentials = 6;
static const localUserAccount = 7;
static const localUserAppState = 8;
static const viewType = 9;
}
void registerHiveAdapters() {
registerPaperlessApiHiveTypeAdapters();
Hive.registerAdapter(ColorSchemeOptionAdapter());
Hive.registerAdapter(ThemeModeAdapter());
Hive.registerAdapter(GlobalSettingsAdapter());
Hive.registerAdapter(AuthenticationInformationAdapter());
Hive.registerAdapter(ClientCertificateAdapter());
Hive.registerAdapter(LocalUserSettingsAdapter());
Hive.registerAdapter(UserCredentialsAdapter());
Hive.registerAdapter(LocalUserAccountAdapter());
Hive.registerAdapter(LocalUserAppStateAdapter());
Hive.registerAdapter(ViewTypeAdapter());
}
extension HiveSingleValueBox<T> on Box<T> {
static const _valueKey = 'SINGLE_VALUE';
bool get hasValue => containsKey(_valueKey);
T? getValue() => get(_valueKey);
Future<void> setValue(T value) => put(_valueKey, value);
}

View File

@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/features/settings/model/color_scheme_option.dart';
part 'global_settings.g.dart';
@HiveType(typeId: HiveTypeIds.globalSettings)
class GlobalSettings with HiveObjectMixin {
@HiveField(0)
String preferredLocaleSubtag;
@HiveField(1)
ThemeMode preferredThemeMode;
@HiveField(2)
ColorSchemeOption preferredColorSchemeOption;
@HiveField(3)
bool showOnboarding;
@HiveField(4)
String? currentLoggedInUser;
GlobalSettings({
required this.preferredLocaleSubtag,
this.preferredThemeMode = ThemeMode.system,
this.preferredColorSchemeOption = ColorSchemeOption.classic,
this.showOnboarding = true,
this.currentLoggedInUser,
});
}

View File

@@ -0,0 +1,31 @@
import 'package:hive_flutter/adapters.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/core/database/tables/local_user_settings.dart';
part 'local_user_account.g.dart';
@HiveType(typeId: HiveTypeIds.localUserAccount)
class LocalUserAccount extends HiveObject {
@HiveField(0)
final String serverUrl;
@HiveField(1)
final String username;
@HiveField(2)
final String? fullName;
@HiveField(3)
final String id;
@HiveField(4)
LocalUserSettings settings;
LocalUserAccount({
required this.id,
required this.serverUrl,
required this.username,
required this.settings,
this.fullName,
});
}

View File

@@ -0,0 +1,40 @@
import 'package:hive_flutter/adapters.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/features/settings/model/view_type.dart';
part 'local_user_app_state.g.dart';
///
/// Object used for the persistence of app state, e.g. set filters,
/// search history and implicit settings.
///
@HiveType(typeId: HiveTypeIds.localUserAppState)
class LocalUserAppState extends HiveObject {
@HiveField(0)
final String userId;
@HiveField(1)
DocumentFilter currentDocumentFilter;
@HiveField(2)
List<String> documentSearchHistory;
@HiveField(3)
ViewType documentsPageViewType;
@HiveField(4)
ViewType savedViewsViewType;
@HiveField(5)
ViewType documentSearchViewType;
LocalUserAppState({
required this.userId,
this.currentDocumentFilter = const DocumentFilter(),
this.documentSearchHistory = const [],
this.documentsPageViewType = ViewType.list,
this.documentSearchViewType = ViewType.list,
this.savedViewsViewType = ViewType.list,
});
}

View File

@@ -0,0 +1,14 @@
import 'package:hive/hive.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
part 'local_user_settings.g.dart';
@HiveType(typeId: HiveTypeIds.localUserSettings)
class LocalUserSettings with HiveObjectMixin {
@HiveField(0)
bool isBiometricAuthenticationEnabled;
LocalUserSettings({
this.isBiometricAuthenticationEnabled = false,
});
}

View File

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

View File

@@ -23,8 +23,8 @@ class DocumentChangedNotifier {
_deleted.add(deleted);
}
void subscribe(
dynamic subscriber, {
void addListener(
Object subscriber, {
DocumentChangedCallback? onUpdated,
DocumentChangedCallback? onDeleted,
}) {
@@ -41,7 +41,7 @@ class DocumentChangedNotifier {
);
}
void unsubscribe(dynamic subscriber) {
void removeListener(Object subscriber) {
_subscribers[subscriber]?.forEach((element) {
element.cancel();
});

View File

@@ -1,34 +0,0 @@
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:paperless_mobile/core/repository/state/indexed_repository_state.dart';
import 'package:rxdart/subjects.dart';
///
/// Base repository class which all repositories should implement
///
abstract class BaseRepository<T> extends Cubit<IndexedRepositoryState<T>>
with HydratedMixin {
final IndexedRepositoryState<T> _initialState;
BaseRepository(this._initialState) : super(_initialState) {
hydrate();
}
Stream<IndexedRepositoryState<T>?> get values =>
BehaviorSubject.seeded(state)..addStream(super.stream);
IndexedRepositoryState<T>? get current => state;
bool get isInitialized => state.hasLoaded;
Future<T> create(T object);
Future<T?> find(int id);
Future<Iterable<T>> findAll([Iterable<int>? ids]);
Future<T> update(T object);
Future<int> delete(T object);
@override
Future<void> clear() async {
await super.clear();
emit(_initialState);
}
}

View File

@@ -1,69 +0,0 @@
import 'dart:async';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/repository/state/impl/correspondent_repository_state.dart';
class CorrespondentRepositoryImpl extends LabelRepository<Correspondent> {
final PaperlessLabelsApi _api;
CorrespondentRepositoryImpl(this._api)
: super(const CorrespondentRepositoryState());
@override
Future<Correspondent> create(Correspondent correspondent) async {
final created = await _api.saveCorrespondent(correspondent);
final updatedState = {...state.values ?? {}}
..putIfAbsent(created.id!, () => created);
emit(CorrespondentRepositoryState(values: updatedState, hasLoaded: true));
return created;
}
@override
Future<int> delete(Correspondent correspondent) async {
await _api.deleteCorrespondent(correspondent);
final updatedState = {...state.values ?? {}}
..removeWhere((k, v) => k == correspondent.id);
emit(CorrespondentRepositoryState(values: updatedState, hasLoaded: true));
return correspondent.id!;
}
@override
Future<Correspondent?> find(int id) async {
final correspondent = await _api.getCorrespondent(id);
if (correspondent != null) {
final updatedState = {...state.values ?? {}}..[id] = correspondent;
emit(CorrespondentRepositoryState(values: updatedState, hasLoaded: true));
return correspondent;
}
return null;
}
@override
Future<Iterable<Correspondent>> findAll([Iterable<int>? ids]) async {
final correspondents = await _api.getCorrespondents(ids);
final updatedState = {...state.values ?? {}}
..addEntries(correspondents.map((e) => MapEntry(e.id!, e)));
emit(CorrespondentRepositoryState(values: updatedState, hasLoaded: true));
return correspondents;
}
@override
Future<Correspondent> update(Correspondent correspondent) async {
final updated = await _api.updateCorrespondent(correspondent);
final updatedState = {...state.values ?? {}}
..update(updated.id!, (_) => updated);
emit(CorrespondentRepositoryState(values: updatedState, hasLoaded: true));
return updated;
}
@override
CorrespondentRepositoryState fromJson(Map<String, dynamic> json) {
return CorrespondentRepositoryState.fromJson(json);
}
@override
Map<String, dynamic> toJson(covariant CorrespondentRepositoryState state) {
return state.toJson();
}
}

View File

@@ -1,67 +0,0 @@
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/repository/state/impl/document_type_repository_state.dart';
class DocumentTypeRepositoryImpl extends LabelRepository<DocumentType> {
final PaperlessLabelsApi _api;
DocumentTypeRepositoryImpl(this._api)
: super(const DocumentTypeRepositoryState());
@override
Future<DocumentType> create(DocumentType documentType) async {
final created = await _api.saveDocumentType(documentType);
final updatedState = {...state.values ?? {}}
..putIfAbsent(created.id!, () => created);
emit(DocumentTypeRepositoryState(values: updatedState, hasLoaded: true));
return created;
}
@override
Future<int> delete(DocumentType documentType) async {
await _api.deleteDocumentType(documentType);
final updatedState = {...state.values ?? {}}
..removeWhere((k, v) => k == documentType.id);
emit(DocumentTypeRepositoryState(values: updatedState, hasLoaded: true));
return documentType.id!;
}
@override
Future<DocumentType?> find(int id) async {
final documentType = await _api.getDocumentType(id);
if (documentType != null) {
final updatedState = {...state.values ?? {}}..[id] = documentType;
emit(DocumentTypeRepositoryState(values: updatedState, hasLoaded: true));
return documentType;
}
return null;
}
@override
Future<Iterable<DocumentType>> findAll([Iterable<int>? ids]) async {
final documentTypes = await _api.getDocumentTypes(ids);
final updatedState = {...state.values ?? {}}
..addEntries(documentTypes.map((e) => MapEntry(e.id!, e)));
emit(DocumentTypeRepositoryState(values: updatedState, hasLoaded: true));
return documentTypes;
}
@override
Future<DocumentType> update(DocumentType documentType) async {
final updated = await _api.updateDocumentType(documentType);
final updatedState = {...state.values ?? {}}
..update(updated.id!, (_) => updated);
emit(DocumentTypeRepositoryState(values: updatedState, hasLoaded: true));
return updated;
}
@override
DocumentTypeRepositoryState fromJson(Map<String, dynamic> json) {
return DocumentTypeRepositoryState.fromJson(json);
}
@override
Map<String, dynamic> toJson(covariant DocumentTypeRepositoryState state) {
return state.toJson();
}
}

View File

@@ -1,62 +0,0 @@
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/saved_view_repository.dart';
import 'package:paperless_mobile/core/repository/state/impl/saved_view_repository_state.dart';
class SavedViewRepositoryImpl extends SavedViewRepository {
final PaperlessSavedViewsApi _api;
SavedViewRepositoryImpl(this._api) : super(const SavedViewRepositoryState());
@override
Future<SavedView> create(SavedView object) async {
final created = await _api.save(object);
final updatedState = {...state.values ?? {}}
..putIfAbsent(created.id!, () => created);
emit(SavedViewRepositoryState(values: updatedState, hasLoaded: true));
return created;
}
@override
Future<int> delete(SavedView view) async {
await _api.delete(view);
final updatedState = {...state.values ?? {}}..remove(view.id);
emit(SavedViewRepositoryState(values: updatedState, hasLoaded: true));
return view.id!;
}
@override
Future<SavedView?> find(int id) async {
final found = await _api.find(id);
final updatedState = {...state.values ?? {}}
..update(id, (_) => found, ifAbsent: () => found);
emit(SavedViewRepositoryState(values: updatedState, hasLoaded: true));
return found;
}
@override
Future<Iterable<SavedView>> findAll([Iterable<int>? ids]) async {
final found = await _api.findAll(ids);
final updatedState = {
...state.values ?? {},
...{for (final view in found) view.id!: view},
};
emit(SavedViewRepositoryState(values: updatedState, hasLoaded: true));
return found;
}
@override
Future<SavedView> update(SavedView object) {
throw UnimplementedError(
"Saved view update is not yet implemented as it is not supported by the API.");
}
@override
SavedViewRepositoryState fromJson(Map<String, dynamic> json) {
return SavedViewRepositoryState.fromJson(json);
}
@override
Map<String, dynamic> toJson(covariant SavedViewRepositoryState state) {
return state.toJson();
}
}

View File

@@ -1,68 +0,0 @@
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/repository/state/impl/storage_path_repository_state.dart';
import 'package:rxdart/rxdart.dart' show BehaviorSubject;
class StoragePathRepositoryImpl extends LabelRepository<StoragePath> {
final PaperlessLabelsApi _api;
StoragePathRepositoryImpl(this._api)
: super(const StoragePathRepositoryState());
@override
Future<StoragePath> create(StoragePath storagePath) async {
final created = await _api.saveStoragePath(storagePath);
final updatedState = {...state.values ?? {}}
..putIfAbsent(created.id!, () => created);
emit(StoragePathRepositoryState(values: updatedState, hasLoaded: true));
return created;
}
@override
Future<int> delete(StoragePath storagePath) async {
await _api.deleteStoragePath(storagePath);
final updatedState = {...state.values ?? {}}
..removeWhere((k, v) => k == storagePath.id);
emit(StoragePathRepositoryState(values: updatedState, hasLoaded: true));
return storagePath.id!;
}
@override
Future<StoragePath?> find(int id) async {
final storagePath = await _api.getStoragePath(id);
if (storagePath != null) {
final updatedState = {...state.values ?? {}}..[id] = storagePath;
emit(StoragePathRepositoryState(values: updatedState, hasLoaded: true));
return storagePath;
}
return null;
}
@override
Future<Iterable<StoragePath>> findAll([Iterable<int>? ids]) async {
final storagePaths = await _api.getStoragePaths(ids);
final updatedState = {...state.values ?? {}}
..addEntries(storagePaths.map((e) => MapEntry(e.id!, e)));
emit(StoragePathRepositoryState(values: updatedState, hasLoaded: true));
return storagePaths;
}
@override
Future<StoragePath> update(StoragePath storagePath) async {
final updated = await _api.updateStoragePath(storagePath);
final updatedState = {...state.values ?? {}}
..update(updated.id!, (_) => updated);
emit(StoragePathRepositoryState(values: updatedState, hasLoaded: true));
return updated;
}
@override
StoragePathRepositoryState fromJson(Map<String, dynamic> json) {
return StoragePathRepositoryState.fromJson(json);
}
@override
Map<String, dynamic> toJson(covariant StoragePathRepositoryState state) {
return state.toJson();
}
}

View File

@@ -1,66 +0,0 @@
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';
class TagRepositoryImpl extends LabelRepository<Tag> {
final PaperlessLabelsApi _api;
TagRepositoryImpl(this._api) : super(const TagRepositoryState());
@override
Future<Tag> create(Tag object) async {
final created = await _api.saveTag(object);
final updatedState = {...state.values ?? {}}
..putIfAbsent(created.id!, () => created);
emit(TagRepositoryState(values: updatedState, hasLoaded: true));
return created;
}
@override
Future<int> delete(Tag tag) async {
await _api.deleteTag(tag);
final updatedState = {...state.values ?? {}}
..removeWhere((k, v) => k == tag.id);
emit(TagRepositoryState(values: updatedState, hasLoaded: true));
return tag.id!;
}
@override
Future<Tag?> find(int id) async {
final tag = await _api.getTag(id);
if (tag != null) {
final updatedState = {...state.values ?? {}}..[id] = tag;
emit(TagRepositoryState(values: updatedState, hasLoaded: true));
return tag;
}
return null;
}
@override
Future<Iterable<Tag>> findAll([Iterable<int>? ids]) async {
final tags = await _api.getTags(ids);
final updatedState = {...state.values ?? {}}
..addEntries(tags.map((e) => MapEntry(e.id!, e)));
emit(TagRepositoryState(values: updatedState, hasLoaded: true));
return tags;
}
@override
Future<Tag> update(Tag tag) async {
final updated = await _api.updateTag(tag);
final updatedState = {...state.values ?? {}}
..update(updated.id!, (_) => updated);
emit(TagRepositoryState(values: updatedState, hasLoaded: true));
return updated;
}
@override
TagRepositoryState? fromJson(Map<String, dynamic> json) {
return TagRepositoryState.fromJson(json);
}
@override
Map<String, dynamic>? toJson(covariant TagRepositoryState state) {
return state.toJson();
}
}

View File

@@ -1,7 +1,221 @@
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/base_repository.dart';
import 'package:paperless_mobile/core/repository/state/indexed_repository_state.dart';
import 'dart:async';
abstract class LabelRepository<T extends Label> extends BaseRepository<T> {
LabelRepository(IndexedRepositoryState<T> initial) : super(initial);
import 'package:flutter/widgets.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/label_repository_state.dart';
class LabelRepository extends HydratedCubit<LabelRepositoryState> {
final PaperlessLabelsApi _api;
final Map<Object, StreamSubscription> _subscribers = {};
LabelRepository(this._api) : super(const LabelRepositoryState());
void addListener(
Object source, {
required void Function(LabelRepositoryState) onChanged,
}) {
onChanged(state);
_subscribers.putIfAbsent(source, () {
return stream.listen((event) => onChanged(event));
});
}
void removeListener(Object source) async {
await _subscribers[source]?.cancel();
_subscribers.remove(source);
}
Future<void> initialize() {
debugPrint("Initializing labels...");
return Future.wait([
findAllCorrespondents(),
findAllDocumentTypes(),
findAllStoragePaths(),
findAllTags(),
]);
}
Future<Tag> createTag(Tag object) async {
final created = await _api.saveTag(object);
final updatedState = {...state.tags}..putIfAbsent(created.id!, () => created);
emit(state.copyWith(tags: updatedState));
return created;
}
Future<int> deleteTag(Tag tag) async {
await _api.deleteTag(tag);
final updatedState = {...state.tags}..removeWhere((k, v) => k == tag.id);
emit(state.copyWith(tags: updatedState));
return tag.id!;
}
Future<Tag?> findTag(int id) async {
final tag = await _api.getTag(id);
if (tag != null) {
final updatedState = {...state.tags}..[id] = tag;
emit(state.copyWith(tags: updatedState));
return tag;
}
return null;
}
Future<Iterable<Tag>> findAllTags([Iterable<int>? ids]) async {
final tags = await _api.getTags(ids);
final updatedState = {...state.tags}..addEntries(tags.map((e) => MapEntry(e.id!, e)));
emit(state.copyWith(tags: updatedState));
return tags;
}
Future<Tag> updateTag(Tag tag) async {
final updated = await _api.updateTag(tag);
final updatedState = {...state.tags}..update(updated.id!, (_) => updated);
emit(state.copyWith(tags: updatedState));
return updated;
}
Future<Correspondent> createCorrespondent(Correspondent correspondent) async {
final created = await _api.saveCorrespondent(correspondent);
final updatedState = {...state.correspondents}..putIfAbsent(created.id!, () => created);
emit(state.copyWith(correspondents: updatedState));
return created;
}
Future<int> deleteCorrespondent(Correspondent correspondent) async {
await _api.deleteCorrespondent(correspondent);
final updatedState = {...state.correspondents}..removeWhere((k, v) => k == correspondent.id);
emit(state.copyWith(correspondents: updatedState));
return correspondent.id!;
}
Future<Correspondent?> findCorrespondent(int id) async {
final correspondent = await _api.getCorrespondent(id);
if (correspondent != null) {
final updatedState = {...state.correspondents}..[id] = correspondent;
emit(state.copyWith(correspondents: updatedState));
return correspondent;
}
return null;
}
Future<Iterable<Correspondent>> findAllCorrespondents([Iterable<int>? ids]) async {
final correspondents = await _api.getCorrespondents(ids);
final updatedState = {...state.correspondents}
..addEntries(correspondents.map((e) => MapEntry(e.id!, e)));
emit(state.copyWith(correspondents: updatedState));
return correspondents;
}
Future<Correspondent> updateCorrespondent(Correspondent correspondent) async {
final updated = await _api.updateCorrespondent(correspondent);
final updatedState = {...state.correspondents}..update(updated.id!, (_) => updated);
emit(state.copyWith(correspondents: updatedState));
return updated;
}
Future<DocumentType> createDocumentType(DocumentType documentType) async {
final created = await _api.saveDocumentType(documentType);
final updatedState = {...state.documentTypes}..putIfAbsent(created.id!, () => created);
emit(state.copyWith(documentTypes: updatedState));
return created;
}
Future<int> deleteDocumentType(DocumentType documentType) async {
await _api.deleteDocumentType(documentType);
final updatedState = {...state.documentTypes}..removeWhere((k, v) => k == documentType.id);
emit(state.copyWith(documentTypes: updatedState));
return documentType.id!;
}
Future<DocumentType?> findDocumentType(int id) async {
final documentType = await _api.getDocumentType(id);
if (documentType != null) {
final updatedState = {...state.documentTypes}..[id] = documentType;
emit(state.copyWith(documentTypes: updatedState));
return documentType;
}
return null;
}
Future<Iterable<DocumentType>> findAllDocumentTypes([Iterable<int>? ids]) async {
final documentTypes = await _api.getDocumentTypes(ids);
final updatedState = {...state.documentTypes}
..addEntries(documentTypes.map((e) => MapEntry(e.id!, e)));
emit(state.copyWith(documentTypes: updatedState));
return documentTypes;
}
Future<DocumentType> updateDocumentType(DocumentType documentType) async {
final updated = await _api.updateDocumentType(documentType);
final updatedState = {...state.documentTypes}..update(updated.id!, (_) => updated);
emit(state.copyWith(documentTypes: updatedState));
return updated;
}
Future<StoragePath> createStoragePath(StoragePath storagePath) async {
final created = await _api.saveStoragePath(storagePath);
final updatedState = {...state.storagePaths}..putIfAbsent(created.id!, () => created);
emit(state.copyWith(storagePaths: updatedState));
return created;
}
Future<int> deleteStoragePath(StoragePath storagePath) async {
await _api.deleteStoragePath(storagePath);
final updatedState = {...state.storagePaths}..removeWhere((k, v) => k == storagePath.id);
emit(state.copyWith(storagePaths: updatedState));
return storagePath.id!;
}
Future<StoragePath?> findStoragePath(int id) async {
final storagePath = await _api.getStoragePath(id);
if (storagePath != null) {
final updatedState = {...state.storagePaths}..[id] = storagePath;
emit(state.copyWith(storagePaths: updatedState));
return storagePath;
}
return null;
}
Future<Iterable<StoragePath>> findAllStoragePaths([Iterable<int>? ids]) async {
final storagePaths = await _api.getStoragePaths(ids);
final updatedState = {...state.storagePaths}
..addEntries(storagePaths.map((e) => MapEntry(e.id!, e)));
emit(state.copyWith(storagePaths: updatedState));
return storagePaths;
}
Future<StoragePath> updateStoragePath(StoragePath storagePath) async {
final updated = await _api.updateStoragePath(storagePath);
final updatedState = {...state.storagePaths}..update(updated.id!, (_) => updated);
emit(state.copyWith(storagePaths: updatedState));
return updated;
}
@override
Future<void> close() {
_subscribers.forEach((key, subscription) {
subscription.cancel();
});
return super.close();
}
@override
Future<void> clear() async {
await super.clear();
emit(const LabelRepositoryState());
}
@override
LabelRepositoryState? fromJson(Map<String, dynamic> json) {
return LabelRepositoryState.fromJson(json);
}
@override
Map<String, dynamic>? toJson(LabelRepositoryState state) {
return state.toJson();
}
}

View File

@@ -0,0 +1,18 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:paperless_api/paperless_api.dart';
part 'label_repository_state.freezed.dart';
part 'label_repository_state.g.dart';
@freezed
class LabelRepositoryState with _$LabelRepositoryState {
const factory LabelRepositoryState({
@Default({}) Map<int, Correspondent> correspondents,
@Default({}) Map<int, DocumentType> documentTypes,
@Default({}) Map<int, Tag> tags,
@Default({}) Map<int, StoragePath> storagePaths,
}) = _LabelRepositoryState;
factory LabelRepositoryState.fromJson(Map<String, dynamic> json) =>
_$LabelRepositoryStateFromJson(json);
}

View File

@@ -0,0 +1,258 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'label_repository_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods');
LabelRepositoryState _$LabelRepositoryStateFromJson(Map<String, dynamic> json) {
return _LabelRepositoryState.fromJson(json);
}
/// @nodoc
mixin _$LabelRepositoryState {
Map<int, Correspondent> get correspondents =>
throw _privateConstructorUsedError;
Map<int, DocumentType> get documentTypes =>
throw _privateConstructorUsedError;
Map<int, Tag> get tags => throw _privateConstructorUsedError;
Map<int, StoragePath> get storagePaths => throw _privateConstructorUsedError;
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$LabelRepositoryStateCopyWith<LabelRepositoryState> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $LabelRepositoryStateCopyWith<$Res> {
factory $LabelRepositoryStateCopyWith(LabelRepositoryState value,
$Res Function(LabelRepositoryState) then) =
_$LabelRepositoryStateCopyWithImpl<$Res, LabelRepositoryState>;
@useResult
$Res call(
{Map<int, Correspondent> correspondents,
Map<int, DocumentType> documentTypes,
Map<int, Tag> tags,
Map<int, StoragePath> storagePaths});
}
/// @nodoc
class _$LabelRepositoryStateCopyWithImpl<$Res,
$Val extends LabelRepositoryState>
implements $LabelRepositoryStateCopyWith<$Res> {
_$LabelRepositoryStateCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
@pragma('vm:prefer-inline')
@override
$Res call({
Object? correspondents = null,
Object? documentTypes = null,
Object? tags = null,
Object? storagePaths = null,
}) {
return _then(_value.copyWith(
correspondents: null == correspondents
? _value.correspondents
: correspondents // ignore: cast_nullable_to_non_nullable
as Map<int, Correspondent>,
documentTypes: null == documentTypes
? _value.documentTypes
: documentTypes // ignore: cast_nullable_to_non_nullable
as Map<int, DocumentType>,
tags: null == tags
? _value.tags
: tags // ignore: cast_nullable_to_non_nullable
as Map<int, Tag>,
storagePaths: null == storagePaths
? _value.storagePaths
: storagePaths // ignore: cast_nullable_to_non_nullable
as Map<int, StoragePath>,
) as $Val);
}
}
/// @nodoc
abstract class _$$_LabelRepositoryStateCopyWith<$Res>
implements $LabelRepositoryStateCopyWith<$Res> {
factory _$$_LabelRepositoryStateCopyWith(_$_LabelRepositoryState value,
$Res Function(_$_LabelRepositoryState) then) =
__$$_LabelRepositoryStateCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{Map<int, Correspondent> correspondents,
Map<int, DocumentType> documentTypes,
Map<int, Tag> tags,
Map<int, StoragePath> storagePaths});
}
/// @nodoc
class __$$_LabelRepositoryStateCopyWithImpl<$Res>
extends _$LabelRepositoryStateCopyWithImpl<$Res, _$_LabelRepositoryState>
implements _$$_LabelRepositoryStateCopyWith<$Res> {
__$$_LabelRepositoryStateCopyWithImpl(_$_LabelRepositoryState _value,
$Res Function(_$_LabelRepositoryState) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? correspondents = null,
Object? documentTypes = null,
Object? tags = null,
Object? storagePaths = null,
}) {
return _then(_$_LabelRepositoryState(
correspondents: null == correspondents
? _value._correspondents
: correspondents // ignore: cast_nullable_to_non_nullable
as Map<int, Correspondent>,
documentTypes: null == documentTypes
? _value._documentTypes
: documentTypes // ignore: cast_nullable_to_non_nullable
as Map<int, DocumentType>,
tags: null == tags
? _value._tags
: tags // ignore: cast_nullable_to_non_nullable
as Map<int, Tag>,
storagePaths: null == storagePaths
? _value._storagePaths
: storagePaths // ignore: cast_nullable_to_non_nullable
as Map<int, StoragePath>,
));
}
}
/// @nodoc
@JsonSerializable()
class _$_LabelRepositoryState implements _LabelRepositoryState {
const _$_LabelRepositoryState(
{final Map<int, Correspondent> correspondents = const {},
final Map<int, DocumentType> documentTypes = const {},
final Map<int, Tag> tags = const {},
final Map<int, StoragePath> storagePaths = const {}})
: _correspondents = correspondents,
_documentTypes = documentTypes,
_tags = tags,
_storagePaths = storagePaths;
factory _$_LabelRepositoryState.fromJson(Map<String, dynamic> json) =>
_$$_LabelRepositoryStateFromJson(json);
final Map<int, Correspondent> _correspondents;
@override
@JsonKey()
Map<int, Correspondent> get correspondents {
if (_correspondents is EqualUnmodifiableMapView) return _correspondents;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_correspondents);
}
final Map<int, DocumentType> _documentTypes;
@override
@JsonKey()
Map<int, DocumentType> get documentTypes {
if (_documentTypes is EqualUnmodifiableMapView) return _documentTypes;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_documentTypes);
}
final Map<int, Tag> _tags;
@override
@JsonKey()
Map<int, Tag> get tags {
if (_tags is EqualUnmodifiableMapView) return _tags;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_tags);
}
final Map<int, StoragePath> _storagePaths;
@override
@JsonKey()
Map<int, StoragePath> get storagePaths {
if (_storagePaths is EqualUnmodifiableMapView) return _storagePaths;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_storagePaths);
}
@override
String toString() {
return 'LabelRepositoryState(correspondents: $correspondents, documentTypes: $documentTypes, tags: $tags, storagePaths: $storagePaths)';
}
@override
bool operator ==(dynamic other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$_LabelRepositoryState &&
const DeepCollectionEquality()
.equals(other._correspondents, _correspondents) &&
const DeepCollectionEquality()
.equals(other._documentTypes, _documentTypes) &&
const DeepCollectionEquality().equals(other._tags, _tags) &&
const DeepCollectionEquality()
.equals(other._storagePaths, _storagePaths));
}
@JsonKey(ignore: true)
@override
int get hashCode => Object.hash(
runtimeType,
const DeepCollectionEquality().hash(_correspondents),
const DeepCollectionEquality().hash(_documentTypes),
const DeepCollectionEquality().hash(_tags),
const DeepCollectionEquality().hash(_storagePaths));
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$_LabelRepositoryStateCopyWith<_$_LabelRepositoryState> get copyWith =>
__$$_LabelRepositoryStateCopyWithImpl<_$_LabelRepositoryState>(
this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$_LabelRepositoryStateToJson(
this,
);
}
}
abstract class _LabelRepositoryState implements LabelRepositoryState {
const factory _LabelRepositoryState(
{final Map<int, Correspondent> correspondents,
final Map<int, DocumentType> documentTypes,
final Map<int, Tag> tags,
final Map<int, StoragePath> storagePaths}) = _$_LabelRepositoryState;
factory _LabelRepositoryState.fromJson(Map<String, dynamic> json) =
_$_LabelRepositoryState.fromJson;
@override
Map<int, Correspondent> get correspondents;
@override
Map<int, DocumentType> get documentTypes;
@override
Map<int, Tag> get tags;
@override
Map<int, StoragePath> get storagePaths;
@override
@JsonKey(ignore: true)
_$$_LabelRepositoryStateCopyWith<_$_LabelRepositoryState> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@@ -1,35 +0,0 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/impl/document_type_repository_impl.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/repository/state/impl/correspondent_repository_state.dart';
import 'package:paperless_mobile/core/repository/state/impl/document_type_repository_state.dart';
import 'package:paperless_mobile/core/repository/state/impl/storage_path_repository_state.dart';
import 'package:paperless_mobile/core/repository/state/impl/tag_repository_state.dart';
class LabelRepositoriesProvider extends StatelessWidget {
final Widget child;
const LabelRepositoriesProvider({super.key, required this.child});
@override
Widget build(BuildContext context) {
return MultiRepositoryProvider(
providers: [
RepositoryProvider(
create: (context) => context.read<LabelRepository<Correspondent>>(),
),
RepositoryProvider(
create: (context) => context.read<LabelRepository<DocumentType>>(),
),
RepositoryProvider(
create: (context) => context.read<LabelRepository<StoragePath>>(),
),
RepositoryProvider(
create: (context) => context.read<LabelRepository<Tag>>(),
),
],
child: child,
);
}
}

View File

@@ -1,8 +1,84 @@
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/base_repository.dart';
import 'package:paperless_mobile/core/repository/state/impl/saved_view_repository_state.dart';
import 'package:paperless_mobile/core/repository/state/indexed_repository_state.dart';
import 'dart:async';
abstract class SavedViewRepository extends BaseRepository<SavedView> {
SavedViewRepository(super.initialState);
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/saved_view_repository_state.dart';
class SavedViewRepository extends HydratedCubit<SavedViewRepositoryState> {
final PaperlessSavedViewsApi _api;
final Map<Object, StreamSubscription> _subscribers = {};
void subscribe(
Object source,
void Function(Map<int, SavedView>) onChanged,
) {
_subscribers.putIfAbsent(source, () {
onChanged(state.savedViews);
return stream.listen((event) => onChanged(event.savedViews));
});
}
void unsubscribe(Object source) async {
await _subscribers[source]?.cancel();
_subscribers.remove(source);
}
SavedViewRepository(this._api) : super(const SavedViewRepositoryState());
Future<SavedView> create(SavedView object) async {
final created = await _api.save(object);
final updatedState = {...state.savedViews}..putIfAbsent(created.id!, () => created);
emit(state.copyWith(savedViews: updatedState));
return created;
}
Future<int> delete(SavedView view) async {
await _api.delete(view);
final updatedState = {...state.savedViews}..remove(view.id);
emit(state.copyWith(savedViews: updatedState));
return view.id!;
}
Future<SavedView?> find(int id) async {
final found = await _api.find(id);
if (found != null) {
final updatedState = {...state.savedViews}..update(id, (_) => found, ifAbsent: () => found);
emit(state.copyWith(savedViews: updatedState));
}
return found;
}
Future<Iterable<SavedView>> findAll([Iterable<int>? ids]) async {
final found = await _api.findAll(ids);
final updatedState = {
...state.savedViews,
...{for (final view in found) view.id!: view},
};
emit(state.copyWith(savedViews: updatedState));
return found;
}
@override
Future<void> close() {
_subscribers.forEach((key, subscription) {
subscription.cancel();
});
return super.close();
}
@override
Future<void> clear() async {
await super.clear();
emit(const SavedViewRepositoryState());
}
@override
SavedViewRepositoryState? fromJson(Map<String, dynamic> json) {
return SavedViewRepositoryState.fromJson(json);
}
@override
Map<String, dynamic>? toJson(SavedViewRepositoryState state) {
return state.toJson();
}
}

View File

@@ -0,0 +1,15 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:paperless_api/paperless_api.dart';
part 'saved_view_repository_state.freezed.dart';
part 'saved_view_repository_state.g.dart';
@freezed
class SavedViewRepositoryState with _$SavedViewRepositoryState {
const factory SavedViewRepositoryState({
@Default({}) Map<int, SavedView> savedViews,
}) = _SavedViewRepositoryState;
factory SavedViewRepositoryState.fromJson(Map<String, dynamic> json) =>
_$SavedViewRepositoryStateFromJson(json);
}

View File

@@ -0,0 +1,167 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'saved_view_repository_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods');
SavedViewRepositoryState _$SavedViewRepositoryStateFromJson(
Map<String, dynamic> json) {
return _SavedViewRepositoryState.fromJson(json);
}
/// @nodoc
mixin _$SavedViewRepositoryState {
Map<int, SavedView> get savedViews => throw _privateConstructorUsedError;
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$SavedViewRepositoryStateCopyWith<SavedViewRepositoryState> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $SavedViewRepositoryStateCopyWith<$Res> {
factory $SavedViewRepositoryStateCopyWith(SavedViewRepositoryState value,
$Res Function(SavedViewRepositoryState) then) =
_$SavedViewRepositoryStateCopyWithImpl<$Res, SavedViewRepositoryState>;
@useResult
$Res call({Map<int, SavedView> savedViews});
}
/// @nodoc
class _$SavedViewRepositoryStateCopyWithImpl<$Res,
$Val extends SavedViewRepositoryState>
implements $SavedViewRepositoryStateCopyWith<$Res> {
_$SavedViewRepositoryStateCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
@pragma('vm:prefer-inline')
@override
$Res call({
Object? savedViews = null,
}) {
return _then(_value.copyWith(
savedViews: null == savedViews
? _value.savedViews
: savedViews // ignore: cast_nullable_to_non_nullable
as Map<int, SavedView>,
) as $Val);
}
}
/// @nodoc
abstract class _$$_SavedViewRepositoryStateCopyWith<$Res>
implements $SavedViewRepositoryStateCopyWith<$Res> {
factory _$$_SavedViewRepositoryStateCopyWith(
_$_SavedViewRepositoryState value,
$Res Function(_$_SavedViewRepositoryState) then) =
__$$_SavedViewRepositoryStateCopyWithImpl<$Res>;
@override
@useResult
$Res call({Map<int, SavedView> savedViews});
}
/// @nodoc
class __$$_SavedViewRepositoryStateCopyWithImpl<$Res>
extends _$SavedViewRepositoryStateCopyWithImpl<$Res,
_$_SavedViewRepositoryState>
implements _$$_SavedViewRepositoryStateCopyWith<$Res> {
__$$_SavedViewRepositoryStateCopyWithImpl(_$_SavedViewRepositoryState _value,
$Res Function(_$_SavedViewRepositoryState) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? savedViews = null,
}) {
return _then(_$_SavedViewRepositoryState(
savedViews: null == savedViews
? _value._savedViews
: savedViews // ignore: cast_nullable_to_non_nullable
as Map<int, SavedView>,
));
}
}
/// @nodoc
@JsonSerializable()
class _$_SavedViewRepositoryState implements _SavedViewRepositoryState {
const _$_SavedViewRepositoryState(
{final Map<int, SavedView> savedViews = const {}})
: _savedViews = savedViews;
factory _$_SavedViewRepositoryState.fromJson(Map<String, dynamic> json) =>
_$$_SavedViewRepositoryStateFromJson(json);
final Map<int, SavedView> _savedViews;
@override
@JsonKey()
Map<int, SavedView> get savedViews {
if (_savedViews is EqualUnmodifiableMapView) return _savedViews;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_savedViews);
}
@override
String toString() {
return 'SavedViewRepositoryState(savedViews: $savedViews)';
}
@override
bool operator ==(dynamic other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$_SavedViewRepositoryState &&
const DeepCollectionEquality()
.equals(other._savedViews, _savedViews));
}
@JsonKey(ignore: true)
@override
int get hashCode => Object.hash(
runtimeType, const DeepCollectionEquality().hash(_savedViews));
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$_SavedViewRepositoryStateCopyWith<_$_SavedViewRepositoryState>
get copyWith => __$$_SavedViewRepositoryStateCopyWithImpl<
_$_SavedViewRepositoryState>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$_SavedViewRepositoryStateToJson(
this,
);
}
}
abstract class _SavedViewRepositoryState implements SavedViewRepositoryState {
const factory _SavedViewRepositoryState(
{final Map<int, SavedView> savedViews}) = _$_SavedViewRepositoryState;
factory _SavedViewRepositoryState.fromJson(Map<String, dynamic> json) =
_$_SavedViewRepositoryState.fromJson;
@override
Map<int, SavedView> get savedViews;
@override
@JsonKey(ignore: true)
_$$_SavedViewRepositoryStateCopyWith<_$_SavedViewRepositoryState>
get copyWith => throw _privateConstructorUsedError;
}

View File

@@ -1,31 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/state/indexed_repository_state.dart';
part 'correspondent_repository_state.g.dart';
@JsonSerializable()
class CorrespondentRepositoryState
extends IndexedRepositoryState<Correspondent> {
const CorrespondentRepositoryState({
super.values = const {},
super.hasLoaded,
});
@override
CorrespondentRepositoryState copyWith({
Map<int, Correspondent>? values,
bool? hasLoaded,
}) {
return CorrespondentRepositoryState(
values: values ?? this.values,
hasLoaded: hasLoaded ?? this.hasLoaded,
);
}
factory CorrespondentRepositoryState.fromJson(Map<String, dynamic> json) =>
_$CorrespondentRepositoryStateFromJson(json);
Map<String, dynamic> toJson() => _$CorrespondentRepositoryStateToJson(this);
}

View File

@@ -1,29 +0,0 @@
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/state/indexed_repository_state.dart';
import 'package:json_annotation/json_annotation.dart';
part 'document_type_repository_state.g.dart';
@JsonSerializable()
class DocumentTypeRepositoryState extends IndexedRepositoryState<DocumentType> {
const DocumentTypeRepositoryState({
super.values = const {},
super.hasLoaded,
});
@override
DocumentTypeRepositoryState copyWith({
Map<int, DocumentType>? values,
bool? hasLoaded,
}) {
return DocumentTypeRepositoryState(
values: values ?? this.values,
hasLoaded: hasLoaded ?? this.hasLoaded,
);
}
factory DocumentTypeRepositoryState.fromJson(Map<String, dynamic> json) =>
_$DocumentTypeRepositoryStateFromJson(json);
Map<String, dynamic> toJson() => _$DocumentTypeRepositoryStateToJson(this);
}

View File

@@ -1,29 +0,0 @@
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/state/indexed_repository_state.dart';
import 'package:json_annotation/json_annotation.dart';
part 'saved_view_repository_state.g.dart';
@JsonSerializable()
class SavedViewRepositoryState extends IndexedRepositoryState<SavedView> {
const SavedViewRepositoryState({
super.values = const {},
super.hasLoaded = false,
});
@override
SavedViewRepositoryState copyWith({
Map<int, SavedView>? values,
bool? hasLoaded,
}) {
return SavedViewRepositoryState(
values: values ?? this.values,
hasLoaded: hasLoaded ?? this.hasLoaded,
);
}
factory SavedViewRepositoryState.fromJson(Map<String, dynamic> json) =>
_$SavedViewRepositoryStateFromJson(json);
Map<String, dynamic> toJson() => _$SavedViewRepositoryStateToJson(this);
}

View File

@@ -1,29 +0,0 @@
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/state/indexed_repository_state.dart';
import 'package:json_annotation/json_annotation.dart';
part 'storage_path_repository_state.g.dart';
@JsonSerializable()
class StoragePathRepositoryState extends IndexedRepositoryState<StoragePath> {
const StoragePathRepositoryState({
super.values = const {},
super.hasLoaded = false,
});
@override
StoragePathRepositoryState copyWith({
Map<int, StoragePath>? values,
bool? hasLoaded,
}) {
return StoragePathRepositoryState(
values: values ?? this.values,
hasLoaded: hasLoaded ?? this.hasLoaded,
);
}
factory StoragePathRepositoryState.fromJson(Map<String, dynamic> json) =>
_$StoragePathRepositoryStateFromJson(json);
Map<String, dynamic> toJson() => _$StoragePathRepositoryStateToJson(this);
}

View File

@@ -1,29 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/state/indexed_repository_state.dart';
part 'tag_repository_state.g.dart';
@JsonSerializable()
class TagRepositoryState extends IndexedRepositoryState<Tag> {
const TagRepositoryState({
super.values = const {},
super.hasLoaded = false,
});
@override
TagRepositoryState copyWith({
Map<int, Tag>? values,
bool? hasLoaded,
}) {
return TagRepositoryState(
values: values ?? this.values,
hasLoaded: hasLoaded ?? this.hasLoaded,
);
}
factory TagRepositoryState.fromJson(Map<String, dynamic> json) =>
_$TagRepositoryStateFromJson(json);
Map<String, dynamic> toJson() => _$TagRepositoryStateToJson(this);
}

View File

@@ -1,16 +0,0 @@
abstract class IndexedRepositoryState<T> {
final Map<int, T>? values;
final bool hasLoaded;
const IndexedRepositoryState({
required this.values,
this.hasLoaded = false,
}) : assert(!(values == null) || !hasLoaded);
IndexedRepositoryState.loaded(this.values) : hasLoaded = true;
IndexedRepositoryState<T> copyWith({
Map<int, T>? values,
bool? hasLoaded,
});
}

View File

@@ -1,4 +1,3 @@
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart';
@@ -8,14 +7,18 @@ import 'package:paperless_mobile/core/interceptor/retry_on_connection_change_int
import 'package:paperless_mobile/features/login/model/client_certificate.dart';
import 'package:pretty_dio_logger/pretty_dio_logger.dart';
/// Manages the security context, authentication and base request URL for
/// an underlying [Dio] client which is injected into all services
/// requiring authenticated access to the Paperless HTTP API.
class SessionManager {
final Dio client;
final List<Interceptor> interceptors;
PaperlessServerInformationModel serverInformation;
final Dio _client;
PaperlessServerInformationModel _serverInformation;
SessionManager([this.interceptors = const []])
: client = _initDio(interceptors),
serverInformation = PaperlessServerInformationModel();
Dio get client => _client;
SessionManager([List<Interceptor> interceptors = const []])
: _client = _initDio(interceptors),
_serverInformation = PaperlessServerInformationModel();
static Dio _initDio(List<Interceptor> interceptors) {
//en- and decoded by utf8 by default
@@ -63,8 +66,7 @@ class SessionManager {
);
final adapter = IOHttpClientAdapter()
..onHttpClientCreate = (client) => HttpClient(context: context)
..badCertificateCallback =
(X509Certificate cert, String host, int port) => true;
..badCertificateCallback = (X509Certificate cert, String host, int port) => true;
client.httpClientAdapter = adapter;
}
@@ -80,7 +82,7 @@ class SessionManager {
}
if (serverInformation != null) {
this.serverInformation = serverInformation;
_serverInformation = serverInformation;
}
}
@@ -88,6 +90,6 @@ class SessionManager {
client.httpClientAdapter = IOHttpClientAdapter();
client.options.baseUrl = '';
client.options.headers.remove(HttpHeaders.authorizationHeader);
serverInformation = PaperlessServerInformationModel();
_serverInformation = PaperlessServerInformationModel();
}
}

View File

@@ -32,8 +32,7 @@ class ConnectivityStatusServiceImpl implements ConnectivityStatusService {
@override
Future<bool> isConnectedToInternet() async {
return _hasActiveInternetConnection(
await (Connectivity().checkConnectivity()));
return _hasActiveInternetConnection(await (Connectivity().checkConnectivity()));
}
@override
@@ -72,11 +71,10 @@ class ConnectivityStatusServiceImpl implements ConnectivityStatusService {
return ReachabilityStatus.unknown;
}
try {
SessionManager manager =
SessionManager([ServerReachabilityErrorInterceptor()])
..updateSettings(clientCertificate: clientCertificate)
..client.options.connectTimeout = const Duration(seconds: 5)
..client.options.receiveTimeout = const Duration(seconds: 5);
SessionManager manager = SessionManager([ServerReachabilityErrorInterceptor()])
..updateSettings(clientCertificate: clientCertificate)
..client.options.connectTimeout = const Duration(seconds: 5)
..client.options.receiveTimeout = const Duration(seconds: 5);
final response = await manager.client.get('$serverAddress/api/');
if (response.statusCode == 200) {
@@ -84,8 +82,7 @@ class ConnectivityStatusServiceImpl implements ConnectivityStatusService {
}
return ReachabilityStatus.notReachable;
} on DioError catch (error) {
if (error.type == DioErrorType.unknown &&
error.error is ReachabilityStatus) {
if (error.type == DioErrorType.unknown && error.error is ReachabilityStatus) {
return error.error as ReachabilityStatus;
}
} on TlsException catch (error) {

View File

@@ -9,11 +9,12 @@ import 'package:paperless_mobile/core/bloc/document_status_cubit.dart';
import 'package:paperless_mobile/core/model/document_processing_status.dart';
import 'package:paperless_mobile/features/login/model/authentication_information.dart';
import 'package:paperless_mobile/constants.dart';
import 'package:paperless_mobile/core/database/tables/user_credentials.dart';
import 'package:web_socket_channel/io.dart';
abstract class StatusService {
Future<void> startListeningBeforeDocumentUpload(String httpUrl,
AuthenticationInformation credentials, String documentFileName);
Future<void> startListeningBeforeDocumentUpload(
String httpUrl, UserCredentials credentials, String documentFileName);
}
class WebSocketStatusService implements StatusService {
@@ -25,7 +26,7 @@ class WebSocketStatusService implements StatusService {
@override
Future<void> startListeningBeforeDocumentUpload(
String httpUrl,
AuthenticationInformation credentials,
UserCredentials credentials,
String documentFileName,
) async {
// socket = await WebSocket.connect(
@@ -57,7 +58,7 @@ class LongPollingStatusService implements StatusService {
@override
Future<void> startListeningBeforeDocumentUpload(
String httpUrl,
AuthenticationInformation credentials,
UserCredentials credentials,
String documentFileName,
) async {
// final today = DateTime.now();

View File

@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:flutter/src/widgets/framework.dart';
import 'package:flutter/src/widgets/placeholder.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
enum DialogConfirmButtonStyle {
normal,
danger;
}
class DialogConfirmButton<T> extends StatelessWidget {
final DialogConfirmButtonStyle style;
final String? label;
final T? returnValue;
const DialogConfirmButton({
super.key,
this.style = DialogConfirmButtonStyle.normal,
this.label,
this.returnValue,
});
@override
Widget build(BuildContext context) {
final _normalStyle = ButtonStyle(
backgroundColor: MaterialStatePropertyAll(
Theme.of(context).colorScheme.primaryContainer,
),
foregroundColor: MaterialStatePropertyAll(
Theme.of(context).colorScheme.onPrimaryContainer,
),
);
final _dangerStyle = ButtonStyle(
backgroundColor: MaterialStatePropertyAll(
Theme.of(context).colorScheme.errorContainer,
),
foregroundColor: MaterialStatePropertyAll(
Theme.of(context).colorScheme.onErrorContainer,
),
);
late final ButtonStyle _style;
switch (style) {
case DialogConfirmButtonStyle.normal:
_style = _normalStyle;
break;
case DialogConfirmButtonStyle.danger:
_style = _dangerStyle;
break;
}
return ElevatedButton(
child: Text(label ?? S.of(context)!.confirm),
style: _style,
onPressed: () => Navigator.of(context).pop(returnValue ?? true),
);
}
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:paperless_mobile/core/model/github_error_report.model.dart';
import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_cancel_button.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
class ErrorReportPage extends StatefulWidget {
@@ -136,10 +137,7 @@ Note: If you have the GitHub Android app installed, the descriptions will not be
Navigator.pop(context, true);
},
),
TextButton(
child: const Text('Cancel'),
onPressed: () => Navigator.pop(context, false),
),
const DialogCancelButton(),
],
),
) ??

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:form_builder_validators/form_builder_validators.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
@@ -56,7 +56,9 @@ class _FormBuilderRelativeDateRangePickerState
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
validator: FormBuilderValidators.numeric(),
// validator: (value) { //TODO: Check if this is required
// do numeric validation
// },
keyboardType: TextInputType.number,
onChanged: (value) {
final parsed = int.tryParse(value);

View File

@@ -4,6 +4,8 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_cancel_button.dart';
import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_confirm_button.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
extension on Color {
@@ -136,11 +138,12 @@ class FormBuilderColorPickerField extends FormBuilderField<Color> {
: LayoutBuilder(
key: ObjectKey(state.value),
builder: (context, constraints) {
return Icon(
Icons.circle,
key: ObjectKey(state.value),
size: constraints.minHeight,
color: state.value,
return Padding(
padding: const EdgeInsets.all(8.0),
child: CircleAvatar(
key: ObjectKey(state.value),
backgroundColor: state.value,
),
);
},
),
@@ -218,17 +221,11 @@ class FormBuilderColorPickerFieldState
return AlertDialog(
// title: null, //const Text('Pick a color!'),
content: SingleChildScrollView(
child: _buildColorPicker(),
),
content: _buildColorPicker(),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(materialLocalizations.cancel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text(materialLocalizations.ok),
const DialogCancelButton(),
DialogConfirmButton(
label: S.of(context)!.ok,
),
],
);

View File

@@ -0,0 +1,155 @@
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class FullscreenSelectionForm extends StatefulWidget {
final FocusNode? focusNode;
final TextEditingController? controller;
final String hintText;
final Widget leadingIcon;
final bool autofocus;
final VoidCallback? onTextFieldCleared;
final List<Widget> trailingActions;
final Widget Function(BuildContext context, int index) selectionBuilder;
final int selectionCount;
final void Function(String value)? onKeyboardSubmit;
final Widget? floatingActionButton;
const FullscreenSelectionForm({
super.key,
this.focusNode,
this.controller,
required this.hintText,
required this.leadingIcon,
this.autofocus = true,
this.onTextFieldCleared,
this.trailingActions = const [],
required this.selectionBuilder,
required this.selectionCount,
this.onKeyboardSubmit,
this.floatingActionButton,
});
@override
State<FullscreenSelectionForm> createState() =>
_FullscreenSelectionFormState();
}
class _FullscreenSelectionFormState extends State<FullscreenSelectionForm> {
late final FocusNode _focusNode;
late final TextEditingController _controller;
bool _showClearIcon = false;
@override
void initState() {
super.initState();
_focusNode = widget.focusNode ?? FocusNode();
_controller = (widget.controller ?? TextEditingController())
..addListener(() {
setState(() {
_showClearIcon = _controller.text.isNotEmpty;
});
});
if (widget.autofocus) {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
//Delay keyboard popup to ensure open animation is finished before.
Future.delayed(
const Duration(milliseconds: 200),
() => _focusNode.requestFocus(),
);
});
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
floatingActionButton: widget.floatingActionButton,
appBar: AppBar(
backgroundColor: theme.colorScheme.surface,
toolbarHeight: 72,
leading: BackButton(
color: theme.colorScheme.onSurface,
),
title: TextFormField(
focusNode: _focusNode,
controller: _controller,
onFieldSubmitted: (value) {
FocusScope.of(context).unfocus();
widget.onKeyboardSubmit?.call(value);
},
autofocus: true,
style: theme.textTheme.bodyLarge?.apply(
color: theme.colorScheme.onSurface,
),
decoration: InputDecoration(
contentPadding: EdgeInsets.zero,
hintStyle: theme.textTheme.bodyLarge?.apply(
color: theme.colorScheme.onSurfaceVariant,
),
icon: widget.leadingIcon,
hintText: widget.hintText,
border: InputBorder.none,
),
textInputAction: TextInputAction.done,
),
actions: [
if (_showClearIcon)
IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_controller.clear();
widget.onTextFieldCleared?.call();
},
),
...widget.trailingActions,
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1),
child: Divider(
color: theme.colorScheme.outline,
),
),
),
body: Builder(builder: (context) {
if (widget.selectionCount == 0) {
return Align(
alignment: Alignment.topCenter,
child: Text(S.of(context)!.noItemsFound).padded(16),
);
}
return Column(
children: [
Expanded(
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: widget.selectionCount,
itemBuilder: (BuildContext context, int index) {
final highlight =
AutocompleteHighlightedOption.of(context) == index;
if (highlight) {
SchedulerBinding.instance
.addPostFrameCallback((Duration timeStamp) {
Scrollable.ensureVisible(
context,
alignment: 0,
);
});
}
return widget.selectionBuilder(context, index);
},
),
),
],
);
}),
);
}
}

View File

@@ -0,0 +1,291 @@
// MIT License
//
// Copyright (c) 2019 Simon Lightfoot
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
typedef ChipsInputSuggestions<T> = Future<List<T>> Function(String query);
typedef ChipSelected<T> = void Function(T data, bool selected);
typedef ChipsBuilder<T> = Widget Function(
BuildContext context, ChipsInputState<T> state, T data);
class ChipsInput<T> extends StatefulWidget {
const ChipsInput({
super.key,
this.decoration = const InputDecoration(),
required this.chipBuilder,
required this.suggestionBuilder,
required this.findSuggestions,
required this.onChanged,
this.onChipTapped,
});
final InputDecoration decoration;
final ChipsInputSuggestions<T> findSuggestions;
final ValueChanged<List<T>> onChanged;
final ValueChanged<T>? onChipTapped;
final ChipsBuilder<T> chipBuilder;
final ChipsBuilder<T> suggestionBuilder;
@override
ChipsInputState<T> createState() => ChipsInputState<T>();
}
class ChipsInputState<T> extends State<ChipsInput<T>> {
static const kObjectReplacementChar = 0xFFFC;
Set<T> _chips = {};
List<T> _suggestions = [];
int _searchId = 0;
FocusNode _focusNode = FocusNode();
TextEditingValue _value = const TextEditingValue();
TextInputConnection? _connection;
String get text {
return String.fromCharCodes(
_value.text.codeUnits.where((ch) => ch != kObjectReplacementChar),
);
}
TextEditingValue get currentTextEditingValue => _value;
bool get _hasInputConnection =>
_connection != null && (_connection?.attached ?? false);
void requestKeyboard() {
if (_focusNode.hasFocus) {
_openInputConnection();
} else {
FocusScope.of(context).requestFocus(_focusNode);
}
}
void selectSuggestion(T data) {
setState(() {
_chips.add(data);
_updateTextInputState();
_suggestions = [];
});
widget.onChanged(_chips.toList(growable: false));
}
void deleteChip(T data) {
setState(() {
_chips.remove(data);
_updateTextInputState();
});
widget.onChanged(_chips.toList(growable: false));
}
@override
void initState() {
super.initState();
_focusNode = FocusNode();
_focusNode.addListener(_onFocusChanged);
}
void _onFocusChanged() {
if (_focusNode.hasFocus) {
_openInputConnection();
} else {
_closeInputConnectionIfNeeded();
}
setState(() {
// rebuild so that _TextCursor is hidden.
});
}
@override
void dispose() {
_focusNode.dispose();
_closeInputConnectionIfNeeded();
super.dispose();
}
void _openInputConnection() {
if (!_hasInputConnection) {
_connection?.setEditingState(_value);
}
_connection?.show();
}
void _closeInputConnectionIfNeeded() {
if (_hasInputConnection) {
_connection?.close();
_connection = null;
}
}
@override
Widget build(BuildContext context) {
var chipsChildren = _chips
.map<Widget>(
(data) => widget.chipBuilder(context, this, data),
)
.toList();
final theme = Theme.of(context);
chipsChildren.add(
SizedBox(
height: 32.0,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(
text,
style: theme.textTheme.bodyLarge?.copyWith(
height: 1.5,
),
),
_TextCaret(
resumed: _focusNode.hasFocus,
),
],
),
),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
//mainAxisSize: MainAxisSize.min,
children: <Widget>[
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: requestKeyboard,
child: InputDecorator(
decoration: widget.decoration,
isFocused: _focusNode.hasFocus,
isEmpty: _value.text.isEmpty,
child: Wrap(
children: chipsChildren,
spacing: 4.0,
runSpacing: 4.0,
),
),
),
Expanded(
child: ListView.builder(
itemCount: _suggestions.length,
itemBuilder: (BuildContext context, int index) {
return widget.suggestionBuilder(
context, this, _suggestions[index]);
},
),
),
],
);
}
void updateEditingValue(TextEditingValue value) {
final oldCount = _countReplacements(_value);
final newCount = _countReplacements(value);
setState(() {
if (newCount < oldCount) {
_chips = Set.from(_chips.take(newCount));
}
_value = value;
});
_onSearchChanged(text);
}
int _countReplacements(TextEditingValue value) {
return value.text.codeUnits
.where((ch) => ch == kObjectReplacementChar)
.length;
}
void _updateTextInputState() {
final text =
String.fromCharCodes(_chips.map((_) => kObjectReplacementChar));
_value = TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: text.length),
composing: TextRange(start: 0, end: text.length),
);
_connection?.setEditingState(_value);
}
void _onSearchChanged(String value) async {
final localId = ++_searchId;
final results = await widget.findSuggestions(value);
if (_searchId == localId && mounted) {
setState(() => _suggestions = results
.where((profile) => !_chips.contains(profile))
.toList(growable: false));
}
}
}
class _TextCaret extends StatefulWidget {
const _TextCaret({
this.duration = const Duration(milliseconds: 500),
this.resumed = false,
});
final Duration duration;
final bool resumed;
@override
_TextCursorState createState() => _TextCursorState();
}
class _TextCursorState extends State<_TextCaret>
with SingleTickerProviderStateMixin {
bool _displayed = false;
late Timer _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(widget.duration, _onTimer);
}
void _onTimer(Timer timer) {
setState(() => _displayed = !_displayed);
}
@override
void dispose() {
_timer.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return FractionallySizedBox(
heightFactor: 0.7,
child: Opacity(
opacity: _displayed && widget.resumed ? 1.0 : 0.0,
child: Container(
width: 2.0,
color: theme.primaryColor,
),
),
);
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class OfflineBanner extends StatelessWidget with PreferredSizeWidget {
class OfflineBanner extends StatelessWidget implements PreferredSizeWidget {
const OfflineBanner({super.key});
@override

View File

@@ -36,3 +36,9 @@ extension DateHelpers on DateTime {
yesterday.year == year;
}
}
extension StringNormalizer on String {
String normalized() {
return trim().toLowerCase();
}
}

View File

@@ -1,13 +0,0 @@
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart';
import 'package:paperless_mobile/features/settings/cubit/application_settings_cubit.dart';
extension AddressableHydratedStorage on Storage {
ApplicationSettingsState get settings {
return ApplicationSettingsState.fromJson(read('ApplicationSettingsCubit'));
}
AuthenticationState get authentication {
return AuthenticationState.fromJson(read('AuthenticationCubit'));
}
}

View File

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

View File

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

View File

@@ -0,0 +1,145 @@
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:equatable/equatable.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:freezed_annotation/freezed_annotation.dart';
part 'document_bulk_action_state.dart';
part 'document_bulk_action_cubit.freezed.dart';
class DocumentBulkActionCubit extends Cubit<DocumentBulkActionState> {
final PaperlessDocumentsApi _documentsApi;
final LabelRepository _labelRepository;
final DocumentChangedNotifier _notifier;
DocumentBulkActionCubit(
this._documentsApi,
this._labelRepository,
this._notifier, {
required List<DocumentModel> selection,
}) : super(
DocumentBulkActionState(
selection: selection,
correspondents: _labelRepository.state.correspondents,
documentTypes: _labelRepository.state.documentTypes,
storagePaths: _labelRepository.state.storagePaths,
tags: _labelRepository.state.tags,
),
) {
_notifier.addListener(
this,
onDeleted: (document) {
// Remove items from internal selection after the document was deleted.
emit(
state.copyWith(
selection: state.selection
.whereNot((element) => element.id == document.id)
.toList(),
),
);
},
);
_labelRepository.addListener(
this,
onChanged: (labels) {
emit(
state.copyWith(
correspondents: labels.correspondents,
documentTypes: labels.documentTypes,
storagePaths: labels.storagePaths,
tags: labels.tags,
),
);
},
);
}
Future<void> bulkDelete() async {
final deletedDocumentIds = await _documentsApi.bulkAction(
BulkDeleteAction(state.selection.map((e) => e.id).toList()),
);
final deletedDocuments = state.selection
.where((element) => deletedDocumentIds.contains(element.id));
for (final doc in deletedDocuments) {
_notifier.notifyDeleted(doc);
}
}
Future<void> bulkModifyCorrespondent(int? correspondentId) async {
final modifiedDocumentIds = await _documentsApi.bulkAction(
BulkModifyLabelAction.correspondent(
state.selectedIds,
labelId: correspondentId,
),
);
final updatedDocuments = state.selection
.where((element) => modifiedDocumentIds.contains(element.id))
.map((doc) => doc.copyWith(correspondent: () => correspondentId));
for (final doc in updatedDocuments) {
_notifier.notifyUpdated(doc);
}
}
Future<void> bulkModifyDocumentType(int? documentTypeId) async {
final modifiedDocumentIds = await _documentsApi.bulkAction(
BulkModifyLabelAction.documentType(
state.selectedIds,
labelId: documentTypeId,
),
);
final updatedDocuments = state.selection
.where((element) => modifiedDocumentIds.contains(element.id))
.map((doc) => doc.copyWith(documentType: () => documentTypeId));
for (final doc in updatedDocuments) {
_notifier.notifyUpdated(doc);
}
}
Future<void> bulkModifyStoragePath(int? storagePathId) async {
final modifiedDocumentIds = await _documentsApi.bulkAction(
BulkModifyLabelAction.storagePath(
state.selectedIds,
labelId: storagePathId,
),
);
final updatedDocuments = state.selection
.where((element) => modifiedDocumentIds.contains(element.id))
.map((doc) => doc.copyWith(storagePath: () => storagePathId));
for (final doc in updatedDocuments) {
_notifier.notifyUpdated(doc);
}
}
Future<void> bulkModifyTags({
Iterable<int> addTagIds = const [],
Iterable<int> removeTagIds = const [],
}) async {
final modifiedDocumentIds = await _documentsApi.bulkAction(
BulkModifyTagsAction(
state.selectedIds,
addTags: addTagIds,
removeTags: removeTagIds,
),
);
final updatedDocuments = state.selection
.where((element) => modifiedDocumentIds.contains(element.id))
.map((doc) => doc.copyWith(tags: [
...doc.tags.toSet().difference(removeTagIds.toSet()),
...addTagIds
]));
for (final doc in updatedDocuments) {
_notifier.notifyUpdated(doc);
}
}
@override
Future<void> close() {
_notifier.removeListener(this);
_labelRepository.removeListener(this);
return super.close();
}
}

View File

@@ -0,0 +1,269 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'document_bulk_action_cubit.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods');
/// @nodoc
mixin _$DocumentBulkActionState {
List<DocumentModel> get selection => throw _privateConstructorUsedError;
Map<int, Correspondent> get correspondents =>
throw _privateConstructorUsedError;
Map<int, DocumentType> get documentTypes =>
throw _privateConstructorUsedError;
Map<int, Tag> get tags => throw _privateConstructorUsedError;
Map<int, StoragePath> get storagePaths => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$DocumentBulkActionStateCopyWith<DocumentBulkActionState> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $DocumentBulkActionStateCopyWith<$Res> {
factory $DocumentBulkActionStateCopyWith(DocumentBulkActionState value,
$Res Function(DocumentBulkActionState) then) =
_$DocumentBulkActionStateCopyWithImpl<$Res, DocumentBulkActionState>;
@useResult
$Res call(
{List<DocumentModel> selection,
Map<int, Correspondent> correspondents,
Map<int, DocumentType> documentTypes,
Map<int, Tag> tags,
Map<int, StoragePath> storagePaths});
}
/// @nodoc
class _$DocumentBulkActionStateCopyWithImpl<$Res,
$Val extends DocumentBulkActionState>
implements $DocumentBulkActionStateCopyWith<$Res> {
_$DocumentBulkActionStateCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
@pragma('vm:prefer-inline')
@override
$Res call({
Object? selection = null,
Object? correspondents = null,
Object? documentTypes = null,
Object? tags = null,
Object? storagePaths = null,
}) {
return _then(_value.copyWith(
selection: null == selection
? _value.selection
: selection // ignore: cast_nullable_to_non_nullable
as List<DocumentModel>,
correspondents: null == correspondents
? _value.correspondents
: correspondents // ignore: cast_nullable_to_non_nullable
as Map<int, Correspondent>,
documentTypes: null == documentTypes
? _value.documentTypes
: documentTypes // ignore: cast_nullable_to_non_nullable
as Map<int, DocumentType>,
tags: null == tags
? _value.tags
: tags // ignore: cast_nullable_to_non_nullable
as Map<int, Tag>,
storagePaths: null == storagePaths
? _value.storagePaths
: storagePaths // ignore: cast_nullable_to_non_nullable
as Map<int, StoragePath>,
) as $Val);
}
}
/// @nodoc
abstract class _$$_DocumentBulkActionStateCopyWith<$Res>
implements $DocumentBulkActionStateCopyWith<$Res> {
factory _$$_DocumentBulkActionStateCopyWith(_$_DocumentBulkActionState value,
$Res Function(_$_DocumentBulkActionState) then) =
__$$_DocumentBulkActionStateCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{List<DocumentModel> selection,
Map<int, Correspondent> correspondents,
Map<int, DocumentType> documentTypes,
Map<int, Tag> tags,
Map<int, StoragePath> storagePaths});
}
/// @nodoc
class __$$_DocumentBulkActionStateCopyWithImpl<$Res>
extends _$DocumentBulkActionStateCopyWithImpl<$Res,
_$_DocumentBulkActionState>
implements _$$_DocumentBulkActionStateCopyWith<$Res> {
__$$_DocumentBulkActionStateCopyWithImpl(_$_DocumentBulkActionState _value,
$Res Function(_$_DocumentBulkActionState) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? selection = null,
Object? correspondents = null,
Object? documentTypes = null,
Object? tags = null,
Object? storagePaths = null,
}) {
return _then(_$_DocumentBulkActionState(
selection: null == selection
? _value._selection
: selection // ignore: cast_nullable_to_non_nullable
as List<DocumentModel>,
correspondents: null == correspondents
? _value._correspondents
: correspondents // ignore: cast_nullable_to_non_nullable
as Map<int, Correspondent>,
documentTypes: null == documentTypes
? _value._documentTypes
: documentTypes // ignore: cast_nullable_to_non_nullable
as Map<int, DocumentType>,
tags: null == tags
? _value._tags
: tags // ignore: cast_nullable_to_non_nullable
as Map<int, Tag>,
storagePaths: null == storagePaths
? _value._storagePaths
: storagePaths // ignore: cast_nullable_to_non_nullable
as Map<int, StoragePath>,
));
}
}
/// @nodoc
class _$_DocumentBulkActionState extends _DocumentBulkActionState {
const _$_DocumentBulkActionState(
{required final List<DocumentModel> selection,
required final Map<int, Correspondent> correspondents,
required final Map<int, DocumentType> documentTypes,
required final Map<int, Tag> tags,
required final Map<int, StoragePath> storagePaths})
: _selection = selection,
_correspondents = correspondents,
_documentTypes = documentTypes,
_tags = tags,
_storagePaths = storagePaths,
super._();
final List<DocumentModel> _selection;
@override
List<DocumentModel> get selection {
if (_selection is EqualUnmodifiableListView) return _selection;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_selection);
}
final Map<int, Correspondent> _correspondents;
@override
Map<int, Correspondent> get correspondents {
if (_correspondents is EqualUnmodifiableMapView) return _correspondents;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_correspondents);
}
final Map<int, DocumentType> _documentTypes;
@override
Map<int, DocumentType> get documentTypes {
if (_documentTypes is EqualUnmodifiableMapView) return _documentTypes;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_documentTypes);
}
final Map<int, Tag> _tags;
@override
Map<int, Tag> get tags {
if (_tags is EqualUnmodifiableMapView) return _tags;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_tags);
}
final Map<int, StoragePath> _storagePaths;
@override
Map<int, StoragePath> get storagePaths {
if (_storagePaths is EqualUnmodifiableMapView) return _storagePaths;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_storagePaths);
}
@override
String toString() {
return 'DocumentBulkActionState(selection: $selection, correspondents: $correspondents, documentTypes: $documentTypes, tags: $tags, storagePaths: $storagePaths)';
}
@override
bool operator ==(dynamic other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$_DocumentBulkActionState &&
const DeepCollectionEquality()
.equals(other._selection, _selection) &&
const DeepCollectionEquality()
.equals(other._correspondents, _correspondents) &&
const DeepCollectionEquality()
.equals(other._documentTypes, _documentTypes) &&
const DeepCollectionEquality().equals(other._tags, _tags) &&
const DeepCollectionEquality()
.equals(other._storagePaths, _storagePaths));
}
@override
int get hashCode => Object.hash(
runtimeType,
const DeepCollectionEquality().hash(_selection),
const DeepCollectionEquality().hash(_correspondents),
const DeepCollectionEquality().hash(_documentTypes),
const DeepCollectionEquality().hash(_tags),
const DeepCollectionEquality().hash(_storagePaths));
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$_DocumentBulkActionStateCopyWith<_$_DocumentBulkActionState>
get copyWith =>
__$$_DocumentBulkActionStateCopyWithImpl<_$_DocumentBulkActionState>(
this, _$identity);
}
abstract class _DocumentBulkActionState extends DocumentBulkActionState {
const factory _DocumentBulkActionState(
{required final List<DocumentModel> selection,
required final Map<int, Correspondent> correspondents,
required final Map<int, DocumentType> documentTypes,
required final Map<int, Tag> tags,
required final Map<int, StoragePath> storagePaths}) =
_$_DocumentBulkActionState;
const _DocumentBulkActionState._() : super._();
@override
List<DocumentModel> get selection;
@override
Map<int, Correspondent> get correspondents;
@override
Map<int, DocumentType> get documentTypes;
@override
Map<int, Tag> get tags;
@override
Map<int, StoragePath> get storagePaths;
@override
@JsonKey(ignore: true)
_$$_DocumentBulkActionStateCopyWith<_$_DocumentBulkActionState>
get copyWith => throw _privateConstructorUsedError;
}

View File

@@ -0,0 +1,15 @@
part of 'document_bulk_action_cubit.dart';
@freezed
class DocumentBulkActionState with _$DocumentBulkActionState {
const DocumentBulkActionState._();
const factory DocumentBulkActionState({
required List<DocumentModel> selection,
required Map<int, Correspondent> correspondents,
required Map<int, DocumentType> documentTypes,
required Map<int, Tag> tags,
required Map<int, StoragePath> storagePaths,
}) = _DocumentBulkActionState;
Iterable<int> get selectedIds => selection.map((d) => d.id);
}

View File

@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_cancel_button.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/document_bulk_action/cubit/document_bulk_action_cubit.dart';
import 'package:paperless_mobile/features/labels/view/widgets/label_form_field.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
typedef LabelOptionsSelector<T extends Label> = Map<int, T> Function(DocumentBulkActionState state);
class BulkEditLabelBottomSheet<T extends Label> extends StatefulWidget {
final String title;
final String formFieldLabel;
final Widget formFieldPrefixIcon;
final LabelOptionsSelector<T> availableOptionsSelector;
final void Function(int? selectedId) onSubmit;
final int? initialValue;
const BulkEditLabelBottomSheet({
super.key,
required this.title,
required this.formFieldLabel,
required this.formFieldPrefixIcon,
required this.availableOptionsSelector,
required this.onSubmit,
this.initialValue,
});
@override
State<BulkEditLabelBottomSheet<T>> createState() => _BulkEditLabelBottomSheetState<T>();
}
class _BulkEditLabelBottomSheetState<T extends Label> extends State<BulkEditLabelBottomSheet<T>> {
final _formKey = GlobalKey<FormBuilderState>();
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: BlocBuilder<DocumentBulkActionCubit, DocumentBulkActionState>(
builder: (context, state) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.title,
style: Theme.of(context).textTheme.titleLarge,
).paddedOnly(bottom: 24),
FormBuilder(
key: _formKey,
child: LabelFormField<T>(
initialValue: widget.initialValue != null
? IdQueryParameter.fromId(widget.initialValue!)
: const IdQueryParameter.unset(),
name: "labelFormField",
options: widget.availableOptionsSelector(state),
labelText: widget.formFieldLabel,
prefixIcon: widget.formFieldPrefixIcon,
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
const DialogCancelButton(),
const SizedBox(width: 16),
FilledButton(
onPressed: () {
if (_formKey.currentState?.saveAndValidate() ?? false) {
final value = _formKey.currentState?.getRawValue('labelFormField')
as IdQueryParameter?;
widget
.onSubmit(value?.maybeWhen(fromId: (id) => id, orElse: () => null));
}
},
child: Text(S.of(context)!.apply),
),
],
).padded(8),
],
),
),
);
},
),
);
}
}

View File

@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_cancel_button.dart';
import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_confirm_button.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class ConfirmBulkModifyLabelDialog extends StatelessWidget {
final String content;
const ConfirmBulkModifyLabelDialog({
super.key,
required this.content,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AlertDialog(
title: Text(S.of(context)!.confirmAction),
content: RichText(
text: TextSpan(
style: theme.textTheme.bodyMedium
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
text: content,
children: [
const TextSpan(text: "\n\n"),
TextSpan(
text: S.of(context)!.areYouSureYouWantToContinue,
),
],
),
),
actions: const [
DialogCancelButton(),
DialogConfirmButton(
style: DialogConfirmButtonStyle.danger,
),
],
);
}
}

View File

@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:flutter/src/widgets/framework.dart';
import 'package:flutter/src/widgets/placeholder.dart';
import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_cancel_button.dart';
import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_confirm_button.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class ConfirmBulkModifyTagsDialog extends StatelessWidget {
final int selectionCount;
final List<String> removeTags;
final List<String> addTags;
const ConfirmBulkModifyTagsDialog({
super.key,
required this.removeTags,
required this.addTags,
required this.selectionCount,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AlertDialog(
title: Text(S.of(context)!.confirmAction),
content: RichText(
text: TextSpan(
style: theme.textTheme.bodyMedium
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
text: _buildText(context),
children: [
const TextSpan(text: "\n\n"),
TextSpan(
text: S.of(context)!.areYouSureYouWantToContinue,
),
],
),
),
actions: const [
DialogCancelButton(),
DialogConfirmButton(
style: DialogConfirmButtonStyle.danger,
),
],
);
}
String _buildText(BuildContext context) {
if (removeTags.isNotEmpty && addTags.isNotEmpty) {
return S.of(context)!.bulkEditTagsModifyMessage(
addTags.join(", "),
selectionCount,
removeTags.join(", "),
);
} else if (removeTags.isNotEmpty) {
return S.of(context)!.bulkEditTagsRemoveMessage(
selectionCount,
removeTags.join(", "),
);
} else {
return S.of(context)!.bulkEditTagsAddMessage(
selectionCount,
addTags.join(", "),
);
}
}
}

View File

@@ -0,0 +1,161 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/widgets/form_fields/fullscreen_selection_form.dart';
import 'package:paperless_mobile/extensions/dart_extensions.dart';
import 'package:paperless_mobile/features/document_bulk_action/view/widgets/confirm_bulk_modify_label_dialog.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class FullscreenBulkEditLabelPage extends StatefulWidget {
final String hintText;
final Map<int, Label> options;
final List<DocumentModel> selection;
final int? Function(DocumentModel document) labelMapper;
final Widget leadingIcon;
final void Function(int? id) onSubmit;
final String Function(int count) removeMessageBuilder;
final String Function(int count, String name) assignMessageBuilder;
FullscreenBulkEditLabelPage({
super.key,
required this.options,
required this.selection,
required this.labelMapper,
required this.leadingIcon,
required this.hintText,
required this.onSubmit,
required this.removeMessageBuilder,
required this.assignMessageBuilder,
}) : assert(selection.isNotEmpty);
@override
State<FullscreenBulkEditLabelPage> createState() =>
_FullscreenBulkEditLabelPageState();
}
class _FullscreenBulkEditLabelPageState<T extends Label>
extends State<FullscreenBulkEditLabelPage> {
final _controller = TextEditingController();
LabelSelection? _selection;
@override
void initState() {
super.initState();
_controller.addListener(() {
setState(() {});
});
if (_initialValues.length == 1 && _initialValues.first != null) {
_selection = LabelSelection(_initialValues.first);
}
}
List<int?> get _initialValues =>
widget.selection.map(widget.labelMapper).toSet().toList();
Iterable<int> _generateOrderedLabels() sync* {
final _availableValues = widget.options.values
.where(
(e) => e.name.normalized().contains(_controller.text.normalized()))
.map((e) => e.id!)
.toSet();
for (var label
in _initialValues.toSet().intersection(_availableValues.toSet())) {
if (label != null) {
yield label;
}
}
for (final id
in _availableValues.whereNot((e) => _initialValues.contains(e))) {
yield id;
}
}
@override
Widget build(BuildContext context) {
final _labels = _generateOrderedLabels();
final hideFab = _selection == null ||
(_initialValues.length == 1 &&
_selection?.label == _initialValues.first);
return FullscreenSelectionForm(
controller: _controller,
hintText: widget.hintText,
leadingIcon: widget.leadingIcon,
selectionBuilder: (context, index) =>
_buildItem(widget.options[_labels.elementAt(index)]!),
selectionCount: _labels.length,
floatingActionButton: !hideFab
? FloatingActionButton.extended(
onPressed: _onSubmit,
label: Text(S.of(context)!.apply),
icon: const Icon(Icons.done),
)
: null,
);
}
Widget _buildItem(Label label) {
Widget? trailingIcon;
if (_initialValues.length > 1 &&
_selection == null &&
_initialValues.contains(label.id)) {
trailingIcon = const Icon(Icons.remove);
} else if (_selection?.label == label.id) {
trailingIcon = const Icon(Icons.done);
}
return ListTile(
title: Text(label.name),
trailing: trailingIcon,
onTap: () {
if (_selection?.label == label.id) {
setState(() {
_selection = LabelSelection(null);
});
} else {
setState(() {
_selection = LabelSelection(label.id);
});
}
},
);
}
void _onSubmit() async {
if (_selection == null) {
Navigator.pop(context);
} else {
bool shouldPerformAction;
if (_selection!.label == null) {
shouldPerformAction = await showDialog<bool>(
context: context,
builder: (context) => ConfirmBulkModifyLabelDialog(
content: widget.removeMessageBuilder(widget.selection.length),
),
) ??
false;
} else {
final labelName = widget.options[_selection!.label]!.name;
shouldPerformAction = await showDialog<bool>(
context: context,
builder: (context) => ConfirmBulkModifyLabelDialog(
content: widget.assignMessageBuilder(
widget.selection.length,
'"$labelName"',
),
),
) ??
false;
}
if (shouldPerformAction) {
widget.onSubmit(_selection!.label);
Navigator.pop(context);
}
}
}
}
class LabelSelection {
final int? label;
LabelSelection(this.label);
}

View File

@@ -0,0 +1,180 @@
import 'package:collection/collection.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/widgets/form_fields/fullscreen_selection_form.dart';
import 'package:paperless_mobile/extensions/dart_extensions.dart';
import 'package:paperless_mobile/features/document_bulk_action/cubit/document_bulk_action_cubit.dart';
import 'package:paperless_mobile/features/document_bulk_action/view/widgets/confirm_bulk_modify_tags_dialog.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class FullscreenBulkEditTagsWidget extends StatefulWidget {
const FullscreenBulkEditTagsWidget({super.key});
@override
State<FullscreenBulkEditTagsWidget> createState() =>
_FullscreenBulkEditTagsWidgetState();
}
class _FullscreenBulkEditTagsWidgetState
extends State<FullscreenBulkEditTagsWidget> {
final TextEditingController _controller = TextEditingController();
/// Tags shared by all documents
late final List<int> _sharedTags;
/// Tags not assigned to at least one document in the selection
late final List<int> _nonSharedTags;
List<int> _addTags = [];
List<int> _removeTags = [];
late List<int> _filteredTags;
@override
void initState() {
super.initState();
final state = context.read<DocumentBulkActionCubit>().state;
_sharedTags = state.selection
.map((e) => e.tags)
.map((e) => e.toSet())
.fold(
state.tags.values.map((e) => e.id!).toSet(),
(previousValue, element) => previousValue.intersection(element),
)
.toList();
_nonSharedTags = state.selection
.map((e) => e.tags)
.flattened
.toSet()
.difference(_sharedTags.toSet())
.toList();
_filteredTags = state.tags.keys.toList();
_controller.addListener(() {
setState(() {
_filteredTags = context
.read<DocumentBulkActionCubit>()
.state
.tags
.values
.where((e) =>
e.name.normalized().contains(_controller.text.normalized()))
.map((e) => e.id!)
.toList();
});
});
}
List<int> get _assignedTags => [..._sharedTags, ..._nonSharedTags];
@override
Widget build(BuildContext context) {
return BlocBuilder<DocumentBulkActionCubit, DocumentBulkActionState>(
builder: (context, state) {
return FullscreenSelectionForm(
controller: _controller,
floatingActionButton: _addTags.isNotEmpty || _removeTags.isNotEmpty
? FloatingActionButton.extended(
label: Text(S.of(context)!.apply),
icon: const Icon(Icons.done),
onPressed: _submit,
)
: null,
hintText: S.of(context)!.startTyping,
leadingIcon: const Icon(Icons.label_outline),
selectionBuilder: (context, index) {
return _buildTagOption(
_filteredTags[index],
state.tags,
);
},
selectionCount: _filteredTags.length,
);
},
);
}
Widget _buildTagOption(int id, Map<int, Tag> options) {
Widget? icon;
if (_sharedTags.contains(id) && !_removeTags.contains(id)) {
// Tag is assigned to all documents and not marked for removal
// => will remain assigned
icon = const Icon(Icons.done);
} else if (_addTags.contains(id)) {
// tag is marked to be added
icon = const Icon(Icons.done);
} else if (_nonSharedTags.contains(id) && !_removeTags.contains(id)) {
// Tag is neither shared among all documents, nor marked to be removed or
// added but assigned to at least one document
icon = const Icon(Icons.remove);
}
return ListTile(
title: Text(options[id]!.name),
trailing: icon,
leading: CircleAvatar(
backgroundColor: options[id]!.color,
foregroundColor: options[id]!.textColor,
child: options[id]!.isInboxTag ? const Icon(Icons.inbox) : null,
),
onTap: () {
if (_addTags.contains(id)) {
setState(() {
_addTags.remove(id);
});
if (_assignedTags.contains(id)) {
setState(() {
_removeTags.add(id);
});
}
} else if (_removeTags.contains(id)) {
setState(() {
_removeTags.remove(id);
});
if (!_sharedTags.contains(id)) {
setState(() {
_addTags.add(id);
});
}
} else {
if (_sharedTags.contains(id)) {
setState(() {
_removeTags.add(id);
});
} else {
setState(() {
_addTags.add(id);
});
}
}
},
);
}
void _submit() async {
if (_addTags.isNotEmpty || _removeTags.isNotEmpty) {
final bloc = context.read<DocumentBulkActionCubit>();
final addNames = _addTags
.map((value) => "\"${bloc.state.tags[value]!.name}\"")
.toList();
final removeNames = _removeTags
.map((value) => "\"${bloc.state.tags[value]!.name}\"")
.toList();
final shouldPerformAction = await showDialog<bool>(
context: context,
builder: (context) => ConfirmBulkModifyTagsDialog(
selectionCount: bloc.state.selection.length,
addTags: addNames,
removeTags: removeNames,
),
) ??
false;
if (shouldPerformAction) {
bloc.bulkModifyTags(
removeTagIds: _removeTags,
addTagIds: _addTags,
);
Navigator.pop(context);
}
}
}
}

View File

@@ -0,0 +1,30 @@
// import 'package:flutter/material.dart';
// import 'package:flutter/src/widgets/framework.dart';
// import 'package:flutter/src/widgets/placeholder.dart';
// class LabelBulkSelectionWidget extends StatelessWidget {
// final int labelId;
// final String title;
// final bool selected;
// final bool excluded;
// final Widget Function(int id) leadingWidgetBuilder;
// final void Function(int id) onSelected;
// final void Function(int id) onUnselected;
// final void Function(int id) onRemoved;
// const LabelBulkSelectionWidget({
// super.key,
// required this.labelId,
// required this.title,
// required this.leadingWidgetBuilder,
// required this.onSelected,
// required this.onUnselected,
// required this.onRemoved,
// });
// @override
// Widget build(BuildContext context) {
// return ListTile(
// title: Text(title),
// );
// }
// }

View File

@@ -2,32 +2,47 @@ import 'dart:async';
import 'dart:io';
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:open_filex/open_filex.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/file_description.dart';
import 'package:paperless_mobile/core/service/file_service.dart';
import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
part 'document_details_cubit.freezed.dart';
part 'document_details_state.dart';
class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
final PaperlessDocumentsApi _api;
final DocumentChangedNotifier _notifier;
final LocalNotificationService _notificationService;
final LabelRepository _labelRepository;
final List<StreamSubscription> _subscriptions = [];
DocumentDetailsCubit(
this._api,
this._labelRepository,
this._notifier,
this._notificationService, {
required DocumentModel initialDocument,
}) : super(DocumentDetailsState(document: initialDocument)) {
_notifier.subscribe(this, onUpdated: replace);
}) : super(DocumentDetailsState(
document: initialDocument,
)) {
_notifier.addListener(this, onUpdated: replace);
_labelRepository.addListener(
this,
onChanged: (labels) => emit(
state.copyWith(
correspondents: labels.correspondents,
documentTypes: labels.documentTypes,
tags: labels.tags,
storagePaths: labels.storagePaths,
),
),
);
loadSuggestions();
loadMetaData();
}
@@ -39,12 +54,16 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
Future<void> loadSuggestions() async {
final suggestions = await _api.findSuggestions(state.document);
emit(state.copyWith(suggestions: suggestions));
if (!isClosed) {
emit(state.copyWith(suggestions: suggestions));
}
}
Future<void> loadMetaData() async {
final metaData = await _api.getMetaData(state.document);
emit(state.copyWith(metaData: metaData));
if (!isClosed) {
emit(state.copyWith(metaData: metaData));
}
}
Future<void> loadFullContent() async {
@@ -70,8 +89,8 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
_notifier.notifyUpdated(updatedDocument);
} else {
final int autoAsn = await _api.findNextAsn();
final updatedDocument = await _api
.update(document.copyWith(archiveSerialNumber: () => autoAsn));
final updatedDocument =
await _api.update(document.copyWith(archiveSerialNumber: () => autoAsn));
_notifier.notifyUpdated(updatedDocument);
}
}
@@ -82,8 +101,7 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
if (state.metaData == null) {
await loadMetaData();
}
final desc = FileDescription.fromPath(
state.metaData!.mediaFilename.replaceAll("/", " "));
final desc = FileDescription.fromPath(state.metaData!.mediaFilename.replaceAll("/", " "));
final fileName = "${desc.filename}.pdf";
final file = File("${cacheDir.path}/$fileName");
@@ -117,8 +135,7 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
await FileService.downloadsDirectory,
);
final desc = FileDescription.fromPath(
state.metaData!.mediaFilename
.replaceAll("/", " "), // Flatten directory structure
state.metaData!.mediaFilename.replaceAll("/", " "), // Flatten directory structure
);
if (!File(filePath).existsSync()) {
File(filePath).createSync();
@@ -183,8 +200,7 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
String _buildDownloadFilePath(bool original, Directory dir) {
final description = FileDescription.fromPath(
state.metaData!.mediaFilename
.replaceAll("/", " "), // Flatten directory structure
state.metaData!.mediaFilename.replaceAll("/", " "), // Flatten directory structure
);
final extension = original ? description.extension : 'pdf';
return "${dir.path}/${description.filename}.$extension";
@@ -192,10 +208,8 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
@override
Future<void> close() async {
for (final element in _subscriptions) {
await element.cancel();
}
_notifier.unsubscribe(this);
_labelRepository.removeListener(this);
_notifier.removeListener(this);
await super.close();
}
}

View File

@@ -0,0 +1,350 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'document_details_cubit.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods');
/// @nodoc
mixin _$DocumentDetailsState {
DocumentModel get document => throw _privateConstructorUsedError;
DocumentMetaData? get metaData => throw _privateConstructorUsedError;
bool get isFullContentLoaded => throw _privateConstructorUsedError;
String? get fullContent => throw _privateConstructorUsedError;
FieldSuggestions? get suggestions => throw _privateConstructorUsedError;
Map<int, Correspondent> get correspondents =>
throw _privateConstructorUsedError;
Map<int, DocumentType> get documentTypes =>
throw _privateConstructorUsedError;
Map<int, Tag> get tags => throw _privateConstructorUsedError;
Map<int, StoragePath> get storagePaths => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$DocumentDetailsStateCopyWith<DocumentDetailsState> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $DocumentDetailsStateCopyWith<$Res> {
factory $DocumentDetailsStateCopyWith(DocumentDetailsState value,
$Res Function(DocumentDetailsState) then) =
_$DocumentDetailsStateCopyWithImpl<$Res, DocumentDetailsState>;
@useResult
$Res call(
{DocumentModel document,
DocumentMetaData? metaData,
bool isFullContentLoaded,
String? fullContent,
FieldSuggestions? suggestions,
Map<int, Correspondent> correspondents,
Map<int, DocumentType> documentTypes,
Map<int, Tag> tags,
Map<int, StoragePath> storagePaths});
}
/// @nodoc
class _$DocumentDetailsStateCopyWithImpl<$Res,
$Val extends DocumentDetailsState>
implements $DocumentDetailsStateCopyWith<$Res> {
_$DocumentDetailsStateCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
@pragma('vm:prefer-inline')
@override
$Res call({
Object? document = null,
Object? metaData = freezed,
Object? isFullContentLoaded = null,
Object? fullContent = freezed,
Object? suggestions = freezed,
Object? correspondents = null,
Object? documentTypes = null,
Object? tags = null,
Object? storagePaths = null,
}) {
return _then(_value.copyWith(
document: null == document
? _value.document
: document // ignore: cast_nullable_to_non_nullable
as DocumentModel,
metaData: freezed == metaData
? _value.metaData
: metaData // ignore: cast_nullable_to_non_nullable
as DocumentMetaData?,
isFullContentLoaded: null == isFullContentLoaded
? _value.isFullContentLoaded
: isFullContentLoaded // ignore: cast_nullable_to_non_nullable
as bool,
fullContent: freezed == fullContent
? _value.fullContent
: fullContent // ignore: cast_nullable_to_non_nullable
as String?,
suggestions: freezed == suggestions
? _value.suggestions
: suggestions // ignore: cast_nullable_to_non_nullable
as FieldSuggestions?,
correspondents: null == correspondents
? _value.correspondents
: correspondents // ignore: cast_nullable_to_non_nullable
as Map<int, Correspondent>,
documentTypes: null == documentTypes
? _value.documentTypes
: documentTypes // ignore: cast_nullable_to_non_nullable
as Map<int, DocumentType>,
tags: null == tags
? _value.tags
: tags // ignore: cast_nullable_to_non_nullable
as Map<int, Tag>,
storagePaths: null == storagePaths
? _value.storagePaths
: storagePaths // ignore: cast_nullable_to_non_nullable
as Map<int, StoragePath>,
) as $Val);
}
}
/// @nodoc
abstract class _$$_DocumentDetailsStateCopyWith<$Res>
implements $DocumentDetailsStateCopyWith<$Res> {
factory _$$_DocumentDetailsStateCopyWith(_$_DocumentDetailsState value,
$Res Function(_$_DocumentDetailsState) then) =
__$$_DocumentDetailsStateCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{DocumentModel document,
DocumentMetaData? metaData,
bool isFullContentLoaded,
String? fullContent,
FieldSuggestions? suggestions,
Map<int, Correspondent> correspondents,
Map<int, DocumentType> documentTypes,
Map<int, Tag> tags,
Map<int, StoragePath> storagePaths});
}
/// @nodoc
class __$$_DocumentDetailsStateCopyWithImpl<$Res>
extends _$DocumentDetailsStateCopyWithImpl<$Res, _$_DocumentDetailsState>
implements _$$_DocumentDetailsStateCopyWith<$Res> {
__$$_DocumentDetailsStateCopyWithImpl(_$_DocumentDetailsState _value,
$Res Function(_$_DocumentDetailsState) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? document = null,
Object? metaData = freezed,
Object? isFullContentLoaded = null,
Object? fullContent = freezed,
Object? suggestions = freezed,
Object? correspondents = null,
Object? documentTypes = null,
Object? tags = null,
Object? storagePaths = null,
}) {
return _then(_$_DocumentDetailsState(
document: null == document
? _value.document
: document // ignore: cast_nullable_to_non_nullable
as DocumentModel,
metaData: freezed == metaData
? _value.metaData
: metaData // ignore: cast_nullable_to_non_nullable
as DocumentMetaData?,
isFullContentLoaded: null == isFullContentLoaded
? _value.isFullContentLoaded
: isFullContentLoaded // ignore: cast_nullable_to_non_nullable
as bool,
fullContent: freezed == fullContent
? _value.fullContent
: fullContent // ignore: cast_nullable_to_non_nullable
as String?,
suggestions: freezed == suggestions
? _value.suggestions
: suggestions // ignore: cast_nullable_to_non_nullable
as FieldSuggestions?,
correspondents: null == correspondents
? _value._correspondents
: correspondents // ignore: cast_nullable_to_non_nullable
as Map<int, Correspondent>,
documentTypes: null == documentTypes
? _value._documentTypes
: documentTypes // ignore: cast_nullable_to_non_nullable
as Map<int, DocumentType>,
tags: null == tags
? _value._tags
: tags // ignore: cast_nullable_to_non_nullable
as Map<int, Tag>,
storagePaths: null == storagePaths
? _value._storagePaths
: storagePaths // ignore: cast_nullable_to_non_nullable
as Map<int, StoragePath>,
));
}
}
/// @nodoc
class _$_DocumentDetailsState implements _DocumentDetailsState {
const _$_DocumentDetailsState(
{required this.document,
this.metaData,
this.isFullContentLoaded = false,
this.fullContent,
this.suggestions,
final Map<int, Correspondent> correspondents = const {},
final Map<int, DocumentType> documentTypes = const {},
final Map<int, Tag> tags = const {},
final Map<int, StoragePath> storagePaths = const {}})
: _correspondents = correspondents,
_documentTypes = documentTypes,
_tags = tags,
_storagePaths = storagePaths;
@override
final DocumentModel document;
@override
final DocumentMetaData? metaData;
@override
@JsonKey()
final bool isFullContentLoaded;
@override
final String? fullContent;
@override
final FieldSuggestions? suggestions;
final Map<int, Correspondent> _correspondents;
@override
@JsonKey()
Map<int, Correspondent> get correspondents {
if (_correspondents is EqualUnmodifiableMapView) return _correspondents;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_correspondents);
}
final Map<int, DocumentType> _documentTypes;
@override
@JsonKey()
Map<int, DocumentType> get documentTypes {
if (_documentTypes is EqualUnmodifiableMapView) return _documentTypes;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_documentTypes);
}
final Map<int, Tag> _tags;
@override
@JsonKey()
Map<int, Tag> get tags {
if (_tags is EqualUnmodifiableMapView) return _tags;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_tags);
}
final Map<int, StoragePath> _storagePaths;
@override
@JsonKey()
Map<int, StoragePath> get storagePaths {
if (_storagePaths is EqualUnmodifiableMapView) return _storagePaths;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_storagePaths);
}
@override
String toString() {
return 'DocumentDetailsState(document: $document, metaData: $metaData, isFullContentLoaded: $isFullContentLoaded, fullContent: $fullContent, suggestions: $suggestions, correspondents: $correspondents, documentTypes: $documentTypes, tags: $tags, storagePaths: $storagePaths)';
}
@override
bool operator ==(dynamic other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$_DocumentDetailsState &&
(identical(other.document, document) ||
other.document == document) &&
(identical(other.metaData, metaData) ||
other.metaData == metaData) &&
(identical(other.isFullContentLoaded, isFullContentLoaded) ||
other.isFullContentLoaded == isFullContentLoaded) &&
(identical(other.fullContent, fullContent) ||
other.fullContent == fullContent) &&
(identical(other.suggestions, suggestions) ||
other.suggestions == suggestions) &&
const DeepCollectionEquality()
.equals(other._correspondents, _correspondents) &&
const DeepCollectionEquality()
.equals(other._documentTypes, _documentTypes) &&
const DeepCollectionEquality().equals(other._tags, _tags) &&
const DeepCollectionEquality()
.equals(other._storagePaths, _storagePaths));
}
@override
int get hashCode => Object.hash(
runtimeType,
document,
metaData,
isFullContentLoaded,
fullContent,
suggestions,
const DeepCollectionEquality().hash(_correspondents),
const DeepCollectionEquality().hash(_documentTypes),
const DeepCollectionEquality().hash(_tags),
const DeepCollectionEquality().hash(_storagePaths));
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$_DocumentDetailsStateCopyWith<_$_DocumentDetailsState> get copyWith =>
__$$_DocumentDetailsStateCopyWithImpl<_$_DocumentDetailsState>(
this, _$identity);
}
abstract class _DocumentDetailsState implements DocumentDetailsState {
const factory _DocumentDetailsState(
{required final DocumentModel document,
final DocumentMetaData? metaData,
final bool isFullContentLoaded,
final String? fullContent,
final FieldSuggestions? suggestions,
final Map<int, Correspondent> correspondents,
final Map<int, DocumentType> documentTypes,
final Map<int, Tag> tags,
final Map<int, StoragePath> storagePaths}) = _$_DocumentDetailsState;
@override
DocumentModel get document;
@override
DocumentMetaData? get metaData;
@override
bool get isFullContentLoaded;
@override
String? get fullContent;
@override
FieldSuggestions? get suggestions;
@override
Map<int, Correspondent> get correspondents;
@override
Map<int, DocumentType> get documentTypes;
@override
Map<int, Tag> get tags;
@override
Map<int, StoragePath> get storagePaths;
@override
@JsonKey(ignore: true)
_$$_DocumentDetailsStateCopyWith<_$_DocumentDetailsState> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@@ -1,42 +1,16 @@
part of 'document_details_cubit.dart';
class DocumentDetailsState with EquatableMixin {
final DocumentModel document;
final DocumentMetaData? metaData;
final bool isFullContentLoaded;
final String? fullContent;
final FieldSuggestions suggestions;
const DocumentDetailsState({
required this.document,
this.metaData,
this.suggestions = const FieldSuggestions(),
this.isFullContentLoaded = false,
this.fullContent,
});
@override
List<Object?> get props => [
document,
suggestions,
isFullContentLoaded,
fullContent,
metaData,
];
DocumentDetailsState copyWith({
DocumentModel? document,
FieldSuggestions? suggestions,
bool? isFullContentLoaded,
String? fullContent,
@freezed
class DocumentDetailsState with _$DocumentDetailsState {
const factory DocumentDetailsState({
required DocumentModel document,
DocumentMetaData? metaData,
}) {
return DocumentDetailsState(
document: document ?? this.document,
suggestions: suggestions ?? this.suggestions,
isFullContentLoaded: isFullContentLoaded ?? this.isFullContentLoaded,
fullContent: fullContent ?? this.fullContent,
metaData: metaData ?? this.metaData,
);
}
@Default(false) bool isFullContentLoaded,
String? fullContent,
FieldSuggestions? suggestions,
@Default({}) Map<int, Correspondent> correspondents,
@Default({}) Map<int, DocumentType> documentTypes,
@Default({}) Map<int, Tag> tags,
@Default({}) Map<int, StoragePath> storagePaths,
}) = _DocumentDetailsState;
}

View File

@@ -5,7 +5,7 @@ import 'package:open_filex/open_filex.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart';
import 'package:paperless_mobile/core/translation/error_code_localization_mapper.dart';
import 'package:paperless_mobile/core/widgets/material/search/colored_tab_bar.dart';
import 'package:paperless_mobile/core/widgets/material/colored_tab_bar.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.dart';
import 'package:paperless_mobile/features/document_details/view/widgets/document_content_widget.dart';
@@ -42,6 +42,8 @@ class DocumentDetailsPage extends StatefulWidget {
class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
late Future<DocumentMetaData> _metaData;
static const double _itemSpacing = 24;
final _pagingScrollController = ScrollController();
@override
void initState() {
super.initState();
@@ -58,116 +60,104 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
Navigator.of(context)
.pop(context.read<DocumentDetailsCubit>().state.document);
Navigator.of(context).pop(context.read<DocumentDetailsCubit>().state.document);
return false;
},
child: DefaultTabController(
length: 4,
child: BlocListener<ConnectivityCubit, ConnectivityState>(
listenWhen: (previous, current) =>
!previous.isConnected && current.isConnected,
listenWhen: (previous, current) => !previous.isConnected && current.isConnected,
listener: (context, state) {
_loadMetaData();
setState(() {});
},
child: Scaffold(
extendBodyBehindAppBar: false,
floatingActionButtonLocation:
FloatingActionButtonLocation.endDocked,
floatingActionButtonLocation: FloatingActionButtonLocation.endDocked,
floatingActionButton: widget.allowEdit ? _buildEditButton() : null,
bottomNavigationBar: _buildBottomAppBar(),
body: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverAppBar(
title: Text(context
.watch<DocumentDetailsCubit>()
.state
.document
.title),
leading: const BackButton(),
pinned: true,
forceElevated: innerBoxIsScrolled,
collapsedHeight: kToolbarHeight,
expandedHeight: 250.0,
flexibleSpace: FlexibleSpaceBar(
background: Stack(
alignment: Alignment.topCenter,
children: [
BlocBuilder<DocumentDetailsCubit, DocumentDetailsState>(
builder: (context, state) => Positioned.fill(
child: DocumentPreview(
document: state.document,
fit: BoxFit.cover,
),
),
),
Positioned.fill(
top: 0,
child: Container(
height: 100,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.black.withOpacity(0.7),
Colors.black.withOpacity(0.2),
Colors.transparent,
Colors.transparent,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
SliverOverlapAbsorber(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: SliverAppBar(
title: Text(context.watch<DocumentDetailsCubit>().state.document.title),
leading: const BackButton(),
pinned: true,
forceElevated: innerBoxIsScrolled,
collapsedHeight: kToolbarHeight,
expandedHeight: 250.0,
flexibleSpace: FlexibleSpaceBar(
background: Stack(
alignment: Alignment.topCenter,
children: [
BlocBuilder<DocumentDetailsCubit, DocumentDetailsState>(
builder: (context, state) => Positioned.fill(
child: DocumentPreview(
document: state.document,
fit: BoxFit.cover,
),
),
),
),
],
Positioned.fill(
top: 0,
child: Container(
height: 100,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.black.withOpacity(0.7),
Colors.black.withOpacity(0.2),
Colors.transparent,
Colors.transparent,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
),
),
],
),
),
),
bottom: ColoredTabBar(
tabBar: TabBar(
isScrollable: true,
tabs: [
Tab(
child: Text(
S.of(context)!.overview,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
bottom: ColoredTabBar(
tabBar: TabBar(
isScrollable: true,
tabs: [
Tab(
child: Text(
S.of(context)!.overview,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
),
Tab(
child: Text(
S.of(context)!.content,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
Tab(
child: Text(
S.of(context)!.content,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
),
Tab(
child: Text(
S.of(context)!.metaData,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
Tab(
child: Text(
S.of(context)!.metaData,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
),
Tab(
child: Text(
S.of(context)!.similarDocuments,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
Tab(
child: Text(
S.of(context)!.similarDocuments,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
),
],
],
),
),
),
),
@@ -176,29 +166,71 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
builder: (context, state) {
return BlocProvider(
create: (context) => SimilarDocumentsCubit(
context.read(),
context.read(),
context.read(),
documentId: state.document.id,
),
child: TabBarView(
children: [
DocumentOverviewWidget(
document: state.document,
itemSpacing: _itemSpacing,
queryString: widget.titleAndContentQueryString,
),
DocumentContentWidget(
isFullContentLoaded: state.isFullContentLoaded,
document: state.document,
fullContent: state.fullContent,
queryString: widget.titleAndContentQueryString,
),
DocumentMetaDataWidget(
document: state.document,
itemSpacing: _itemSpacing,
),
const SimilarDocumentsView(),
],
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 16,
horizontal: 16,
),
child: TabBarView(
children: [
CustomScrollView(
slivers: [
SliverOverlapInjector(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
),
DocumentOverviewWidget(
document: state.document,
itemSpacing: _itemSpacing,
queryString: widget.titleAndContentQueryString,
availableCorrespondents: state.correspondents,
availableDocumentTypes: state.documentTypes,
availableTags: state.tags,
availableStoragePaths: state.storagePaths,
),
],
),
CustomScrollView(
slivers: [
SliverOverlapInjector(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
),
DocumentContentWidget(
isFullContentLoaded: state.isFullContentLoaded,
document: state.document,
fullContent: state.fullContent,
queryString: widget.titleAndContentQueryString,
),
],
),
CustomScrollView(
slivers: [
SliverOverlapInjector(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
),
DocumentMetaDataWidget(
document: state.document,
itemSpacing: _itemSpacing,
),
],
),
CustomScrollView(
controller: _pagingScrollController,
slivers: [
SliverOverlapInjector(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
),
SimilarDocumentsView(
pagingScrollController: _pagingScrollController,
),
],
),
],
),
),
);
},
@@ -213,32 +245,21 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
Widget _buildEditButton() {
return BlocBuilder<DocumentDetailsCubit, DocumentDetailsState>(
builder: (context, state) {
final _filteredSuggestions =
state.suggestions.documentDifference(state.document);
// final _filteredSuggestions =
// state.suggestions?.documentDifference(state.document);
return BlocBuilder<ConnectivityCubit, ConnectivityState>(
builder: (context, connectivityState) {
if (!connectivityState.isConnected) {
return const SizedBox.shrink();
}
return b.Badge(
position: b.BadgePosition.topEnd(top: -12, end: -6),
showBadge: _filteredSuggestions.hasSuggestions,
child: Tooltip(
message: S.of(context)!.editDocumentTooltip,
preferBelow: false,
verticalOffset: 40,
child: FloatingActionButton(
child: const Icon(Icons.edit),
onPressed: () => _onEdit(state.document),
),
return Tooltip(
message: S.of(context)!.editDocumentTooltip,
preferBelow: false,
verticalOffset: 40,
child: FloatingActionButton(
child: const Icon(Icons.edit),
onPressed: () => _onEdit(state.document),
),
badgeContent: Text(
'${_filteredSuggestions.suggestionsCount}',
style: const TextStyle(
color: Colors.white,
),
),
badgeColor: Colors.red,
);
},
);
@@ -259,9 +280,8 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
IconButton(
tooltip: S.of(context)!.deleteDocumentTooltip,
icon: const Icon(Icons.delete),
onPressed: widget.allowEdit && isConnected
? () => _onDelete(state.document)
: null,
onPressed:
widget.allowEdit && isConnected ? () => _onDelete(state.document) : null,
).paddedSymmetrically(horizontal: 4),
DocumentDownloadButton(
document: state.document,
@@ -271,8 +291,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
IconButton(
tooltip: S.of(context)!.previewTooltip,
icon: const Icon(Icons.visibility),
onPressed:
isConnected ? () => _onOpen(state.document) : null,
onPressed: isConnected ? () => _onOpen(state.document) : null,
).paddedOnly(right: 4.0),
IconButton(
tooltip: S.of(context)!.openInSystemViewer,
@@ -299,13 +318,10 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
providers: [
BlocProvider.value(
value: DocumentEditCubit(
document,
documentsApi: context.read(),
correspondentRepository: context.read(),
documentTypeRepository: context.read(),
storagePathRepository: context.read(),
tagRepository: context.read(),
notifier: context.read(),
context.read(),
context.read(),
context.read(),
document: document,
),
),
BlocProvider<DocumentDetailsCubit>.value(
@@ -313,8 +329,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
),
],
child: BlocListener<DocumentEditCubit, DocumentEditState>(
listenWhen: (previous, current) =>
previous.document != current.document,
listenWhen: (previous, current) => previous.document != current.document,
listener: (context, state) {
cubit.replace(state.document);
},
@@ -334,8 +349,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
}
void _onOpenFileInSystemViewer() async {
final status =
await context.read<DocumentDetailsCubit>().openDocumentInSystemViewer();
final status = await context.read<DocumentDetailsCubit>().openDocumentInSystemViewer();
if (status == ResultType.done) return;
if (status == ResultType.noAppToOpen) {
showGenericError(context, S.of(context)!.noAppToDisplayPDFFilesFound);
@@ -344,16 +358,14 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
showGenericError(context, translateError(context, ErrorCode.unknown));
}
if (status == ResultType.permissionDenied) {
showGenericError(
context, S.of(context)!.couldNotOpenFilePermissionDenied);
showGenericError(context, S.of(context)!.couldNotOpenFilePermissionDenied);
}
}
void _onDelete(DocumentModel document) async {
final delete = await showDialog(
context: context,
builder: (context) =>
DeleteDocumentConfirmationDialog(document: document),
builder: (context) => DeleteDocumentConfirmationDialog(document: document),
) ??
false;
if (delete) {
@@ -373,8 +385,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => DocumentView(
documentBytes:
context.read<PaperlessDocumentsApi>().getPreview(document.id),
documentBytes: context.read<PaperlessDocumentsApi>().getPreview(document.id),
),
),
);

View File

@@ -20,11 +20,7 @@ class DocumentContentWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(
vertical: 16,
horizontal: 16,
),
return SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [

View File

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

View File

@@ -31,50 +31,43 @@ class _DocumentMetaDataWidgetState extends State<DocumentMetaDataWidget> {
if (state.metaData == null) {
return const Center(child: CircularProgressIndicator());
}
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 16,
horizontal: 16,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ArchiveSerialNumberField(
document: widget.document,
).paddedOnly(bottom: widget.itemSpacing),
DetailsItem.text(
DateFormat().format(widget.document.modified),
context: context,
label: S.of(context)!.modifiedAt,
).paddedOnly(bottom: widget.itemSpacing),
DetailsItem.text(
DateFormat().format(widget.document.added),
context: context,
label: S.of(context)!.addedAt,
).paddedOnly(bottom: widget.itemSpacing),
DetailsItem.text(
state.metaData!.mediaFilename,
context: context,
label: S.of(context)!.mediaFilename,
).paddedOnly(bottom: widget.itemSpacing),
DetailsItem.text(
state.metaData!.originalChecksum,
context: context,
label: S.of(context)!.originalMD5Checksum,
).paddedOnly(bottom: widget.itemSpacing),
DetailsItem.text(
formatBytes(state.metaData!.originalSize, 2),
context: context,
label: S.of(context)!.originalFileSize,
).paddedOnly(bottom: widget.itemSpacing),
DetailsItem.text(
state.metaData!.originalMimeType,
context: context,
label: S.of(context)!.originalMIMEType,
).paddedOnly(bottom: widget.itemSpacing),
],
),
return SliverList(
delegate: SliverChildListDelegate(
[
ArchiveSerialNumberField(
document: widget.document,
).paddedOnly(bottom: widget.itemSpacing),
DetailsItem.text(
DateFormat().format(widget.document.modified),
context: context,
label: S.of(context)!.modifiedAt,
).paddedOnly(bottom: widget.itemSpacing),
DetailsItem.text(
DateFormat().format(widget.document.added),
context: context,
label: S.of(context)!.addedAt,
).paddedOnly(bottom: widget.itemSpacing),
DetailsItem.text(
state.metaData!.mediaFilename,
context: context,
label: S.of(context)!.mediaFilename,
).paddedOnly(bottom: widget.itemSpacing),
DetailsItem.text(
state.metaData!.originalChecksum,
context: context,
label: S.of(context)!.originalMD5Checksum,
).paddedOnly(bottom: widget.itemSpacing),
DetailsItem.text(
formatBytes(state.metaData!.originalSize, 2),
context: context,
label: S.of(context)!.originalFileSize,
).paddedOnly(bottom: widget.itemSpacing),
DetailsItem.text(
state.metaData!.originalMimeType,
context: context,
label: S.of(context)!.originalMIMEType,
).paddedOnly(bottom: widget.itemSpacing),
],
),
);
},

View File

@@ -11,6 +11,10 @@ import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class DocumentOverviewWidget extends StatelessWidget {
final DocumentModel document;
final Map<int, Correspondent> availableCorrespondents;
final Map<int, DocumentType> availableDocumentTypes;
final Map<int, Tag> availableTags;
final Map<int, StoragePath> availableStoragePaths;
final String? queryString;
final double itemSpacing;
const DocumentOverviewWidget({
@@ -18,72 +22,74 @@ class DocumentOverviewWidget extends StatelessWidget {
required this.document,
this.queryString,
required this.itemSpacing,
required this.availableCorrespondents,
required this.availableDocumentTypes,
required this.availableTags,
required this.availableStoragePaths,
});
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.symmetric(
vertical: 16,
horizontal: 16,
),
children: [
DetailsItem(
label: S.of(context)!.title,
content: HighlightedText(
text: document.title,
highlights: queryString?.split(" ") ?? [],
style: Theme.of(context).textTheme.bodyLarge,
),
).paddedOnly(bottom: itemSpacing),
DetailsItem.text(
DateFormat.yMMMMd().format(document.created),
context: context,
label: S.of(context)!.createdAt,
).paddedOnly(bottom: itemSpacing),
Visibility(
visible: document.documentType != null,
child: DetailsItem(
label: S.of(context)!.documentType,
content: LabelText<DocumentType>(
return SliverList(
delegate: SliverChildListDelegate(
[
DetailsItem(
label: S.of(context)!.title,
content: HighlightedText(
text: document.title,
highlights: queryString?.split(" ") ?? [],
style: Theme.of(context).textTheme.bodyLarge,
id: document.documentType,
),
).paddedOnly(bottom: itemSpacing),
),
Visibility(
visible: document.correspondent != null,
child: DetailsItem(
label: S.of(context)!.correspondent,
content: LabelText<Correspondent>(
style: Theme.of(context).textTheme.bodyLarge,
id: document.correspondent,
),
DetailsItem.text(
DateFormat.yMMMMd().format(document.created),
context: context,
label: S.of(context)!.createdAt,
).paddedOnly(bottom: itemSpacing),
),
Visibility(
visible: document.storagePath != null,
child: DetailsItem(
label: S.of(context)!.storagePath,
content: StoragePathWidget(
pathId: document.storagePath,
),
).paddedOnly(bottom: itemSpacing),
),
Visibility(
visible: document.tags.isNotEmpty,
child: DetailsItem(
label: S.of(context)!.tags,
content: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: TagsWidget(
isClickable: false,
tagIds: document.tags,
Visibility(
visible: document.documentType != null,
child: DetailsItem(
label: S.of(context)!.documentType,
content: LabelText<DocumentType>(
style: Theme.of(context).textTheme.bodyLarge,
label: availableDocumentTypes[document.documentType],
),
),
).paddedOnly(bottom: itemSpacing),
),
],
).paddedOnly(bottom: itemSpacing),
),
Visibility(
visible: document.correspondent != null,
child: DetailsItem(
label: S.of(context)!.correspondent,
content: LabelText<Correspondent>(
style: Theme.of(context).textTheme.bodyLarge,
label: availableCorrespondents[document.correspondent],
),
).paddedOnly(bottom: itemSpacing),
),
Visibility(
visible: document.storagePath != null,
child: DetailsItem(
label: S.of(context)!.storagePath,
content: LabelText<StoragePath>(
label: availableStoragePaths[document.storagePath],
),
).paddedOnly(bottom: itemSpacing),
),
Visibility(
visible: document.tags.isNotEmpty,
child: DetailsItem(
label: S.of(context)!.tags,
content: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: TagsWidget(
isClickable: false,
tags: document.tags.map((e) => availableTags[e]!).toList(),
),
),
).paddedOnly(bottom: itemSpacing),
),
],
),
);
}
}

View File

@@ -1,66 +1,36 @@
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart';
import 'package:freezed_annotation/freezed_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';
part 'document_edit_state.dart';
part 'document_edit_cubit.freezed.dart';
class DocumentEditCubit extends Cubit<DocumentEditState> {
final DocumentModel _initialDocument;
final PaperlessDocumentsApi _docsApi;
final LabelRepository _labelRepository;
final DocumentChangedNotifier _notifier;
final LabelRepository<Correspondent> _correspondentRepository;
final LabelRepository<DocumentType> _documentTypeRepository;
final LabelRepository<StoragePath> _storagePathRepository;
final LabelRepository<Tag> _tagRepository;
final List<StreamSubscription> _subscriptions = [];
DocumentEditCubit(
DocumentModel document, {
required PaperlessDocumentsApi documentsApi,
required LabelRepository<Correspondent> correspondentRepository,
required LabelRepository<DocumentType> documentTypeRepository,
required LabelRepository<StoragePath> storagePathRepository,
required LabelRepository<Tag> tagRepository,
required DocumentChangedNotifier notifier,
this._labelRepository,
this._docsApi,
this._notifier, {
required DocumentModel document,
}) : _initialDocument = document,
_docsApi = documentsApi,
_correspondentRepository = correspondentRepository,
_documentTypeRepository = documentTypeRepository,
_storagePathRepository = storagePathRepository,
_tagRepository = tagRepository,
_notifier = notifier,
super(
DocumentEditState(
document: document,
correspondents: correspondentRepository.current?.values ?? {},
documentTypes: documentTypeRepository.current?.values ?? {},
storagePaths: storagePathRepository.current?.values ?? {},
tags: tagRepository.current?.values ?? {},
),
) {
_notifier.subscribe(this, onUpdated: replace);
_subscriptions.add(
_correspondentRepository.values
.listen((v) => emit(state.copyWith(correspondents: v?.values))),
);
_subscriptions.add(
_documentTypeRepository.values
.listen((v) => emit(state.copyWith(documentTypes: v?.values))),
);
_subscriptions.add(
_storagePathRepository.values
.listen((v) => emit(state.copyWith(storagePaths: v?.values))),
);
_subscriptions.add(
_tagRepository.values.listen(
(v) => emit(state.copyWith(tags: v?.values)),
),
super(DocumentEditState(document: document)) {
_notifier.addListener(this, onUpdated: replace);
_labelRepository.addListener(
this,
onChanged: (labels) => emit(state.copyWith(
correspondents: labels.correspondents,
documentTypes: labels.documentTypes,
storagePaths: labels.storagePaths,
tags: labels.tags,
)),
);
}
@@ -70,20 +40,20 @@ class DocumentEditCubit extends Cubit<DocumentEditState> {
// Reload changed labels (documentCount property changes with removal/add)
if (document.documentType != _initialDocument.documentType) {
_documentTypeRepository
.find((document.documentType ?? _initialDocument.documentType)!);
_labelRepository.findDocumentType(
(document.documentType ?? _initialDocument.documentType)!);
}
if (document.correspondent != _initialDocument.correspondent) {
_correspondentRepository
.find((document.correspondent ?? _initialDocument.correspondent)!);
_labelRepository.findCorrespondent(
(document.correspondent ?? _initialDocument.correspondent)!);
}
if (document.storagePath != _initialDocument.storagePath) {
_storagePathRepository
.find((document.storagePath ?? _initialDocument.storagePath)!);
_labelRepository.findStoragePath(
(document.storagePath ?? _initialDocument.storagePath)!);
}
if (!const DeepCollectionEquality.unordered()
.equals(document.tags, _initialDocument.tags)) {
_tagRepository.findAll(document.tags);
_labelRepository.findAllTags(document.tags);
}
}
@@ -93,10 +63,8 @@ class DocumentEditCubit extends Cubit<DocumentEditState> {
@override
Future<void> close() {
for (final sub in _subscriptions) {
sub.cancel();
}
_notifier.unsubscribe(this);
_notifier.removeListener(this);
_labelRepository.removeListener(this);
return super.close();
}
}

View File

@@ -0,0 +1,260 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'document_edit_cubit.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods');
/// @nodoc
mixin _$DocumentEditState {
DocumentModel get document => throw _privateConstructorUsedError;
Map<int, Correspondent> get correspondents =>
throw _privateConstructorUsedError;
Map<int, DocumentType> get documentTypes =>
throw _privateConstructorUsedError;
Map<int, StoragePath> get storagePaths => throw _privateConstructorUsedError;
Map<int, Tag> get tags => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$DocumentEditStateCopyWith<DocumentEditState> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $DocumentEditStateCopyWith<$Res> {
factory $DocumentEditStateCopyWith(
DocumentEditState value, $Res Function(DocumentEditState) then) =
_$DocumentEditStateCopyWithImpl<$Res, DocumentEditState>;
@useResult
$Res call(
{DocumentModel document,
Map<int, Correspondent> correspondents,
Map<int, DocumentType> documentTypes,
Map<int, StoragePath> storagePaths,
Map<int, Tag> tags});
}
/// @nodoc
class _$DocumentEditStateCopyWithImpl<$Res, $Val extends DocumentEditState>
implements $DocumentEditStateCopyWith<$Res> {
_$DocumentEditStateCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
@pragma('vm:prefer-inline')
@override
$Res call({
Object? document = null,
Object? correspondents = null,
Object? documentTypes = null,
Object? storagePaths = null,
Object? tags = null,
}) {
return _then(_value.copyWith(
document: null == document
? _value.document
: document // ignore: cast_nullable_to_non_nullable
as DocumentModel,
correspondents: null == correspondents
? _value.correspondents
: correspondents // ignore: cast_nullable_to_non_nullable
as Map<int, Correspondent>,
documentTypes: null == documentTypes
? _value.documentTypes
: documentTypes // ignore: cast_nullable_to_non_nullable
as Map<int, DocumentType>,
storagePaths: null == storagePaths
? _value.storagePaths
: storagePaths // ignore: cast_nullable_to_non_nullable
as Map<int, StoragePath>,
tags: null == tags
? _value.tags
: tags // ignore: cast_nullable_to_non_nullable
as Map<int, Tag>,
) as $Val);
}
}
/// @nodoc
abstract class _$$_DocumentEditStateCopyWith<$Res>
implements $DocumentEditStateCopyWith<$Res> {
factory _$$_DocumentEditStateCopyWith(_$_DocumentEditState value,
$Res Function(_$_DocumentEditState) then) =
__$$_DocumentEditStateCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{DocumentModel document,
Map<int, Correspondent> correspondents,
Map<int, DocumentType> documentTypes,
Map<int, StoragePath> storagePaths,
Map<int, Tag> tags});
}
/// @nodoc
class __$$_DocumentEditStateCopyWithImpl<$Res>
extends _$DocumentEditStateCopyWithImpl<$Res, _$_DocumentEditState>
implements _$$_DocumentEditStateCopyWith<$Res> {
__$$_DocumentEditStateCopyWithImpl(
_$_DocumentEditState _value, $Res Function(_$_DocumentEditState) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? document = null,
Object? correspondents = null,
Object? documentTypes = null,
Object? storagePaths = null,
Object? tags = null,
}) {
return _then(_$_DocumentEditState(
document: null == document
? _value.document
: document // ignore: cast_nullable_to_non_nullable
as DocumentModel,
correspondents: null == correspondents
? _value._correspondents
: correspondents // ignore: cast_nullable_to_non_nullable
as Map<int, Correspondent>,
documentTypes: null == documentTypes
? _value._documentTypes
: documentTypes // ignore: cast_nullable_to_non_nullable
as Map<int, DocumentType>,
storagePaths: null == storagePaths
? _value._storagePaths
: storagePaths // ignore: cast_nullable_to_non_nullable
as Map<int, StoragePath>,
tags: null == tags
? _value._tags
: tags // ignore: cast_nullable_to_non_nullable
as Map<int, Tag>,
));
}
}
/// @nodoc
class _$_DocumentEditState implements _DocumentEditState {
const _$_DocumentEditState(
{required this.document,
final Map<int, Correspondent> correspondents = const {},
final Map<int, DocumentType> documentTypes = const {},
final Map<int, StoragePath> storagePaths = const {},
final Map<int, Tag> tags = const {}})
: _correspondents = correspondents,
_documentTypes = documentTypes,
_storagePaths = storagePaths,
_tags = tags;
@override
final DocumentModel document;
final Map<int, Correspondent> _correspondents;
@override
@JsonKey()
Map<int, Correspondent> get correspondents {
if (_correspondents is EqualUnmodifiableMapView) return _correspondents;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_correspondents);
}
final Map<int, DocumentType> _documentTypes;
@override
@JsonKey()
Map<int, DocumentType> get documentTypes {
if (_documentTypes is EqualUnmodifiableMapView) return _documentTypes;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_documentTypes);
}
final Map<int, StoragePath> _storagePaths;
@override
@JsonKey()
Map<int, StoragePath> get storagePaths {
if (_storagePaths is EqualUnmodifiableMapView) return _storagePaths;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_storagePaths);
}
final Map<int, Tag> _tags;
@override
@JsonKey()
Map<int, Tag> get tags {
if (_tags is EqualUnmodifiableMapView) return _tags;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_tags);
}
@override
String toString() {
return 'DocumentEditState(document: $document, correspondents: $correspondents, documentTypes: $documentTypes, storagePaths: $storagePaths, tags: $tags)';
}
@override
bool operator ==(dynamic other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$_DocumentEditState &&
(identical(other.document, document) ||
other.document == document) &&
const DeepCollectionEquality()
.equals(other._correspondents, _correspondents) &&
const DeepCollectionEquality()
.equals(other._documentTypes, _documentTypes) &&
const DeepCollectionEquality()
.equals(other._storagePaths, _storagePaths) &&
const DeepCollectionEquality().equals(other._tags, _tags));
}
@override
int get hashCode => Object.hash(
runtimeType,
document,
const DeepCollectionEquality().hash(_correspondents),
const DeepCollectionEquality().hash(_documentTypes),
const DeepCollectionEquality().hash(_storagePaths),
const DeepCollectionEquality().hash(_tags));
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$_DocumentEditStateCopyWith<_$_DocumentEditState> get copyWith =>
__$$_DocumentEditStateCopyWithImpl<_$_DocumentEditState>(
this, _$identity);
}
abstract class _DocumentEditState implements DocumentEditState {
const factory _DocumentEditState(
{required final DocumentModel document,
final Map<int, Correspondent> correspondents,
final Map<int, DocumentType> documentTypes,
final Map<int, StoragePath> storagePaths,
final Map<int, Tag> tags}) = _$_DocumentEditState;
@override
DocumentModel get document;
@override
Map<int, Correspondent> get correspondents;
@override
Map<int, DocumentType> get documentTypes;
@override
Map<int, StoragePath> get storagePaths;
@override
Map<int, Tag> get tags;
@override
@JsonKey(ignore: true)
_$$_DocumentEditStateCopyWith<_$_DocumentEditState> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@@ -1,43 +1,12 @@
part of 'document_edit_cubit.dart';
class DocumentEditState extends Equatable {
final DocumentModel document;
final Map<int, Correspondent> correspondents;
final Map<int, DocumentType> documentTypes;
final Map<int, StoragePath> storagePaths;
final Map<int, Tag> tags;
const DocumentEditState({
required this.correspondents,
required this.documentTypes,
required this.storagePaths,
required this.tags,
required this.document,
});
@override
List<Object> get props => [
correspondents,
documentTypes,
storagePaths,
tags,
document,
];
DocumentEditState copyWith({
Map<int, Correspondent>? correspondents,
Map<int, DocumentType>? documentTypes,
Map<int, StoragePath>? storagePaths,
Map<int, Tag>? tags,
DocumentModel? document,
}) {
return DocumentEditState(
document: document ?? this.document,
correspondents: correspondents ?? this.correspondents,
documentTypes: documentTypes ?? this.documentTypes,
storagePaths: storagePaths ?? this.storagePaths,
tags: tags ?? this.tags,
);
}
@freezed
class DocumentEditState with _$DocumentEditState {
const factory DocumentEditState({
required DocumentModel document,
@Default({}) Map<int, Correspondent> correspondents,
@Default({}) Map<int, DocumentType> documentTypes,
@Default({}) Map<int, StoragePath> storagePaths,
@Default({}) Map<int, Tag> tags,
}) = _DocumentEditState;
}

View File

@@ -1,10 +1,11 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_typeahead/flutter_typeahead.dart';
import 'package:form_builder_validators/form_builder_validators.dart';
import 'package:intl/intl.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart';
@@ -15,13 +16,14 @@ import 'package:paperless_mobile/features/edit_label/view/impl/add_correspondent
import 'package:paperless_mobile/features/edit_label/view/impl/add_document_type_page.dart';
import 'package:paperless_mobile/features/edit_label/view/impl/add_storage_path_page.dart';
import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_form_field.dart';
import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_form_field.dart';
import 'package:paperless_mobile/features/labels/view/widgets/label_form_field.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart';
class DocumentEditPage extends StatefulWidget {
final FieldSuggestions suggestions;
final FieldSuggestions? suggestions;
const DocumentEditPage({
Key? key,
required this.suggestions,
@@ -43,13 +45,13 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
final GlobalKey<FormBuilderState> _formKey = GlobalKey();
bool _isSubmitLoading = false;
late final FieldSuggestions _filteredSuggestions;
late final FieldSuggestions? _filteredSuggestions;
@override
void initState() {
super.initState();
_filteredSuggestions = widget.suggestions
.documentDifference(context.read<DocumentEditCubit>().state.document);
_filteredSuggestions =
widget.suggestions?.documentDifference(context.read<DocumentEditCubit>().state.document);
}
@override
@@ -93,69 +95,137 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
ListView(
children: [
_buildTitleFormField(state.document.title).padded(),
_buildCreatedAtFormField(state.document.created)
.padded(),
_buildCorrespondentFormField(
state.document.correspondent,
state.correspondents,
).padded(),
_buildDocumentTypeFormField(
state.document.documentType,
state.documentTypes,
).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: _filteredSuggestions.tags
.toSet()
.difference(state.document.tags.toSet())
.isNotEmpty
? _buildSuggestionsSkeleton<int>(
suggestions: _filteredSuggestions.tags,
itemBuilder: (context, itemData) {
final tag = state.tags[itemData]!;
return ActionChip(
label: Text(
tag.name,
style:
TextStyle(color: tag.textColor),
),
backgroundColor: tag.color,
onPressed: () {
final currentTags = _formKey
.currentState
?.fields[fkTags]
?.value 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})));
}
},
_buildCreatedAtFormField(state.document.created).padded(),
// Correspondent form field
Column(
children: [
LabelFormField<Correspondent>(
showAnyAssignedOption: false,
showNotAssignedOption: false,
addLabelPageBuilder: (initialValue) => RepositoryProvider.value(
value: context.read<LabelRepository>(),
child: AddCorrespondentPage(
initialName: initialValue,
),
),
addLabelText: S.of(context)!.addCorrespondent,
labelText: S.of(context)!.correspondent,
options: context.watch<DocumentEditCubit>().state.correspondents,
initialValue: state.document.correspondent != null
? IdQueryParameter.fromId(state.document.correspondent!)
: const IdQueryParameter.unset(),
name: fkCorrespondent,
prefixIcon: const Icon(Icons.person_outlined),
),
if (_filteredSuggestions?.hasSuggestedCorrespondents ?? false)
_buildSuggestionsSkeleton<int>(
suggestions: _filteredSuggestions!.correspondents,
itemBuilder: (context, itemData) => ActionChip(
label: Text(state.correspondents[itemData]!.name),
onPressed: () {
_formKey.currentState?.fields[fkCorrespondent]?.didChange(
IdQueryParameter.fromId(itemData),
);
},
)
: null,
),
),
],
).padded(),
// DocumentType form field
Column(
children: [
LabelFormField<DocumentType>(
showAnyAssignedOption: false,
showNotAssignedOption: false,
addLabelPageBuilder: (currentInput) => RepositoryProvider.value(
value: context.read<LabelRepository>(),
child: AddDocumentTypePage(
initialName: currentInput,
),
),
addLabelText: S.of(context)!.addDocumentType,
labelText: S.of(context)!.documentType,
initialValue: state.document.documentType != null
? IdQueryParameter.fromId(state.document.documentType!)
: const IdQueryParameter.unset(),
options: state.documentTypes,
name: _DocumentEditPageState.fkDocumentType,
prefixIcon: const Icon(Icons.description_outlined),
),
if (_filteredSuggestions?.hasSuggestedDocumentTypes ?? false)
_buildSuggestionsSkeleton<int>(
suggestions: _filteredSuggestions!.documentTypes,
itemBuilder: (context, itemData) => ActionChip(
label: Text(state.documentTypes[itemData]!.name),
onPressed: () =>
_formKey.currentState?.fields[fkDocumentType]?.didChange(
IdQueryParameter.fromId(itemData),
),
),
),
],
).padded(),
// StoragePath form field
Column(
children: [
LabelFormField<StoragePath>(
showAnyAssignedOption: false,
showNotAssignedOption: false,
addLabelPageBuilder: (initialValue) => RepositoryProvider.value(
value: context.read<LabelRepository>(),
child: AddStoragePathPage(initalName: initialValue),
),
addLabelText: S.of(context)!.addStoragePath,
labelText: S.of(context)!.storagePath,
options: state.storagePaths,
initialValue: state.document.storagePath != null
? IdQueryParameter.fromId(state.document.storagePath!)
: const IdQueryParameter.unset(),
name: fkStoragePath,
prefixIcon: const Icon(Icons.folder_outlined),
),
],
).padded(),
// Tag form field
TagsFormField(
options: state.tags,
name: fkTags,
allowOnlySelection: true,
allowCreation: true,
allowExclude: false,
initialValue: TagsQuery.ids(
include: state.document.tags.toList(),
),
).padded(),
if (_filteredSuggestions?.tags
.toSet()
.difference(state.document.tags.toSet())
.isNotEmpty ??
false)
_buildSuggestionsSkeleton<int>(
suggestions: (_filteredSuggestions?.tags.toSet() ?? {}),
itemBuilder: (context, itemData) {
final tag = state.tags[itemData]!;
return ActionChip(
label: Text(
tag.name,
style: TextStyle(color: tag.textColor),
),
backgroundColor: tag.color,
onPressed: () {
final currentTags =
_formKey.currentState?.fields[fkTags]?.value as TagsQuery;
_formKey.currentState?.fields[fkTags]?.didChange(
currentTags.maybeWhen(
ids: (include, exclude) => TagsQuery.ids(
include: [...include, itemData], exclude: exclude),
orElse: () => TagsQuery.ids(include: [itemData]),
),
);
},
);
},
),
// Prevent tags from being hidden by fab
const SizedBox(height: 64),
],
@@ -185,104 +255,18 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
);
}
Widget _buildStoragePathFormField(
int? initialId,
Map<int, StoragePath> options,
) {
return Column(
children: [
LabelFormField<StoragePath>(
notAssignedSelectable: false,
formBuilderState: _formKey.currentState,
labelCreationWidgetBuilder: (initialValue) => RepositoryProvider(
create: (context) => context.read<LabelRepository<StoragePath>>(),
child: AddStoragePathPage(initalValue: initialValue),
),
textFieldLabel: S.of(context)!.storagePath,
labelOptions: options,
initialValue: IdQueryParameter.fromId(initialId),
name: fkStoragePath,
prefixIcon: const Icon(Icons.folder_outlined),
),
],
);
}
Widget _buildCorrespondentFormField(
int? initialId, Map<int, Correspondent> options) {
return Column(
children: [
LabelFormField<Correspondent>(
notAssignedSelectable: false,
formBuilderState: _formKey.currentState,
labelCreationWidgetBuilder: (initialValue) => RepositoryProvider(
create: (context) => context.read<LabelRepository<Correspondent>>(),
child: AddCorrespondentPage(initialName: initialValue),
),
textFieldLabel: S.of(context)!.correspondent,
labelOptions: options,
initialValue: IdQueryParameter.fromId(initialId),
name: fkCorrespondent,
prefixIcon: const Icon(Icons.person_outlined),
),
if (_filteredSuggestions.hasSuggestedCorrespondents)
_buildSuggestionsSkeleton<int>(
suggestions: _filteredSuggestions.correspondents,
itemBuilder: (context, itemData) => ActionChip(
label: Text(options[itemData]!.name),
onPressed: () => _formKey.currentState?.fields[fkCorrespondent]
?.didChange((IdQueryParameter.fromId(itemData))),
),
),
],
);
}
Widget _buildDocumentTypeFormField(
int? initialId,
Map<int, DocumentType> options,
) {
return Column(
children: [
LabelFormField<DocumentType>(
notAssignedSelectable: false,
formBuilderState: _formKey.currentState,
labelCreationWidgetBuilder: (currentInput) => RepositoryProvider(
create: (context) => context.read<LabelRepository<DocumentType>>(),
child: AddDocumentTypePage(
initialName: currentInput,
),
),
textFieldLabel: S.of(context)!.documentType,
initialValue: IdQueryParameter.fromId(initialId),
labelOptions: options,
name: fkDocumentType,
prefixIcon: const Icon(Icons.description_outlined),
),
if (_filteredSuggestions.hasSuggestedDocumentTypes)
_buildSuggestionsSkeleton<int>(
suggestions: _filteredSuggestions.documentTypes,
itemBuilder: (context, itemData) => ActionChip(
label: Text(options[itemData]!.name),
onPressed: () => _formKey.currentState?.fields[fkDocumentType]
?.didChange(IdQueryParameter.fromId(itemData)),
),
),
],
);
}
Future<void> _onSubmit(DocumentModel document) async {
if (_formKey.currentState?.saveAndValidate() ?? false) {
final values = _formKey.currentState!.value;
var mergedDocument = document.copyWith(
title: values[fkTitle],
created: values[fkCreatedDate],
documentType: () => (values[fkDocumentType] as IdQueryParameter).id,
correspondent: () => (values[fkCorrespondent] as IdQueryParameter).id,
storagePath: () => (values[fkStoragePath] as IdQueryParameter).id,
tags: (values[fkTags] as IdsTagsQuery).includedIds,
content: values[fkContent]);
title: values[fkTitle],
created: values[fkCreatedDate],
documentType: () => (values[fkDocumentType] as SetIdQueryParameter).id,
correspondent: () => (values[fkCorrespondent] as SetIdQueryParameter).id,
storagePath: () => (values[fkStoragePath] as SetIdQueryParameter).id,
tags: (values[fkTags] as IdsTagsQuery).include,
content: values[fkContent],
);
setState(() {
_isSubmitLoading = true;
});
@@ -303,7 +287,12 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
Widget _buildTitleFormField(String? initialTitle) {
return FormBuilderTextField(
name: fkTitle,
validator: FormBuilderValidators.required(),
validator: (value) {
if (value?.trim().isEmpty ?? true) {
return S.of(context)!.thisFieldIsRequired;
}
return null;
},
decoration: InputDecoration(
label: Text(S.of(context)!.title),
),
@@ -326,13 +315,12 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
format: DateFormat.yMMMMd(),
initialEntryMode: DatePickerEntryMode.calendar,
),
if (_filteredSuggestions.hasSuggestedDates)
if (_filteredSuggestions?.hasSuggestedDates ?? false)
_buildSuggestionsSkeleton<DateTime>(
suggestions: _filteredSuggestions.dates,
suggestions: _filteredSuggestions!.dates,
itemBuilder: (context, itemData) => ActionChip(
label: Text(DateFormat.yMMMd().format(itemData)),
onPressed: () => _formKey.currentState?.fields[fkCreatedDate]
?.didChange(itemData),
onPressed: () => _formKey.currentState?.fields[fkCreatedDate]?.didChange(itemData),
),
),
],
@@ -361,11 +349,63 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
itemBuilder: (context, index) => ColoredChipWrapper(
child: itemBuilder(context, suggestions.elementAt(index)),
),
separatorBuilder: (BuildContext context, int index) =>
const SizedBox(width: 4.0),
separatorBuilder: (BuildContext context, int index) => const SizedBox(width: 4.0),
),
),
],
).padded();
}
}
// class SampleWidget extends StatefulWidget {
// const SampleWidget({super.key});
// @override
// State<SampleWidget> createState() => _SampleWidgetState();
// }
// class _SampleWidgetState extends State<SampleWidget> {
// @override
// Widget build(BuildContext context) {
// return BlocBuilder<OptionsBloc, OptionsState>(
// builder: (context, state) {
// return OptionsFormField(
// options: state.options,
// onAddOption: (option) {
// // This will call the repository and will cause a new state containing the new option to be emitted.
// context.read<OptionsBloc>().addOption(option);
// },
// );
// },
// );
// }
// }
// class OptionsFormField extends StatefulWidget {
// final List<Option> options;
// final void Function(Option option) onAddOption;
// const OptionsFormField({
// super.key,
// required this.options,
// required this.onAddOption,
// });
// @override
// State<OptionsFormField> createState() => _OptionsFormFieldState();
// }
// class _OptionsFormFieldState extends State<OptionsFormField> {
// final TextEditingController _controller;
// @override
// Widget build(BuildContext context) {
// return TextFormField(
// onTap: () async {
// // User creates new option...
// final Option option = await showOptionCreationForm();
// widget.onAddOption(option);
// },
// );
// }
// }

View File

@@ -12,7 +12,6 @@ import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart';
import 'package:paperless_mobile/core/delegate/customizable_sliver_persistent_header_delegate.dart';
import 'package:paperless_mobile/core/global/constants.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/repository/provider/label_repositories_provider.dart';
import 'package:paperless_mobile/core/service/file_description.dart';
import 'package:paperless_mobile/core/service/file_service.dart';
import 'package:paperless_mobile/features/app_drawer/view/app_drawer.dart';
@@ -198,20 +197,14 @@ class _ScannerPageState extends State<ScannerPage>
);
final uploadResult = await Navigator.of(context).push<DocumentUploadResult>(
MaterialPageRoute(
builder: (_) => LabelRepositoriesProvider(
child: BlocProvider(
create: (context) => DocumentUploadCubit(
documentApi: context.read<PaperlessDocumentsApi>(),
correspondentRepository:
context.read<LabelRepository<Correspondent>>(),
documentTypeRepository:
context.read<LabelRepository<DocumentType>>(),
tagRepository: context.read<LabelRepository<Tag>>(),
),
child: DocumentUploadPreparationPage(
fileBytes: file.bytes,
fileExtension: file.extension,
),
builder: (_) => BlocProvider(
create: (context) => DocumentUploadCubit(
context.read(),
context.read(),
),
child: DocumentUploadPreparationPage(
fileBytes: file.bytes,
fileExtension: file.extension,
),
),
),
@@ -316,22 +309,16 @@ class _ScannerPageState extends State<ScannerPage>
}
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => LabelRepositoriesProvider(
child: BlocProvider(
create: (context) => DocumentUploadCubit(
documentApi: context.read<PaperlessDocumentsApi>(),
correspondentRepository:
context.read<LabelRepository<Correspondent>>(),
documentTypeRepository:
context.read<LabelRepository<DocumentType>>(),
tagRepository: context.read<LabelRepository<Tag>>(),
),
child: DocumentUploadPreparationPage(
fileBytes: file.readAsBytesSync(),
filename: fileDescription.filename,
title: fileDescription.filename,
fileExtension: fileDescription.extension,
),
builder: (_) => BlocProvider(
create: (context) => DocumentUploadCubit(
context.read(),
context.read(),
),
child: DocumentUploadPreparationPage(
fileBytes: file.readAsBytesSync(),
filename: fileDescription.filename,
title: fileDescription.filename,
fileExtension: fileDescription.extension,
),
),
),

View File

@@ -1,26 +1,46 @@
import 'package:collection/collection.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart';
import 'package:paperless_mobile/features/paged_document_view/cubit/document_paging_bloc_mixin.dart';
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/repository/label_repository.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';
part 'document_search_cubit.g.dart';
part 'document_search_state.dart';
part 'document_search_cubit.g.dart';
class DocumentSearchCubit extends HydratedCubit<DocumentSearchState>
with DocumentPagingBlocMixin {
class DocumentSearchCubit extends Cubit<DocumentSearchState> with DocumentPagingBlocMixin {
@override
final PaperlessDocumentsApi api;
final LabelRepository _labelRepository;
@override
final DocumentChangedNotifier notifier;
DocumentSearchCubit(this.api, this.notifier)
: super(const DocumentSearchState()) {
notifier.subscribe(
final LocalUserAppState _userAppState;
DocumentSearchCubit(
this.api,
this.notifier,
this._labelRepository,
this._userAppState,
) : super(DocumentSearchState(searchHistory: _userAppState.documentSearchHistory)) {
_labelRepository.addListener(
this,
onChanged: (labels) {
emit(
state.copyWith(
correspondents: labels.correspondents,
documentTypes: labels.documentTypes,
tags: labels.tags,
storagePaths: labels.storagePaths,
),
);
},
);
notifier.addListener(
this,
onDeleted: remove,
onUpdated: replace,
@@ -36,17 +56,19 @@ class DocumentSearchCubit extends HydratedCubit<DocumentSearchState>
final searchFilter = DocumentFilter(
query: TextQuery.extended(query),
);
await updateFilter(filter: searchFilter);
emit(
state.copyWith(
searchHistory: [
query,
...state.searchHistory
.whereNot((previousQuery) => previousQuery == query)
...state.searchHistory.whereNot((previousQuery) => previousQuery == query)
],
),
);
_userAppState
..documentSearchHistory = state.searchHistory
..save();
}
void updateViewType(ViewType viewType) {
@@ -56,11 +78,12 @@ class DocumentSearchCubit extends HydratedCubit<DocumentSearchState>
void removeHistoryEntry(String entry) {
emit(
state.copyWith(
searchHistory: state.searchHistory
.whereNot((element) => element == entry)
.toList(),
searchHistory: state.searchHistory.whereNot((element) => element == entry).toList(),
),
);
_userAppState
..documentSearchHistory = state.searchHistory
..save();
}
Future<void> suggest(String query) async {
@@ -80,26 +103,22 @@ class DocumentSearchCubit extends HydratedCubit<DocumentSearchState>
}
void reset() {
emit(state.copyWith(
view: SearchView.suggestions,
suggestions: [],
isLoading: false,
));
emit(
state.copyWith(
view: SearchView.suggestions,
suggestions: [],
isLoading: false,
),
);
}
@override
Future<void> close() {
notifier.unsubscribe(this);
notifier.removeListener(this);
_labelRepository.removeListener(this);
return super.close();
}
@override
DocumentSearchState? fromJson(Map<String, dynamic> json) {
return DocumentSearchState.fromJson(json);
}
@override
Map<String, dynamic>? toJson(DocumentSearchState state) {
return state.toJson();
}
Future<void> onFilterUpdated(DocumentFilter filter) async {}
}

View File

@@ -13,15 +13,25 @@ class DocumentSearchState extends DocumentPagingState {
final List<String> suggestions;
@JsonKey()
final ViewType viewType;
final Map<int, Correspondent> correspondents;
final Map<int, DocumentType> documentTypes;
final Map<int, Tag> tags;
final Map<int, StoragePath> storagePaths;
const DocumentSearchState({
this.view = SearchView.suggestions,
this.searchHistory = const [],
this.suggestions = const [],
this.viewType = ViewType.detailed,
super.filter,
super.filter = const DocumentFilter(),
super.hasLoaded,
super.isLoading,
super.value,
this.correspondents = const {},
this.documentTypes = const {},
this.tags = const {},
this.storagePaths = const {},
});
@override
@@ -31,6 +41,10 @@ class DocumentSearchState extends DocumentPagingState {
suggestions,
view,
viewType,
correspondents,
documentTypes,
tags,
storagePaths,
];
@override
@@ -57,6 +71,10 @@ class DocumentSearchState extends DocumentPagingState {
List<String>? suggestions,
SearchView? view,
ViewType? viewType,
Map<int, Correspondent>? correspondents,
Map<int, DocumentType>? documentTypes,
Map<int, Tag>? tags,
Map<int, StoragePath>? storagePaths,
}) {
return DocumentSearchState(
value: value ?? this.value,
@@ -67,6 +85,10 @@ class DocumentSearchState extends DocumentPagingState {
view: view ?? this.view,
suggestions: suggestions ?? this.suggestions,
viewType: viewType ?? this.viewType,
correspondents: correspondents ?? this.correspondents,
documentTypes: documentTypes ?? this.documentTypes,
tags: tags ?? this.tags,
storagePaths: storagePaths ?? this.storagePaths,
);
}

View File

@@ -3,24 +3,31 @@ import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hive/hive.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/database/tables/local_user_app_state.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/document_search/cubit/document_search_cubit.dart';
import 'package:paperless_mobile/features/document_search/view/remove_history_entry_dialog.dart';
import 'package:paperless_mobile/features/documents/view/widgets/adaptive_documents_view.dart';
import 'package:paperless_mobile/features/documents/view/widgets/selection/view_type_selection_widget.dart';
import 'package:paperless_mobile/features/settings/model/view_type.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/routes/document_details_route.dart';
import 'dart:math' as math;
Future<void> showDocumentSearchPage(BuildContext context) {
final currentUser =
Hive.box<GlobalSettings>(HiveBoxes.globalSettings).getValue()!.currentLoggedInUser;
return Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => BlocProvider(
create: (context) => DocumentSearchCubit(
context.read(),
context.read(),
context.read(),
Hive.box<LocalUserAppState>(HiveBoxes.localUserAppState).get(currentUser)!,
),
child: const DocumentSearchPage(),
),
@@ -69,13 +76,14 @@ class _DocumentSearchPageState extends State<DocumentSearchPage> {
controller: _queryController,
onChanged: (query) {
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 700), () {
_debounceTimer = Timer(const Duration(milliseconds: 500), () {
context.read<DocumentSearchCubit>().suggest(query);
});
},
textInputAction: TextInputAction.search,
onSubmitted: (query) {
FocusScope.of(context).unfocus();
_debounceTimer?.cancel();
context.read<DocumentSearchCubit>().search(query);
},
),
@@ -110,9 +118,8 @@ class _DocumentSearchPageState extends State<DocumentSearchPage> {
}
Widget _buildSuggestionsView(DocumentSearchState state) {
final suggestions = state.suggestions
.whereNot((element) => state.searchHistory.contains(element))
.toList();
final suggestions =
state.suggestions.whereNot((element) => state.searchHistory.contains(element)).toList();
final historyMatches = state.searchHistory
.where(
(element) => element.startsWith(query),
@@ -194,8 +201,7 @@ class _DocumentSearchPageState extends State<DocumentSearchPage> {
builder: (context, state) {
return ViewTypeSelectionWidget(
viewType: state.viewType,
onChanged: (type) =>
context.read<DocumentSearchCubit>().updateViewType(type),
onChanged: (type) => context.read<DocumentSearchCubit>().updateViewType(type),
);
},
)
@@ -229,6 +235,10 @@ class _DocumentSearchPageState extends State<DocumentSearchPage> {
),
);
},
correspondents: state.correspondents,
documentTypes: state.documentTypes,
tags: state.tags,
storagePaths: state.storagePaths,
)
],
);

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_cancel_button.dart';
import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_confirm_button.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class RemoveHistoryEntryDialog extends StatelessWidget {
@@ -13,12 +14,10 @@ class RemoveHistoryEntryDialog extends StatelessWidget {
content: Text(S.of(context)!.removeQueryFromSearchHistory),
actions: [
const DialogCancelButton(),
TextButton(
child: Text(S.of(context)!.remove),
onPressed: () {
Navigator.pop(context, true);
},
),
DialogConfirmButton(
style: DialogConfirmButtonStyle.danger,
label: S.of(context)!.remove,
)
],
);
}

View File

@@ -1,11 +1,15 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/bloc/paperless_server_information_cubit.dart';
import 'package:paperless_mobile/core/bloc/paperless_server_information_state.dart';
import 'package:hive_flutter/adapters.dart';
import 'package:paperless_mobile/core/bloc/server_information_cubit.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/delegate/customizable_sliver_persistent_header_delegate.dart';
import 'package:paperless_mobile/core/widgets/material/search/m3_search_bar.dart';
import 'package:paperless_mobile/core/widgets/material/search/m3_search_bar.dart' as s;
import 'package:paperless_mobile/features/document_search/view/document_search_page.dart';
import 'package:paperless_mobile/features/settings/view/dialogs/account_settings_dialog.dart';
import 'package:paperless_mobile/features/settings/view/manage_accounts_page.dart';
import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart';
import 'package:paperless_mobile/features/settings/view/widgets/user_avatar.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class SliverSearchBar extends StatelessWidget {
@@ -23,12 +27,12 @@ class SliverSearchBar extends StatelessWidget {
floating: floating,
pinned: pinned,
delegate: CustomizableSliverPersistentHeaderDelegate(
minExtent: 56 + 8,
maxExtent: 56 + 8,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: SearchBar(
height: 56,
minExtent: kToolbarHeight,
maxExtent: kToolbarHeight,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8.0),
child: s.SearchBar(
height: kToolbarHeight,
supportingText: S.of(context)!.searchDocuments,
onTap: () => showDocumentSearchPage(context),
leadingIcon: IconButton(
@@ -36,18 +40,25 @@ class SliverSearchBar extends StatelessWidget {
onPressed: Scaffold.of(context).openDrawer,
),
trailingIcon: IconButton(
icon: BlocBuilder<PaperlessServerInformationCubit,
PaperlessServerInformationState>(
builder: (context, state) {
return CircleAvatar(
child: Text(state.information?.userInitials ?? ''),
icon: GlobalSettingsBuilder(
builder: (context, settings) {
return ValueListenableBuilder(
valueListenable:
Hive.box<LocalUserAccount>(HiveBoxes.localUserAccount).listenable(),
builder: (context, box, _) {
final account = box.get(settings.currentLoggedInUser!)!;
return UserAvatar(userId: settings.currentLoggedInUser!, account: account);
},
);
},
),
onPressed: () {
showDialog(
context: context,
builder: (context) => const AccountSettingsDialog(),
builder: (_) => BlocProvider.value(
value: context.read<ServerInformationCubit>(),
child: const ManageAccountsPage(),
),
);
},
),

View File

@@ -5,42 +5,26 @@ 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/repository/state/impl/correspondent_repository_state.dart';
import 'package:paperless_mobile/core/repository/state/impl/document_type_repository_state.dart';
import 'package:paperless_mobile/core/repository/state/impl/tag_repository_state.dart';
part 'document_upload_state.dart';
class DocumentUploadCubit extends Cubit<DocumentUploadState> {
final PaperlessDocumentsApi _documentApi;
final LabelRepository<Tag> _tagRepository;
final LabelRepository<Correspondent> _correspondentRepository;
final LabelRepository<DocumentType> _documentTypeRepository;
final LabelRepository _labelRepository;
final List<StreamSubscription> _subs = [];
DocumentUploadCubit({
required PaperlessDocumentsApi documentApi,
required LabelRepository<Tag> tagRepository,
required LabelRepository<Correspondent> correspondentRepository,
required LabelRepository<DocumentType> documentTypeRepository,
}) : _documentApi = documentApi,
_tagRepository = tagRepository,
_correspondentRepository = correspondentRepository,
_documentTypeRepository = documentTypeRepository,
super(const DocumentUploadState()) {
_subs.add(_tagRepository.values.listen(
(tags) => emit(state.copyWith(tags: tags?.values)),
));
_subs.add(_correspondentRepository.values.listen(
(correspondents) =>
emit(state.copyWith(correspondents: correspondents?.values)),
));
_subs.add(_documentTypeRepository.values.listen(
(documentTypes) =>
emit(state.copyWith(documentTypes: documentTypes?.values)),
));
DocumentUploadCubit(this._labelRepository, this._documentApi)
: super(const DocumentUploadState()) {
_labelRepository.addListener(
this,
onChanged: (labels) {
emit(state.copyWith(
correspondents: labels.correspondents,
documentTypes: labels.documentTypes,
tags: labels.tags,
));
},
);
}
Future<String?> upload(
@@ -65,9 +49,7 @@ class DocumentUploadCubit extends Cubit<DocumentUploadState> {
@override
Future<void> close() async {
for (final sub in _subs) {
await sub.cancel();
}
_labelRepository.removeListener(this);
return super.close();
}
}

View File

@@ -3,7 +3,7 @@ import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:form_builder_validators/form_builder_validators.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:intl/intl.dart';
import 'package:paperless_api/paperless_api.dart';
@@ -41,12 +41,10 @@ class DocumentUploadPreparationPage extends StatefulWidget {
}) : super(key: key);
@override
State<DocumentUploadPreparationPage> createState() =>
_DocumentUploadPreparationPageState();
State<DocumentUploadPreparationPage> createState() => _DocumentUploadPreparationPageState();
}
class _DocumentUploadPreparationPageState
extends State<DocumentUploadPreparationPage> {
class _DocumentUploadPreparationPageState extends State<DocumentUploadPreparationPage> {
static const fkFileName = "filename";
static final fileNameDateFormat = DateFormat("yyyy_MM_ddTHH_mm_ss");
@@ -73,8 +71,7 @@ class _DocumentUploadPreparationPageState
title: Text(S.of(context)!.prepareDocument),
bottom: _isUploadLoading
? const PreferredSize(
child: LinearProgressIndicator(),
preferredSize: Size.fromHeight(4.0))
child: LinearProgressIndicator(), preferredSize: Size.fromHeight(4.0))
: null,
),
floatingActionButton: Visibility(
@@ -95,30 +92,30 @@ class _DocumentUploadPreparationPageState
FormBuilderTextField(
autovalidateMode: AutovalidateMode.always,
name: DocumentModel.titleKey,
initialValue:
widget.title ?? "scan_${fileNameDateFormat.format(_now)}",
validator: FormBuilderValidators.required(),
initialValue: widget.title ?? "scan_${fileNameDateFormat.format(_now)}",
validator: (value) {
if (value?.trim().isEmpty ?? true) {
return S.of(context)!.thisFieldIsRequired;
}
return null;
},
decoration: InputDecoration(
labelText: S.of(context)!.title,
suffixIcon: IconButton(
icon: const Icon(Icons.close),
onPressed: () {
_formKey.currentState?.fields[DocumentModel.titleKey]
?.didChange("");
_formKey.currentState?.fields[DocumentModel.titleKey]?.didChange("");
if (_syncTitleAndFilename) {
_formKey.currentState?.fields[fkFileName]
?.didChange("");
_formKey.currentState?.fields[fkFileName]?.didChange("");
}
},
),
errorText: _errors[DocumentModel.titleKey],
),
onChanged: (value) {
final String transformedValue =
_formatFilename(value ?? '');
final String transformedValue = _formatFilename(value ?? '');
if (_syncTitleAndFilename) {
_formKey.currentState?.fields[fkFileName]
?.didChange(transformedValue);
_formKey.currentState?.fields[fkFileName]?.didChange(transformedValue);
}
},
),
@@ -133,12 +130,10 @@ class _DocumentUploadPreparationPageState
suffixText: widget.fileExtension,
suffixIcon: IconButton(
icon: const Icon(Icons.clear),
onPressed: () => _formKey.currentState?.fields[fkFileName]
?.didChange(''),
onPressed: () => _formKey.currentState?.fields[fkFileName]?.didChange(''),
),
),
initialValue: widget.filename ??
"scan_${fileNameDateFormat.format(_now)}",
initialValue: widget.filename ?? "scan_${fileNameDateFormat.format(_now)}",
),
// Synchronize title and filename
SwitchListTile(
@@ -148,13 +143,10 @@ class _DocumentUploadPreparationPageState
() => _syncTitleAndFilename = value,
);
if (_syncTitleAndFilename) {
final String transformedValue = _formatFilename(_formKey
.currentState
?.fields[DocumentModel.titleKey]
?.value as String);
final String transformedValue = _formatFilename(
_formKey.currentState?.fields[DocumentModel.titleKey]?.value as String);
if (_syncTitleAndFilename) {
_formKey.currentState?.fields[fkFileName]
?.didChange(transformedValue);
_formKey.currentState?.fields[fkFileName]?.didChange(transformedValue);
}
}
},
@@ -179,8 +171,7 @@ class _DocumentUploadPreparationPageState
? IconButton(
icon: const Icon(Icons.close),
onPressed: () {
_formKey.currentState!
.fields[DocumentModel.createdKey]
_formKey.currentState!.fields[DocumentModel.createdKey]
?.didChange(null);
},
)
@@ -189,47 +180,44 @@ class _DocumentUploadPreparationPageState
),
// Correspondent
LabelFormField<Correspondent>(
notAssignedSelectable: false,
formBuilderState: _formKey.currentState,
labelCreationWidgetBuilder: (initialName) =>
RepositoryProvider(
create: (context) =>
context.read<LabelRepository<Correspondent>>(),
showAnyAssignedOption: false,
showNotAssignedOption: false,
addLabelPageBuilder: (initialName) => RepositoryProvider.value(
value: context.read<LabelRepository>(),
child: AddCorrespondentPage(initialName: initialName),
),
textFieldLabel: S.of(context)!.correspondent + " *",
addLabelText: S.of(context)!.addCorrespondent,
labelText: S.of(context)!.correspondent + " *",
name: DocumentModel.correspondentKey,
labelOptions: state.correspondents,
options: state.correspondents,
prefixIcon: const Icon(Icons.person_outline),
),
// Document type
LabelFormField<DocumentType>(
notAssignedSelectable: false,
formBuilderState: _formKey.currentState,
labelCreationWidgetBuilder: (initialName) =>
RepositoryProvider(
create: (context) =>
context.read<LabelRepository<DocumentType>>(),
showAnyAssignedOption: false,
showNotAssignedOption: false,
addLabelPageBuilder: (initialName) => RepositoryProvider.value(
value: context.read<LabelRepository>(),
child: AddDocumentTypePage(initialName: initialName),
),
textFieldLabel: S.of(context)!.documentType + " *",
addLabelText: S.of(context)!.addDocumentType,
labelText: S.of(context)!.documentType + " *",
name: DocumentModel.documentTypeKey,
labelOptions: state.documentTypes,
options: state.documentTypes,
prefixIcon: const Icon(Icons.description_outlined),
),
TagFormField(
TagsFormField(
name: DocumentModel.tagsKey,
notAssignedSelectable: false,
anyAssignedSelectable: false,
excludeAllowed: false,
selectableOptions: state.tags,
//Label: "Tags" + " *",
allowCreation: true,
allowExclude: false,
allowOnlySelection: true,
options: state.tags,
),
Text(
"* " + S.of(context)!.uploadInferValuesHint,
style: Theme.of(context).textTheme.bodySmall,
),
SizedBox(height: 300),
const SizedBox(height: 300),
].padded(),
),
);
@@ -248,10 +236,9 @@ class _DocumentUploadPreparationPageState
final createdAt = fv[DocumentModel.createdKey] as DateTime?;
final title = fv[DocumentModel.titleKey] as String;
final docType = fv[DocumentModel.documentTypeKey] as IdQueryParameter;
final docType = fv[DocumentModel.documentTypeKey] as SetIdQueryParameter;
final tags = fv[DocumentModel.tagsKey] as IdsTagsQuery;
final correspondent =
fv[DocumentModel.correspondentKey] as IdQueryParameter;
final correspondent = fv[DocumentModel.correspondentKey] as SetIdQueryParameter;
final taskId = await cubit.upload(
widget.fileBytes,
@@ -262,7 +249,7 @@ class _DocumentUploadPreparationPageState
title: title,
documentType: docType.id,
correspondent: correspondent.id,
tags: tags.ids,
tags: tags.include,
createdAt: createdAt,
);
showSnackBar(
@@ -279,8 +266,7 @@ class _DocumentUploadPreparationPageState
setState(() => _errors = errors);
} catch (unknownError, stackTrace) {
debugPrint(unknownError.toString());
showErrorMessage(
context, const PaperlessServerException.unknown(), stackTrace);
showErrorMessage(context, const PaperlessServerException.unknown(), stackTrace);
} finally {
setState(() {
_isUploadLoading = false;

View File

@@ -1,31 +1,68 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/notifier/document_changed_notifier.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';
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/repository/label_repository.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';
part 'documents_state.dart';
part 'documents_cubit.g.dart';
part 'documents_state.dart';
class DocumentsCubit extends HydratedCubit<DocumentsState>
with DocumentPagingBlocMixin {
class DocumentsCubit extends Cubit<DocumentsState> with DocumentPagingBlocMixin {
@override
final PaperlessDocumentsApi api;
final LabelRepository _labelRepository;
@override
final DocumentChangedNotifier notifier;
DocumentsCubit(this.api, this.notifier) : super(const DocumentsState()) {
notifier.subscribe(
final LocalUserAppState _userState;
DocumentsCubit(
this.api,
this.notifier,
this._labelRepository,
this._userState,
) : super(DocumentsState(
filter: _userState.currentDocumentFilter,
viewType: _userState.documentsPageViewType,
)) {
notifier.addListener(
this,
onUpdated: replace,
onDeleted: remove,
onUpdated: (document) {
replace(document);
emit(
state.copyWith(
selection: state.selection.map((e) => e.id == document.id ? document : e).toList(),
),
);
},
onDeleted: (document) {
remove(document);
emit(
state.copyWith(
selection: state.selection.where((e) => e.id != document.id).toList(),
),
);
},
);
_labelRepository.addListener(
this,
onChanged: (labels) => emit(
state.copyWith(
correspondents: labels.correspondents,
documentTypes: labels.documentTypes,
storagePaths: labels.storagePaths,
tags: labels.tags,
),
),
);
}
@@ -40,28 +77,12 @@ class DocumentsCubit extends HydratedCubit<DocumentsState>
await reload();
}
Future<void> bulkEditTags(
Iterable<DocumentModel> documents, {
Iterable<int> addTags = const [],
Iterable<int> removeTags = const [],
}) async {
debugPrint("[DocumentsCubit] bulkEditTags");
await api.bulkAction(BulkModifyTagsAction(
documents.map((doc) => doc.id),
addTags: addTags,
removeTags: removeTags,
));
await reload();
}
void toggleDocumentSelection(DocumentModel model) {
debugPrint("[DocumentsCubit] toggleSelection");
if (state.selectedIds.contains(model.id)) {
emit(
state.copyWith(
selection: state.selection
.where((element) => element.id != model.id)
.toList(),
selection: state.selection.where((element) => element.id != model.id).toList(),
),
);
} else {
@@ -84,23 +105,22 @@ class DocumentsCubit extends HydratedCubit<DocumentsState>
return res;
}
@override
DocumentsState? fromJson(Map<String, dynamic> json) {
return DocumentsState.fromJson(json);
}
@override
Map<String, dynamic>? toJson(DocumentsState state) {
return state.toJson();
}
@override
Future<void> close() {
notifier.unsubscribe(this);
notifier.removeListener(this);
_labelRepository.removeListener(this);
return super.close();
}
void setViewType(ViewType viewType) {
emit(state.copyWith(viewType: viewType));
_userState.documentsPageViewType = viewType;
_userState.save();
}
@override
Future<void> onFilterUpdated(DocumentFilter filter) async {
_userState.currentDocumentFilter = filter;
await _userState.save();
}
}

View File

@@ -2,9 +2,18 @@ part of 'documents_cubit.dart';
@JsonSerializable()
class DocumentsState extends DocumentPagingState {
@JsonKey(includeFromJson: false, includeToJson: false)
@JsonKey(includeToJson: false, includeFromJson: false)
final List<DocumentModel> selection;
@JsonKey(includeToJson: false, includeFromJson: false)
final Map<int, Correspondent> correspondents;
@JsonKey(includeToJson: false, includeFromJson: false)
final Map<int, DocumentType> documentTypes;
@JsonKey(includeToJson: false, includeFromJson: false)
final Map<int, Tag> tags;
@JsonKey(includeToJson: false, includeFromJson: false)
final Map<int, StoragePath> storagePaths;
final ViewType viewType;
const DocumentsState({
@@ -14,6 +23,10 @@ class DocumentsState extends DocumentPagingState {
super.filter = const DocumentFilter(),
super.hasLoaded = false,
super.isLoading = false,
this.correspondents = const {},
this.documentTypes = const {},
this.tags = const {},
this.storagePaths = const {},
});
List<int> get selectedIds => selection.map((e) => e.id).toList();
@@ -25,6 +38,10 @@ class DocumentsState extends DocumentPagingState {
DocumentFilter? filter,
List<DocumentModel>? selection,
ViewType? viewType,
Map<int, Correspondent>? correspondents,
Map<int, DocumentType>? documentTypes,
Map<int, Tag>? tags,
Map<int, StoragePath>? storagePaths,
}) {
return DocumentsState(
hasLoaded: hasLoaded ?? this.hasLoaded,
@@ -33,18 +50,21 @@ class DocumentsState extends DocumentPagingState {
filter: filter ?? this.filter,
selection: selection ?? this.selection,
viewType: viewType ?? this.viewType,
correspondents: correspondents ?? this.correspondents,
documentTypes: documentTypes ?? this.documentTypes,
tags: tags ?? this.tags,
storagePaths: storagePaths ?? this.storagePaths,
);
}
factory DocumentsState.fromJson(Map<String, dynamic> json) =>
_$DocumentsStateFromJson(json);
Map<String, dynamic> toJson() => _$DocumentsStateToJson(this);
@override
List<Object?> get props => [
selection,
viewType,
correspondents,
documentTypes,
tags,
storagePaths,
...super.props,
];
@@ -62,4 +82,9 @@ class DocumentsState extends DocumentPagingState {
value: value,
);
}
factory DocumentsState.fromJson(Map<String, dynamic> json) =>
_$DocumentsStateFromJson(json);
Map<String, dynamic> toJson() => _$DocumentsStateToJson(this);
}

View File

@@ -1,23 +1,21 @@
import 'package:badges/badges.dart' as b;
import 'package:collection/collection.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/bloc/connectivity_cubit.dart';
import 'package:paperless_mobile/core/delegate/customizable_sliver_persistent_header_delegate.dart';
import 'package:paperless_mobile/core/widgets/material/search/colored_tab_bar.dart';
import 'package:paperless_mobile/core/widgets/material/colored_tab_bar.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_details/view/pages/document_details_page.dart';
import 'package:paperless_mobile/features/document_search/view/sliver_search_bar.dart';
import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart';
import 'package:paperless_mobile/features/documents/view/widgets/adaptive_documents_view.dart';
import 'package:paperless_mobile/features/documents/view/widgets/documents_empty_state.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/selection/document_selection_sliver_app_bar.dart';
import 'package:paperless_mobile/features/documents/view/widgets/selection/view_type_selection_widget.dart';
import 'package:paperless_mobile/features/documents/view/widgets/sort_documents_button.dart';
import 'package:paperless_mobile/features/labels/cubit/providers/labels_bloc_provider.dart';
import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart';
import 'package:paperless_mobile/features/saved_view/view/add_saved_view_page.dart';
import 'package:paperless_mobile/features/saved_view/view/saved_view_list.dart';
@@ -44,12 +42,9 @@ class DocumentsPage extends StatefulWidget {
State<DocumentsPage> createState() => _DocumentsPageState();
}
class _DocumentsPageState extends State<DocumentsPage>
with SingleTickerProviderStateMixin {
final SliverOverlapAbsorberHandle searchBarHandle =
SliverOverlapAbsorberHandle();
final SliverOverlapAbsorberHandle tabBarHandle =
SliverOverlapAbsorberHandle();
class _DocumentsPageState extends State<DocumentsPage> with SingleTickerProviderStateMixin {
final SliverOverlapAbsorberHandle searchBarHandle = SliverOverlapAbsorberHandle();
final SliverOverlapAbsorberHandle tabBarHandle = SliverOverlapAbsorberHandle();
late final TabController _tabController;
int _currentTab = 0;
@@ -86,8 +81,7 @@ class _DocumentsPageState extends State<DocumentsPage>
@override
Widget build(BuildContext context) {
return BlocListener<TaskStatusCubit, TaskStatusState>(
listenWhen: (previous, current) =>
!previous.isSuccess && current.isSuccess,
listenWhen: (previous, current) => !previous.isSuccess && current.isSuccess,
listener: (context, state) {
showSnackBar(
context,
@@ -104,8 +98,7 @@ class _DocumentsPageState extends State<DocumentsPage>
},
child: BlocConsumer<ConnectivityCubit, ConnectivityState>(
listenWhen: (previous, current) =>
previous != ConnectivityState.connected &&
current == ConnectivityState.connected,
previous != ConnectivityState.connected && current == ConnectivityState.connected,
listener: (context, state) {
try {
context.read<DocumentsCubit>().reload();
@@ -115,42 +108,45 @@ class _DocumentsPageState extends State<DocumentsPage>
},
builder: (context, connectivityState) {
return SafeArea(
top: context.read<DocumentsCubit>().state.selection.isEmpty,
child: Scaffold(
drawer: const AppDrawer(),
floatingActionButton: BlocBuilder<DocumentsCubit, DocumentsState>(
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,
final show = state.selection.isEmpty;
return AnimatedScale(
scale: show ? 1 : 0,
duration: const Duration(milliseconds: 200),
curve: Curves.easeIn,
child: 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: Colors.red,
child: _currentTab == 0
? FloatingActionButton(
child: const Icon(Icons.filter_alt_outlined),
onPressed: _openDocumentFilter,
)
: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () => _onCreateSavedView(state.filter),
),
),
animationType: b.BadgeAnimationType.fade,
badgeColor: Colors.red,
child: _currentTab == 0
? FloatingActionButton(
child: const Icon(Icons.filter_alt_outlined),
onPressed: _openDocumentFilter,
)
: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () => _onCreateSavedView(state.filter),
),
);
},
),
resizeToAvoidBottomInset: true,
body: WillPopScope(
onWillPop: () async {
if (context
.read<DocumentsCubit>()
.state
.selection
.isNotEmpty) {
if (context.read<DocumentsCubit>().state.selection.isNotEmpty) {
context.read<DocumentsCubit>().resetSelection();
}
return false;
@@ -167,7 +163,8 @@ class _DocumentsPageState extends State<DocumentsPage>
if (state.selection.isNotEmpty) {
// Show selection app bar when selection mode is active
return DocumentSelectionSliverAppBar(
state: state);
state: state,
);
}
return const SliverSearchBar(floating: true);
},
@@ -184,8 +181,7 @@ class _DocumentsPageState extends State<DocumentsPage>
}
return SliverPersistentHeader(
pinned: true,
delegate:
CustomizableSliverPersistentHeaderDelegate(
delegate: CustomizableSliverPersistentHeaderDelegate(
minExtent: kTextTabBarHeight,
maxExtent: kTextTabBarHeight,
child: ColoredTabBar(
@@ -209,22 +205,15 @@ class _DocumentsPageState extends State<DocumentsPage>
if (metrics.maxScrollExtent == 0) {
return true;
}
final desiredTab =
(metrics.pixels / metrics.maxScrollExtent)
.round();
if (metrics.axis == Axis.horizontal &&
_currentTab != desiredTab) {
final desiredTab = (metrics.pixels / metrics.maxScrollExtent).round();
if (metrics.axis == Axis.horizontal && _currentTab != desiredTab) {
setState(() => _currentTab = desiredTab);
}
return false;
},
child: TabBarView(
controller: _tabController,
physics: context
.watch<DocumentsCubit>()
.state
.selection
.isNotEmpty
physics: context.watch<DocumentsCubit>().state.selection.isNotEmpty
? const NeverScrollableScrollPhysics()
: null,
children: [
@@ -292,25 +281,20 @@ class _DocumentsPageState extends State<DocumentsPage>
final currState = context.read<DocumentsCubit>().state;
final max = notification.metrics.maxScrollExtent;
if (max == 0 ||
_currentTab != 0 ||
currState.isLoading ||
currState.isLastPageLoaded) {
return true;
if (max == 0 || _currentTab != 0 || currState.isLoading || currState.isLastPageLoaded) {
return false;
}
final offset = notification.metrics.pixels;
if (offset >= max * 0.7) {
context
.read<DocumentsCubit>()
.loadMore()
.onError<PaperlessServerException>(
context.read<DocumentsCubit>().loadMore().onError<PaperlessServerException>(
(error, stackTrace) => showErrorMessage(
context,
error,
stackTrace,
),
);
return true;
}
return false;
},
@@ -338,8 +322,7 @@ class _DocumentsPageState extends State<DocumentsPage>
return SliverAdaptiveDocumentsView(
viewType: state.viewType,
onTap: _openDetails,
onSelected:
context.read<DocumentsCubit>().toggleDocumentSelection,
onSelected: context.read<DocumentsCubit>().toggleDocumentSelection,
hasInternetConnection: connectivityState.isConnected,
onTagSelected: _addTagToFilter,
onCorrespondentSelected: _addCorrespondentToFilter,
@@ -350,6 +333,10 @@ class _DocumentsPageState extends State<DocumentsPage>
isLabelClickable: true,
isLoading: state.isLoading,
selectedDocumentIds: state.selectedIds,
correspondents: state.correspondents,
documentTypes: state.documentTypes,
tags: state.tags,
storagePaths: state.storagePaths,
);
},
),
@@ -361,53 +348,38 @@ class _DocumentsPageState extends State<DocumentsPage>
Widget _buildViewActions() {
return SliverToBoxAdapter(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const SortDocumentsButton(),
BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, state) {
return ViewTypeSelectionWidget(
child: BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, state) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
SortDocumentsButton(
enabled: state.selection.isEmpty,
),
ViewTypeSelectionWidget(
viewType: state.viewType,
onChanged: context.read<DocumentsCubit>().setViewType,
);
},
)
],
),
],
);
},
).paddedSymmetrically(horizontal: 8, vertical: 4),
);
}
void _onDelete(DocumentsState documentsState) async {
final shouldDelete = await showDialog<bool>(
context: context,
builder: (context) =>
BulkDeleteConfirmationDialog(state: documentsState),
) ??
false;
if (shouldDelete) {
try {
await context
.read<DocumentsCubit>()
.bulkDelete(documentsState.selection);
showSnackBar(
context,
S.of(context)!.documentsSuccessfullyDeleted,
);
context.read<DocumentsCubit>().resetSelection();
} on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace);
}
}
}
void _onCreateSavedView(DocumentFilter filter) async {
final newView = await Navigator.of(context).push<SavedView?>(
MaterialPageRoute(
builder: (context) => LabelsBlocProvider(
child: AddSavedViewPage(
currentFilter: filter,
),
builder: (context) => BlocBuilder<SavedViewCubit, SavedViewState>(
builder: (context, state) {
return AddSavedViewPage(
currentFilter: filter,
correspondents: state.correspondents,
documentTypes: state.documentTypes,
storagePaths: state.storagePaths,
tags: state.tags,
);
},
),
),
);
@@ -441,12 +413,18 @@ class _DocumentsPageState extends State<DocumentsPage>
snapSizes: const [0.9, 1],
initialChildSize: .9,
maxChildSize: 1,
builder: (context, controller) => LabelsBlocProvider(
child: DocumentFilterPanel(
initialFilter: context.read<DocumentsCubit>().state.filter,
scrollController: controller,
draggableSheetController: draggableSheetController,
),
builder: (context, controller) => BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, state) {
return DocumentFilterPanel(
initialFilter: context.read<DocumentsCubit>().state.filter,
scrollController: controller,
draggableSheetController: draggableSheetController,
correspondents: state.correspondents,
documentTypes: state.documentTypes,
storagePaths: state.storagePaths,
tags: state.tags,
);
},
),
),
),
@@ -456,9 +434,7 @@ class _DocumentsPageState extends State<DocumentsPage>
if (filterIntent.shouldReset) {
await context.read<DocumentsCubit>().resetFilter();
} else {
await context
.read<DocumentsCubit>()
.updateFilter(filter: filterIntent.filter!);
await context.read<DocumentsCubit>().updateFilter(filter: filterIntent.filter!);
}
} on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace);
@@ -478,20 +454,21 @@ class _DocumentsPageState extends State<DocumentsPage>
void _addTagToFilter(int tagId) {
try {
final tagsQuery =
context.read<DocumentsCubit>().state.filter.tags is IdsTagsQuery
? context.read<DocumentsCubit>().state.filter.tags as IdsTagsQuery
: const IdsTagsQuery();
if (tagsQuery.includedIds.contains(tagId)) {
final tagsQuery = context.read<DocumentsCubit>().state.filter.tags is IdsTagsQuery
? context.read<DocumentsCubit>().state.filter.tags as IdsTagsQuery
: const IdsTagsQuery();
if (tagsQuery.include.contains(tagId)) {
context.read<DocumentsCubit>().updateCurrentFilter(
(filter) => filter.copyWith(
tags: tagsQuery.withIdsRemoved([tagId]),
tags: tagsQuery.copyWith(
include: tagsQuery.include.whereNot((id) => id == tagId).toList(),
exclude: tagsQuery.exclude.whereNot((id) => id == tagId).toList()),
),
);
} else {
context.read<DocumentsCubit>().updateCurrentFilter(
(filter) => filter.copyWith(
tags: tagsQuery.withIdQueriesAdded([IncludeTagIdQuery(tagId)]),
tags: tagsQuery.copyWith(include: [...tagsQuery.include, tagId]),
),
);
}
@@ -503,16 +480,17 @@ class _DocumentsPageState extends State<DocumentsPage>
void _addCorrespondentToFilter(int? correspondentId) {
final cubit = context.read<DocumentsCubit>();
try {
if (cubit.state.filter.correspondent.id == correspondentId) {
cubit.updateCurrentFilter(
(filter) =>
filter.copyWith(correspondent: const IdQueryParameter.unset()),
);
} else {
cubit.updateCurrentFilter(
(filter) => filter.copyWith(
correspondent: IdQueryParameter.fromId(correspondentId)),
);
final correspondent = cubit.state.filter.correspondent;
if (correspondent is SetIdQueryParameter) {
if (correspondentId == null || correspondent.id == correspondentId) {
cubit.updateCurrentFilter(
(filter) => filter.copyWith(correspondent: const IdQueryParameter.unset()),
);
} else {
cubit.updateCurrentFilter(
(filter) => filter.copyWith(correspondent: IdQueryParameter.fromId(correspondentId)),
);
}
}
} on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace);
@@ -522,16 +500,17 @@ class _DocumentsPageState extends State<DocumentsPage>
void _addDocumentTypeToFilter(int? documentTypeId) {
final cubit = context.read<DocumentsCubit>();
try {
if (cubit.state.filter.documentType.id == documentTypeId) {
cubit.updateCurrentFilter(
(filter) =>
filter.copyWith(documentType: const IdQueryParameter.unset()),
);
} else {
cubit.updateCurrentFilter(
(filter) => filter.copyWith(
documentType: IdQueryParameter.fromId(documentTypeId)),
);
final documentType = cubit.state.filter.documentType;
if (documentType is SetIdQueryParameter) {
if (documentTypeId == null || documentType.id == documentTypeId) {
cubit.updateCurrentFilter(
(filter) => filter.copyWith(documentType: const IdQueryParameter.unset()),
);
} else {
cubit.updateCurrentFilter(
(filter) => filter.copyWith(documentType: IdQueryParameter.fromId(documentTypeId)),
);
}
}
} on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace);
@@ -541,16 +520,17 @@ class _DocumentsPageState extends State<DocumentsPage>
void _addStoragePathToFilter(int? pathId) {
final cubit = context.read<DocumentsCubit>();
try {
if (cubit.state.filter.correspondent.id == pathId) {
cubit.updateCurrentFilter(
(filter) =>
filter.copyWith(storagePath: const IdQueryParameter.unset()),
);
} else {
cubit.updateCurrentFilter(
(filter) =>
filter.copyWith(storagePath: IdQueryParameter.fromId(pathId)),
);
final path = cubit.state.filter.documentType;
if (path is SetIdQueryParameter) {
if (pathId == null || path.id == pathId) {
cubit.updateCurrentFilter(
(filter) => filter.copyWith(storagePath: const IdQueryParameter.unset()),
);
} else {
cubit.updateCurrentFilter(
(filter) => filter.copyWith(storagePath: IdQueryParameter.fromId(pathId)),
);
}
}
} on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace);

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/provider/label_repositories_provider.dart';
import 'package:paperless_mobile/features/documents/view/widgets/placeholder/document_grid_loading_widget.dart';
import 'package:paperless_mobile/features/documents/view/widgets/items/document_detailed_item.dart';
import 'package:paperless_mobile/features/documents/view/widgets/items/document_grid_item.dart';
@@ -25,7 +24,13 @@ abstract class AdaptiveDocumentsView extends StatelessWidget {
final void Function(int? id)? onDocumentTypeSelected;
final void Function(int? id)? onStoragePathSelected;
bool get showLoadingPlaceholder => (!hasLoaded && isLoading);
final Map<int, Correspondent> correspondents;
final Map<int, DocumentType> documentTypes;
final Map<int, Tag> tags;
final Map<int, StoragePath> storagePaths;
bool get showLoadingPlaceholder => !hasLoaded && isLoading;
const AdaptiveDocumentsView({
super.key,
this.selectedDocumentIds = const [],
@@ -42,6 +47,10 @@ abstract class AdaptiveDocumentsView extends StatelessWidget {
required this.isLoading,
required this.hasLoaded,
this.enableHeroAnimation = true,
required this.correspondents,
required this.documentTypes,
required this.tags,
required this.storagePaths,
});
AdaptiveDocumentsView.fromPagedState(
@@ -58,6 +67,10 @@ abstract class AdaptiveDocumentsView extends StatelessWidget {
required this.hasInternetConnection,
this.viewType = ViewType.list,
this.selectedDocumentIds = const [],
required this.correspondents,
required this.documentTypes,
required this.tags,
required this.storagePaths,
}) : documents = state.documents,
isLoading = state.isLoading,
hasLoaded = state.hasLoaded;
@@ -80,6 +93,10 @@ class SliverAdaptiveDocumentsView extends AdaptiveDocumentsView {
super.enableHeroAnimation,
required super.isLoading,
required super.hasLoaded,
required super.correspondents,
required super.documentTypes,
required super.tags,
required super.storagePaths,
});
@override
@@ -96,27 +113,29 @@ class SliverAdaptiveDocumentsView extends AdaptiveDocumentsView {
Widget _buildListView() {
if (showLoadingPlaceholder) {
return DocumentsListLoadingWidget.sliver();
return const DocumentsListLoadingWidget.sliver();
}
return SliverList(
delegate: SliverChildBuilderDelegate(
childCount: documents.length,
(context, index) {
final document = documents.elementAt(index);
return LabelRepositoriesProvider(
child: DocumentListItem(
isLabelClickable: isLabelClickable,
document: document,
onTap: onTap,
isSelected: selectedDocumentIds.contains(document.id),
onSelected: onSelected,
isSelectionActive: selectedDocumentIds.isNotEmpty,
onTagSelected: onTagSelected,
onCorrespondentSelected: onCorrespondentSelected,
onDocumentTypeSelected: onDocumentTypeSelected,
onStoragePathSelected: onStoragePathSelected,
enableHeroAnimation: enableHeroAnimation,
),
return DocumentListItem(
isLabelClickable: isLabelClickable,
document: document,
onTap: onTap,
isSelected: selectedDocumentIds.contains(document.id),
onSelected: onSelected,
isSelectionActive: selectedDocumentIds.isNotEmpty,
onTagSelected: onTagSelected,
onCorrespondentSelected: onCorrespondentSelected,
onDocumentTypeSelected: onDocumentTypeSelected,
onStoragePathSelected: onStoragePathSelected,
enableHeroAnimation: enableHeroAnimation,
correspondents: correspondents,
documentTypes: documentTypes,
storagePaths: storagePaths,
tags: tags,
);
},
),
@@ -126,28 +145,30 @@ class SliverAdaptiveDocumentsView extends AdaptiveDocumentsView {
Widget _buildFullView(BuildContext context) {
if (showLoadingPlaceholder) {
//TODO: Build detailed loading animation
return DocumentsListLoadingWidget.sliver();
return const DocumentsListLoadingWidget.sliver();
}
return SliverList(
delegate: SliverChildBuilderDelegate(
childCount: documents.length,
(context, index) {
final document = documents.elementAt(index);
return LabelRepositoriesProvider(
child: DocumentDetailedItem(
isLabelClickable: isLabelClickable,
document: document,
onTap: onTap,
isSelected: selectedDocumentIds.contains(document.id),
onSelected: onSelected,
isSelectionActive: selectedDocumentIds.isNotEmpty,
onTagSelected: onTagSelected,
onCorrespondentSelected: onCorrespondentSelected,
onDocumentTypeSelected: onDocumentTypeSelected,
onStoragePathSelected: onStoragePathSelected,
enableHeroAnimation: enableHeroAnimation,
highlights: document.searchHit?.highlights,
),
return DocumentDetailedItem(
isLabelClickable: isLabelClickable,
document: document,
onTap: onTap,
isSelected: selectedDocumentIds.contains(document.id),
onSelected: onSelected,
isSelectionActive: selectedDocumentIds.isNotEmpty,
onTagSelected: onTagSelected,
onCorrespondentSelected: onCorrespondentSelected,
onDocumentTypeSelected: onDocumentTypeSelected,
onStoragePathSelected: onStoragePathSelected,
enableHeroAnimation: enableHeroAnimation,
highlights: document.searchHit?.highlights,
correspondents: correspondents,
documentTypes: documentTypes,
storagePaths: storagePaths,
tags: tags,
);
},
),
@@ -180,6 +201,10 @@ class SliverAdaptiveDocumentsView extends AdaptiveDocumentsView {
onDocumentTypeSelected: onDocumentTypeSelected,
onStoragePathSelected: onStoragePathSelected,
enableHeroAnimation: enableHeroAnimation,
correspondents: correspondents,
documentTypes: documentTypes,
storagePaths: storagePaths,
tags: tags,
);
},
);
@@ -205,6 +230,10 @@ class DefaultAdaptiveDocumentsView extends AdaptiveDocumentsView {
super.selectedDocumentIds,
super.viewType,
super.enableHeroAnimation = true,
required super.correspondents,
required super.documentTypes,
required super.tags,
required super.storagePaths,
});
@override
@@ -231,20 +260,22 @@ class DefaultAdaptiveDocumentsView extends AdaptiveDocumentsView {
itemCount: documents.length,
itemBuilder: (context, index) {
final document = documents.elementAt(index);
return LabelRepositoriesProvider(
child: DocumentListItem(
isLabelClickable: isLabelClickable,
document: document,
onTap: onTap,
isSelected: selectedDocumentIds.contains(document.id),
onSelected: onSelected,
isSelectionActive: selectedDocumentIds.isNotEmpty,
onTagSelected: onTagSelected,
onCorrespondentSelected: onCorrespondentSelected,
onDocumentTypeSelected: onDocumentTypeSelected,
onStoragePathSelected: onStoragePathSelected,
enableHeroAnimation: enableHeroAnimation,
),
return DocumentListItem(
isLabelClickable: isLabelClickable,
document: document,
onTap: onTap,
isSelected: selectedDocumentIds.contains(document.id),
onSelected: onSelected,
isSelectionActive: selectedDocumentIds.isNotEmpty,
onTagSelected: onTagSelected,
onCorrespondentSelected: onCorrespondentSelected,
onDocumentTypeSelected: onDocumentTypeSelected,
onStoragePathSelected: onStoragePathSelected,
enableHeroAnimation: enableHeroAnimation,
correspondents: correspondents,
documentTypes: documentTypes,
storagePaths: storagePaths,
tags: tags,
);
},
);
@@ -252,7 +283,7 @@ class DefaultAdaptiveDocumentsView extends AdaptiveDocumentsView {
Widget _buildFullView() {
if (showLoadingPlaceholder) {
return DocumentsListLoadingWidget();
return const DocumentsListLoadingWidget();
}
return ListView.builder(
@@ -263,20 +294,22 @@ class DefaultAdaptiveDocumentsView extends AdaptiveDocumentsView {
itemCount: documents.length,
itemBuilder: (context, index) {
final document = documents.elementAt(index);
return LabelRepositoriesProvider(
child: DocumentDetailedItem(
isLabelClickable: isLabelClickable,
document: document,
onTap: onTap,
isSelected: selectedDocumentIds.contains(document.id),
onSelected: onSelected,
isSelectionActive: selectedDocumentIds.isNotEmpty,
onTagSelected: onTagSelected,
onCorrespondentSelected: onCorrespondentSelected,
onDocumentTypeSelected: onDocumentTypeSelected,
onStoragePathSelected: onStoragePathSelected,
enableHeroAnimation: enableHeroAnimation,
),
return DocumentDetailedItem(
isLabelClickable: isLabelClickable,
document: document,
onTap: onTap,
isSelected: selectedDocumentIds.contains(document.id),
onSelected: onSelected,
isSelectionActive: selectedDocumentIds.isNotEmpty,
onTagSelected: onTagSelected,
onCorrespondentSelected: onCorrespondentSelected,
onDocumentTypeSelected: onDocumentTypeSelected,
onStoragePathSelected: onStoragePathSelected,
enableHeroAnimation: enableHeroAnimation,
correspondents: correspondents,
documentTypes: documentTypes,
storagePaths: storagePaths,
tags: tags,
);
},
);
@@ -284,7 +317,7 @@ class DefaultAdaptiveDocumentsView extends AdaptiveDocumentsView {
Widget _buildGridView() {
if (showLoadingPlaceholder) {
return DocumentGridLoadingWidget();
return const DocumentGridLoadingWidget();
}
return GridView.builder(
padding: EdgeInsets.zero,
@@ -311,6 +344,10 @@ class DefaultAdaptiveDocumentsView extends AdaptiveDocumentsView {
onDocumentTypeSelected: onDocumentTypeSelected,
onStoragePathSelected: onStoragePathSelected,
enableHeroAnimation: enableHeroAnimation,
correspondents: correspondents,
documentTypes: documentTypes,
storagePaths: storagePaths,
tags: tags,
);
},
);

View File

@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_cancel_button.dart';
import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_confirm_button.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class DeleteDocumentConfirmationDialog extends StatelessWidget {
@@ -30,19 +32,10 @@ class DeleteDocumentConfirmationDialog extends StatelessWidget {
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(S.of(context)!.cancel),
),
TextButton(
style: ButtonStyle(
foregroundColor:
MaterialStateProperty.all(Theme.of(context).colorScheme.error),
),
onPressed: () {
Navigator.pop(context, true);
},
child: Text(S.of(context)!.delete),
const DialogCancelButton(),
DialogConfirmButton(
label: S.of(context)!.delete,
style: DialogConfirmButtonStyle.danger,
),
],
);

View File

@@ -1,6 +1,7 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:intl/intl.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/documents/view/widgets/document_preview.dart';
@@ -8,7 +9,6 @@ import 'package:paperless_mobile/features/documents/view/widgets/items/document_
import 'package:paperless_mobile/features/labels/correspondent/view/widgets/correspondent_widget.dart';
import 'package:paperless_mobile/features/labels/document_type/view/widgets/document_type_widget.dart';
import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart';
import 'package:flutter_html/flutter_html.dart';
class DocumentDetailedItem extends DocumentItem {
final String? highlights;
@@ -26,6 +26,10 @@ class DocumentDetailedItem extends DocumentItem {
super.onStoragePathSelected,
super.onTagSelected,
super.onTap,
required super.tags,
required super.correspondents,
required super.documentTypes,
required super.storagePaths,
});
@override
@@ -40,10 +44,10 @@ class DocumentDetailedItem extends DocumentItem {
padding.bottom -
kBottomNavigationBarHeight -
kToolbarHeight;
final maxHeight = highlights != null
? min(600.0, availableHeight)
: min(500.0, availableHeight);
final maxHeight =
highlights != null ? min(600.0, availableHeight) : min(500.0, availableHeight);
return Card(
color: isSelected ? Theme.of(context).colorScheme.inversePrimary : null,
child: InkWell(
enableFeedback: true,
borderRadius: BorderRadius.circular(12),
@@ -112,7 +116,7 @@ class DocumentDetailedItem extends DocumentItem {
textStyle: Theme.of(context).textTheme.titleSmall?.apply(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
correspondentId: document.correspondent,
correspondent: correspondents[document.correspondent],
),
],
).paddedLTRB(8, 0, 8, 4),
@@ -127,13 +131,13 @@ class DocumentDetailedItem extends DocumentItem {
textStyle: Theme.of(context).textTheme.titleSmall?.apply(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
documentTypeId: document.documentType,
documentType: documentTypes[document.documentType],
),
],
).paddedLTRB(8, 0, 8, 4),
TagsWidget(
isMultiLine: false,
tagIds: document.tags,
tags: document.tags.map((e) => tags[e]!).toList(),
).padded(),
if (highlights != null)
Html(

View File

@@ -21,6 +21,10 @@ class DocumentGridItem extends DocumentItem {
super.onTagSelected,
super.onTap,
required super.enableHeroAnimation,
required super.tags,
required super.correspondents,
required super.documentTypes,
required super.storagePaths,
});
@override
@@ -54,10 +58,10 @@ class DocumentGridItem extends DocumentItem {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CorrespondentWidget(
correspondentId: document.correspondent,
correspondent: correspondents[document.correspondent],
),
DocumentTypeWidget(
documentTypeId: document.documentType,
documentType: documentTypes[document.documentType],
),
Text(
document.title,
@@ -67,7 +71,7 @@ class DocumentGridItem extends DocumentItem {
),
const Spacer(),
TagsWidget(
tagIds: document.tags,
tags: document.tags.map((e) => tags[e]!).toList(),
isMultiLine: false,
onTagSelected: onTagSelected,
),

View File

@@ -10,6 +10,11 @@ abstract class DocumentItem extends StatelessWidget {
final bool isLabelClickable;
final bool enableHeroAnimation;
final Map<int, Tag> tags;
final Map<int, Correspondent> correspondents;
final Map<int, DocumentType> documentTypes;
final Map<int, StoragePath> storagePaths;
final void Function(int tagId)? onTagSelected;
final void Function(int? correspondentId)? onCorrespondentSelected;
final void Function(int? documentTypeId)? onDocumentTypeSelected;
@@ -28,5 +33,9 @@ abstract class DocumentItem extends StatelessWidget {
this.onDocumentTypeSelected,
this.onStoragePathSelected,
required this.enableHeroAnimation,
required this.tags,
required this.correspondents,
required this.documentTypes,
required this.storagePaths,
});
}

View File

@@ -1,11 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/features/documents/view/widgets/document_preview.dart';
import 'package:paperless_mobile/features/documents/view/widgets/items/document_item.dart';
import 'package:paperless_mobile/features/labels/cubit/label_cubit.dart';
import 'package:paperless_mobile/features/labels/cubit/providers/document_type_bloc_provider.dart';
import 'package:paperless_mobile/features/labels/correspondent/view/widgets/correspondent_widget.dart';
import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart';
@@ -25,11 +21,15 @@ class DocumentListItem extends DocumentItem {
super.onTagSelected,
super.onTap,
super.enableHeroAnimation = true,
required super.tags,
required super.correspondents,
required super.documentTypes,
required super.storagePaths,
});
@override
Widget build(BuildContext context) {
return DocumentTypeBlocProvider(
return Material(
child: ListTile(
dense: true,
selected: isSelected,
@@ -46,7 +46,7 @@ class DocumentListItem extends DocumentItem {
absorbing: isSelectionActive,
child: CorrespondentWidget(
isClickable: isLabelClickable,
correspondentId: document.correspondent,
correspondent: correspondents[document.correspondent],
onSelected: onCorrespondentSelected,
),
),
@@ -61,62 +61,59 @@ class DocumentListItem extends DocumentItem {
absorbing: isSelectionActive,
child: TagsWidget(
isClickable: isLabelClickable,
tagIds: document.tags,
tags: document.tags
.where((e) => tags.containsKey(e))
.map((e) => tags[e]!)
.toList(),
isMultiLine: false,
onTagSelected: (id) => onTagSelected?.call(id),
),
)
),
],
),
subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child:
BlocBuilder<LabelCubit<DocumentType>, LabelState<DocumentType>>(
builder: (context, docTypes) {
return RichText(
maxLines: 1,
overflow: TextOverflow.ellipsis,
text: TextSpan(
text: DateFormat.yMMMd().format(document.created),
style: Theme.of(context)
.textTheme
.labelSmall
?.apply(color: Colors.grey),
children: document.documentType != null
? [
const TextSpan(text: '\u30FB'),
TextSpan(
text:
docTypes.labels[document.documentType]?.name,
),
]
: null,
),
);
},
)
// Row(
// children: [
// Text(
// DateFormat.yMMMd().format(document.created),
// style: Theme.of(context)
// .textTheme
// .bodySmall
// ?.apply(color: Colors.grey),
// ),
// if (document.documentType != null) ...[
// Text("\u30FB"),
// DocumentTypeWidget(
// documentTypeId: document.documentType,
// textStyle: Theme.of(context).textTheme.bodySmall?.apply(
// color: Colors.grey,
// overflow: TextOverflow.ellipsis,
// ),
// ),
// ],
// ],
// ),
padding: const EdgeInsets.symmetric(vertical: 4),
child: RichText(
maxLines: 1,
overflow: TextOverflow.ellipsis,
text: TextSpan(
text: DateFormat.yMMMd().format(document.created),
style: Theme.of(context)
.textTheme
.labelSmall
?.apply(color: Colors.grey),
children: document.documentType != null
? [
const TextSpan(text: '\u30FB'),
TextSpan(
text: documentTypes[document.documentType]?.name,
),
]
: null,
),
),
// Row(
// children: [
// Text(
// DateFormat.yMMMd().format(document.created),
// style: Theme.of(context)
// .textTheme
// .bodySmall
// ?.apply(color: Colors.grey),
// ),
// if (document.documentType != null) ...[
// Text("\u30FB"),
// DocumentTypeWidget(
// documentTypeId: document.documentType,
// textStyle: Theme.of(context).textTheme.bodySmall?.apply(
// color: Colors.grey,
// overflow: TextOverflow.ellipsis,
// ),
// ),
// ],
// ],
// ),
),
isThreeLine: document.tags.isNotEmpty,
leading: AspectRatio(
aspectRatio: _a4AspectRatio,

View File

@@ -5,6 +5,7 @@ import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/widgets/form_builder_fields/extended_date_range_form_field/form_builder_extended_date_range_picker.dart';
import 'package:paperless_mobile/features/labels/cubit/label_cubit.dart';
import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_form_field.dart';
import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_form_field.dart';
import 'package:paperless_mobile/features/labels/view/widgets/label_form_field.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
@@ -49,6 +50,11 @@ class DocumentFilterForm extends StatefulWidget {
final DocumentFilter initialFilter;
final ScrollController? scrollController;
final EdgeInsets padding;
final Map<int, Correspondent> correspondents;
final Map<int, DocumentType> documentTypes;
final Map<int, Tag> tags;
final Map<int, StoragePath> storagePaths;
const DocumentFilterForm({
super.key,
this.header,
@@ -56,6 +62,10 @@ class DocumentFilterForm extends StatefulWidget {
required this.initialFilter,
this.scrollController,
this.padding = const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
required this.correspondents,
required this.documentTypes,
required this.tags,
required this.storagePaths,
});
@override
@@ -80,7 +90,7 @@ class _DocumentFilterFormState extends State<DocumentFilterForm> {
slivers: [
if (widget.header != null) widget.header!,
..._buildFormFieldList(),
SliverToBoxAdapter(
const SliverToBoxAdapter(
child: SizedBox(
height: 32,
),
@@ -145,47 +155,32 @@ class _DocumentFilterFormState extends State<DocumentFilterForm> {
}
Widget _buildDocumentTypeFormField() {
return BlocBuilder<LabelCubit<DocumentType>, LabelState<DocumentType>>(
builder: (context, state) {
return LabelFormField<DocumentType>(
formBuilderState: widget.formKey.currentState,
name: DocumentFilterForm.fkDocumentType,
labelOptions: state.labels,
textFieldLabel: S.of(context)!.documentType,
initialValue: widget.initialFilter.documentType,
prefixIcon: const Icon(Icons.description_outlined),
);
},
return LabelFormField<DocumentType>(
name: DocumentFilterForm.fkDocumentType,
options: widget.documentTypes,
labelText: S.of(context)!.documentType,
initialValue: widget.initialFilter.documentType,
prefixIcon: const Icon(Icons.description_outlined),
);
}
Widget _buildCorrespondentFormField() {
return BlocBuilder<LabelCubit<Correspondent>, LabelState<Correspondent>>(
builder: (context, state) {
return LabelFormField<Correspondent>(
formBuilderState: widget.formKey.currentState,
name: DocumentFilterForm.fkCorrespondent,
labelOptions: state.labels,
textFieldLabel: S.of(context)!.correspondent,
initialValue: widget.initialFilter.correspondent,
prefixIcon: const Icon(Icons.person_outline),
);
},
return LabelFormField<Correspondent>(
name: DocumentFilterForm.fkCorrespondent,
options: widget.correspondents,
labelText: S.of(context)!.correspondent,
initialValue: widget.initialFilter.correspondent,
prefixIcon: const Icon(Icons.person_outline),
);
}
Widget _buildStoragePathFormField() {
return BlocBuilder<LabelCubit<StoragePath>, LabelState<StoragePath>>(
builder: (context, state) {
return LabelFormField<StoragePath>(
formBuilderState: widget.formKey.currentState,
name: DocumentFilterForm.fkStoragePath,
labelOptions: state.labels,
textFieldLabel: S.of(context)!.storagePath,
initialValue: widget.initialFilter.storagePath,
prefixIcon: const Icon(Icons.folder_outlined),
);
},
return LabelFormField<StoragePath>(
name: DocumentFilterForm.fkStoragePath,
options: widget.storagePaths,
labelText: S.of(context)!.storagePath,
initialValue: widget.initialFilter.storagePath,
prefixIcon: const Icon(Icons.folder_outlined),
);
}
@@ -197,16 +192,14 @@ class _DocumentFilterFormState extends State<DocumentFilterForm> {
);
}
BlocBuilder<LabelCubit<Tag>, LabelState<Tag>> _buildTagsFormField() {
return BlocBuilder<LabelCubit<Tag>, LabelState<Tag>>(
builder: (context, state) {
return TagFormField(
name: DocumentModel.tagsKey,
initialValue: widget.initialFilter.tags,
allowCreation: false,
selectableOptions: state.labels,
);
},
Widget _buildTagsFormField() {
return TagsFormField(
name: DocumentModel.tagsKey,
initialValue: widget.initialFilter.tags,
options: widget.tags,
allowExclude: false,
allowOnlySelection: false,
allowCreation: false,
);
}
}

View File

@@ -13,11 +13,20 @@ class DocumentFilterPanel extends StatefulWidget {
final DocumentFilter initialFilter;
final ScrollController scrollController;
final DraggableScrollableController draggableSheetController;
final Map<int, Correspondent> correspondents;
final Map<int, DocumentType> documentTypes;
final Map<int, Tag> tags;
final Map<int, StoragePath> storagePaths;
const DocumentFilterPanel({
Key? key,
required this.initialFilter,
required this.scrollController,
required this.draggableSheetController,
required this.correspondents,
required this.documentTypes,
required this.tags,
required this.storagePaths,
}) : super(key: key);
@override
@@ -38,10 +47,8 @@ class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
void animateTitleByDrag() {
setState(
() {
_heightAnimationValue = dp(
((max(0.9, widget.draggableSheetController.size) - 0.9) / 0.1), 5);
},
() => _heightAnimationValue =
dp(((max(0.9, widget.draggableSheetController.size) - 0.9) / 0.1), 5),
);
}
@@ -96,6 +103,10 @@ class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
scrollController: widget.scrollController,
initialFilter: widget.initialFilter,
header: _buildPanelHeader(),
correspondents: widget.correspondents,
documentTypes: widget.documentTypes,
storagePaths: widget.storagePaths,
tags: widget.tags,
),
),
);

View File

@@ -10,6 +10,10 @@ import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class SortFieldSelectionBottomSheet extends StatefulWidget {
final SortOrder initialSortOrder;
final SortField? initialSortField;
final Map<int, Correspondent> correspondents;
final Map<int, DocumentType> documentTypes;
final Map<int, Tag> tags;
final Map<int, StoragePath> storagePaths;
final Future Function(SortField? field, SortOrder order) onSubmit;
@@ -18,6 +22,10 @@ class SortFieldSelectionBottomSheet extends StatefulWidget {
required this.initialSortOrder,
required this.initialSortField,
required this.onSubmit,
required this.correspondents,
required this.documentTypes,
required this.tags,
required this.storagePaths,
});
@override
@@ -67,31 +75,20 @@ class _SortFieldSelectionBottomSheetState
Column(
children: [
_buildSortOption(SortField.archiveSerialNumber),
BlocBuilder<LabelCubit<Correspondent>,
LabelState<Correspondent>>(
builder: (context, state) {
return _buildSortOption(
SortField.correspondentName,
enabled: state.labels.values.fold<bool>(
false,
(previousValue, element) =>
previousValue ||
(element.documentCount ?? 0) > 0),
);
},
_buildSortOption(
SortField.correspondentName,
enabled: widget.correspondents.values.fold<bool>(
false,
(previousValue, element) =>
previousValue || (element.documentCount ?? 0) > 0),
),
_buildSortOption(SortField.title),
BlocBuilder<LabelCubit<DocumentType>, LabelState<DocumentType>>(
builder: (context, state) {
return _buildSortOption(
SortField.documentType,
enabled: state.labels.values.fold<bool>(
false,
(previousValue, element) =>
previousValue ||
(element.documentCount ?? 0) > 0),
);
},
_buildSortOption(
SortField.documentType,
enabled: widget.documentTypes.values.fold<bool>(
false,
(previousValue, element) =>
previousValue || (element.documentCount ?? 0) > 0),
),
_buildSortOption(SortField.created),
_buildSortOption(SortField.added),

View File

@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:paperless_api/paperless_api.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/features/documents/cubit/documents_cubit.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
@@ -29,19 +31,10 @@ class BulkDeleteConfirmationDialog extends StatelessWidget {
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(S.of(context)!.cancel),
),
TextButton(
style: ButtonStyle(
foregroundColor:
MaterialStateProperty.all(Theme.of(context).colorScheme.error),
),
onPressed: () {
Navigator.pop(context, true);
},
child: Text(S.of(context)!.delete),
const DialogCancelButton(),
DialogConfirmButton(
label: S.of(context)!.delete,
style: DialogConfirmButtonStyle.danger,
),
],
);

View File

@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_cancel_button.dart';
import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_confirm_button.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class ConfirmDeleteSavedViewDialog extends StatelessWidget {
@@ -19,16 +21,10 @@ class ConfirmDeleteSavedViewDialog extends StatelessWidget {
),
content: Text(S.of(context)!.doYouReallyWantToDeleteThisView),
actions: [
TextButton(
child: Text(S.of(context)!.cancel),
onPressed: () => Navigator.pop(context, false),
),
TextButton(
child: Text(
S.of(context)!.delete,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
onPressed: () => Navigator.pop(context, true),
const DialogCancelButton(),
DialogConfirmButton(
label: S.of(context)!.delete,
style: DialogConfirmButtonStyle.danger,
),
],
);

View File

@@ -1,12 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter/src/widgets/framework.dart';
import 'package:flutter/src/widgets/placeholder.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/document_bulk_action/cubit/document_bulk_action_cubit.dart';
import 'package:paperless_mobile/features/document_bulk_action/view/widgets/fullscreen_bulk_edit_label_page.dart';
import 'package:paperless_mobile/features/document_bulk_action/view/widgets/fullscreen_bulk_edit_tags_widget.dart';
import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart';
import 'package:paperless_mobile/features/documents/view/widgets/selection/bulk_delete_confirmation_dialog.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart';
import 'package:provider/provider.dart';
class DocumentSelectionSliverAppBar extends StatelessWidget {
final DocumentsState state;
@@ -15,7 +17,11 @@ class DocumentSelectionSliverAppBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SliverAppBar(
stretch: false,
pinned: true,
floating: true,
snap: true,
backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
title: Text(
S.of(context)!.countSelected(state.selection.length),
),
@@ -50,6 +56,181 @@ class DocumentSelectionSliverAppBar extends StatelessWidget {
},
),
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(kTextTabBarHeight),
child: SizedBox(
height: kTextTabBarHeight,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
ActionChip(
label: Text(S.of(context)!.correspondent),
avatar: const Icon(Icons.edit),
onPressed: () async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => BlocProvider(
create: (context) => DocumentBulkActionCubit(
context.read(),
context.read(),
context.read(),
selection: state.selection,
),
child: BlocBuilder<DocumentBulkActionCubit,
DocumentBulkActionState>(
builder: (context, state) {
return FullscreenBulkEditLabelPage(
options: state.correspondents,
selection: state.selection,
labelMapper: (document) => document.correspondent,
leadingIcon: const Icon(Icons.person_outline),
hintText: S.of(context)!.startTyping,
onSubmit: context
.read<DocumentBulkActionCubit>()
.bulkModifyCorrespondent,
assignMessageBuilder: (int count, String name) {
return S
.of(context)!
.bulkEditCorrespondentAssignMessage(
name,
count,
);
},
removeMessageBuilder: (int count) {
return S
.of(context)!
.bulkEditCorrespondentRemoveMessage(count);
},
);
},
),
),
),
);
},
).paddedOnly(left: 8, right: 4),
ActionChip(
label: Text(S.of(context)!.documentType),
avatar: const Icon(Icons.edit),
onPressed: () async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => BlocProvider(
create: (context) => DocumentBulkActionCubit(
context.read(),
context.read(),
context.read(),
selection: state.selection,
),
child: BlocBuilder<DocumentBulkActionCubit,
DocumentBulkActionState>(
builder: (context, state) {
return FullscreenBulkEditLabelPage(
options: state.documentTypes,
selection: state.selection,
labelMapper: (document) => document.documentType,
leadingIcon:
const Icon(Icons.description_outlined),
hintText: S.of(context)!.startTyping,
onSubmit: context
.read<DocumentBulkActionCubit>()
.bulkModifyDocumentType,
assignMessageBuilder: (int count, String name) {
return S
.of(context)!
.bulkEditDocumentTypeAssignMessage(
count,
name,
);
},
removeMessageBuilder: (int count) {
return S
.of(context)!
.bulkEditDocumentTypeRemoveMessage(count);
},
);
},
),
),
),
);
},
).paddedOnly(left: 8, right: 4),
ActionChip(
label: Text(S.of(context)!.storagePath),
avatar: const Icon(Icons.edit),
onPressed: () async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => BlocProvider(
create: (context) => DocumentBulkActionCubit(
context.read(),
context.read(),
context.read(),
selection: state.selection,
),
child: BlocBuilder<DocumentBulkActionCubit,
DocumentBulkActionState>(
builder: (context, state) {
return FullscreenBulkEditLabelPage(
options: state.storagePaths,
selection: state.selection,
labelMapper: (document) => document.storagePath,
leadingIcon: const Icon(Icons.folder_outlined),
hintText: S.of(context)!.startTyping,
onSubmit: context
.read<DocumentBulkActionCubit>()
.bulkModifyStoragePath,
assignMessageBuilder: (int count, String name) {
return S
.of(context)!
.bulkEditStoragePathAssignMessage(
count,
name,
);
},
removeMessageBuilder: (int count) {
return S
.of(context)!
.bulkEditStoragePathRemoveMessage(count);
},
);
},
),
),
),
);
},
).paddedOnly(left: 8, right: 4),
_buildBulkEditTagsChip(context).paddedOnly(left: 4, right: 4),
],
),
),
),
);
}
Widget _buildBulkEditTagsChip(BuildContext context) {
return ActionChip(
label: Text(S.of(context)!.tags),
avatar: const Icon(Icons.edit),
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => BlocProvider(
create: (context) => DocumentBulkActionCubit(
context.read(),
context.read(),
context.read(),
selection: state.selection,
),
child: Builder(builder: (context) {
return const FullscreenBulkEditTagsWidget();
}),
),
),
);
},
);
}
}

View File

@@ -29,6 +29,10 @@ class ViewTypeSelectionWidget extends StatelessWidget {
}
return PopupMenuButton<ViewType>(
constraints: const BoxConstraints(
minWidth: 4 * 56.0,
maxWidth: 5 * 56.0,
), // Ensures text is not split into two lines
position: PopupMenuPosition.under,
initialValue: viewType,
icon: Icon(icon),
@@ -70,7 +74,10 @@ class ViewTypeSelectionWidget extends StatelessWidget {
child: ListTile(
selected: selected,
trailing: selected ? const Icon(Icons.done) : null,
title: Text(label),
title: Text(
label,
maxLines: 1,
),
iconColor: Theme.of(context).colorScheme.onSurface,
textColor: Theme.of(context).colorScheme.onSurface,
leading: Icon(icon),

View File

@@ -8,8 +8,10 @@ import 'package:paperless_mobile/features/documents/view/widgets/search/sort_fie
import 'package:paperless_mobile/features/labels/cubit/label_cubit.dart';
class SortDocumentsButton extends StatelessWidget {
final bool enabled;
const SortDocumentsButton({
super.key,
this.enabled = true,
});
@override
@@ -24,47 +26,47 @@ class SortDocumentsButton extends StatelessWidget {
? Icons.arrow_upward
: Icons.arrow_downward),
label: Text(translateSortField(context, state.filter.sortField)),
onPressed: () {
showModalBottomSheet(
elevation: 2,
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
builder: (_) => BlocProvider<DocumentsCubit>.value(
value: context.read<DocumentsCubit>(),
child: MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => LabelCubit<DocumentType>(
context.read<LabelRepository<DocumentType>>(),
onPressed: enabled
? () {
showModalBottomSheet(
elevation: 2,
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
BlocProvider(
create: (context) => LabelCubit<Correspondent>(
context.read<LabelRepository<Correspondent>>(),
),
),
],
child: SortFieldSelectionBottomSheet(
initialSortField: state.filter.sortField,
initialSortOrder: state.filter.sortOrder,
onSubmit: (field, order) =>
context.read<DocumentsCubit>().updateCurrentFilter(
(filter) => filter.copyWith(
sortField: field,
sortOrder: order,
builder: (_) => BlocProvider<DocumentsCubit>.value(
value: context.read<DocumentsCubit>(),
child: MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => LabelCubit(context.read()),
),
],
child: SortFieldSelectionBottomSheet(
initialSortField: state.filter.sortField,
initialSortOrder: state.filter.sortOrder,
onSubmit: (field, order) => context
.read<DocumentsCubit>()
.updateCurrentFilter(
(filter) => filter.copyWith(
sortField: field,
sortOrder: order,
),
),
),
),
),
),
);
},
correspondents: state.correspondents,
documentTypes: state.documentTypes,
storagePaths: state.storagePaths,
tags: state.tags,
),
),
),
);
}
: null,
);
},
);

View File

@@ -1,34 +1,33 @@
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/features/labels/cubit/label_cubit_mixin.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'edit_label_state.dart';
part 'edit_label_cubit.freezed.dart';
class EditLabelCubit<T extends Label> extends Cubit<EditLabelState<T>> {
final LabelRepository<T> _repository;
class EditLabelCubit extends Cubit<EditLabelState>
with LabelCubitMixin<EditLabelState> {
@override
final LabelRepository labelRepository;
StreamSubscription? _subscription;
EditLabelCubit(LabelRepository<T> repository)
: _repository = repository,
super(EditLabelState<T>(labels: repository.current?.values ?? {})) {
_subscription = repository.values.listen(
(event) => emit(EditLabelState(labels: event?.values ?? {})),
EditLabelCubit(this.labelRepository) : super(const EditLabelState()) {
labelRepository.addListener(
this,
onChanged: (labels) => state.copyWith(
correspondents: labels.correspondents,
documentTypes: labels.documentTypes,
tags: labels.tags,
storagePaths: labels.storagePaths,
),
);
}
Future<T> create(T label) => _repository.create(label);
Future<T> update(T label) => _repository.update(label);
Future<void> delete(T label) => _repository.delete(label);
@override
Future<void> close() {
_subscription?.cancel();
labelRepository.removeListener(this);
return super.close();
}
}

Some files were not shown because too many files have changed in this diff Show More