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" default: "alpha"
type: choice type: choice
options: options:
- internal
- promote_to_alpha
- alpha - alpha
- promote_to_beta - promote_to_beta
- beta - beta
@@ -31,25 +33,15 @@ jobs:
channel: stable channel: stable
- run: flutter doctor -v - run: flutter doctor -v
# Setup app # Clone repository
- name: Checkout Paperless mobile, get packages and run code generators - name: Checkout Paperless mobile, get packages and run code generators
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c 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 - name: Configure Keystore
run: | 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 "storeFile=upload-keystore.jks" >> key.properties
echo "keyAlias=$KEYSTORE_KEY_ALIAS" >> key.properties echo "keyAlias=$KEYSTORE_KEY_ALIAS" >> key.properties
echo "storePassword=$KEYSTORE_STORE_PASSWORD" >> key.properties echo "storePassword=$KEYSTORE_STORE_PASSWORD" >> key.properties
@@ -59,8 +51,25 @@ jobs:
KEYSTORE_KEY_ALIAS: ${{ secrets.KEYSTORE_KEY_ALIAS }} KEYSTORE_KEY_ALIAS: ${{ secrets.KEYSTORE_KEY_ALIAS }}
KEYSTORE_KEY_PASSWORD: ${{ secrets.KEYSTORE_KEY_PASSWORD }} KEYSTORE_KEY_PASSWORD: ${{ secrets.KEYSTORE_KEY_PASSWORD }}
KEYSTORE_STORE_PASSWORD: ${{ secrets.KEYSTORE_STORE_PASSWORD }} KEYSTORE_STORE_PASSWORD: ${{ secrets.KEYSTORE_STORE_PASSWORD }}
RELEASE_KEYSTORE: ${{ secrets.RELEASE_KEYSTORE }}
RELEASE_KEYSTORE_PASSPHRASE: ${{ secrets.RELEASE_KEYSTORE_PASSPHRASE }}
working-directory: android 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) 🚀. # Build and deploy with Fastlane (by default, to alpha track) 🚀.
# Naturally, promote_to_production only deploys. # Naturally, promote_to_production only deploys.
- run: bundle exec fastlane ${{ github.event.inputs.lane || 'alpha' }} - run: bundle exec fastlane ${{ github.event.inputs.lane || 'alpha' }}

View File

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

View File

@@ -21,6 +21,27 @@ platform :android do
gradle(task: "test") gradle(task: "test")
end 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" desc "Submit a new alpha build to Google Play"
lane :alpha do lane :alpha do
sh "flutter build appbundle -v" sh "flutter build appbundle -v"
@@ -28,6 +49,7 @@ platform :android do
track: 'alpha', track: 'alpha',
aab: '../build/app/outputs/bundle/release/app-release.aab', aab: '../build/app/outputs/bundle/release/app-release.aab',
json_key_data: ENV['PLAY_STORE_CREDENTIALS'], json_key_data: ENV['PLAY_STORE_CREDENTIALS'],
release_status: "draft",
) )
end end
@@ -36,7 +58,6 @@ platform :android do
upload_to_play_store( upload_to_play_store(
track: 'alpha', track: 'alpha',
track_promote_to: 'beta', track_promote_to: 'beta',
skip_upload_changelogs: true,
json_key_data: ENV['PLAY_STORE_CREDENTIALS'], json_key_data: ENV['PLAY_STORE_CREDENTIALS'],
) )
end end
@@ -48,6 +69,7 @@ platform :android do
track: 'beta', track: 'beta',
aab: '../build/app/outputs/bundle/release/app-release.aab', aab: '../build/app/outputs/bundle/release/app-release.aab',
json_key_data: ENV['PLAY_STORE_CREDENTIALS'], json_key_data: ENV['PLAY_STORE_CREDENTIALS'],
release_status: "draft",
) )
end end
@@ -56,7 +78,6 @@ platform :android do
upload_to_play_store( upload_to_play_store(
track: 'beta', track: 'beta',
track_promote_to: 'production', track_promote_to: 'production',
skip_upload_changelogs: true,
json_key_data: ENV['PLAY_STORE_CREDENTIALS'], json_key_data: ENV['PLAY_STORE_CREDENTIALS'],
) )
end end
@@ -68,6 +89,7 @@ platform :android do
track: 'production', track: 'production',
aab: '../build/app/outputs/bundle/release/app-release.aab', aab: '../build/app/outputs/bundle/release/app-release.aab',
json_key_data: ENV['PLAY_STORE_CREDENTIALS'], json_key_data: ENV['PLAY_STORE_CREDENTIALS'],
release_status: "draft",
) )
end end
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 (2.7.5):
- FMDB/standard (= 2.7.5) - FMDB/standard (= 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): - integration_test (0.0.1):
- Flutter - Flutter
- local_auth_ios (0.0.1): - local_auth_ios (0.0.1):
@@ -99,16 +101,17 @@ DEPENDENCIES:
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- fluttertoast (from `.symlinks/plugins/fluttertoast/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`) - integration_test (from `.symlinks/plugins/integration_test/ios`)
- local_auth_ios (from `.symlinks/plugins/local_auth_ios/ios`) - local_auth_ios (from `.symlinks/plugins/local_auth_ios/ios`)
- open_filex (from `.symlinks/plugins/open_filex/ios`) - open_filex (from `.symlinks/plugins/open_filex/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/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`) - pdfx (from `.symlinks/plugins/pdfx/ios`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`) - receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
- share_plus (from `.symlinks/plugins/share_plus/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`) - sqflite (from `.symlinks/plugins/sqflite/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
@@ -142,6 +145,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_native_splash/ios" :path: ".symlinks/plugins/flutter_native_splash/ios"
fluttertoast: fluttertoast:
:path: ".symlinks/plugins/fluttertoast/ios" :path: ".symlinks/plugins/fluttertoast/ios"
in_app_review:
:path: ".symlinks/plugins/in_app_review/ios"
integration_test: integration_test:
:path: ".symlinks/plugins/integration_test/ios" :path: ".symlinks/plugins/integration_test/ios"
local_auth_ios: local_auth_ios:
@@ -151,7 +156,7 @@ EXTERNAL SOURCES:
package_info_plus: package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios" :path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation: path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin" :path: ".symlinks/plugins/path_provider_foundation/ios"
pdfx: pdfx:
:path: ".symlinks/plugins/pdfx/ios" :path: ".symlinks/plugins/pdfx/ios"
permission_handler_apple: permission_handler_apple:
@@ -161,7 +166,7 @@ EXTERNAL SOURCES:
share_plus: share_plus:
:path: ".symlinks/plugins/share_plus/ios" :path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation: shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin" :path: ".symlinks/plugins/shared_preferences_foundation/ios"
sqflite: sqflite:
:path: ".symlinks/plugins/sqflite/ios" :path: ".symlinks/plugins/sqflite/ios"
url_launcher_ios: url_launcher_ios:
@@ -180,6 +185,7 @@ SPEC CHECKSUMS:
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
fluttertoast: eb263d302cc92e04176c053d2385237e9f43fad0 fluttertoast: eb263d302cc92e04176c053d2385237e9f43fad0
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
in_app_review: 318597b3a06c22bb46dc454d56828c85f444f99d
integration_test: a1e7d09bd98eca2fc37aefd79d4f41ad37bdbbe5 integration_test: a1e7d09bd98eca2fc37aefd79d4f41ad37bdbbe5
local_auth_ios: 0d333dde7780f669e66f19d2ff6005f3ea84008d local_auth_ios: 0d333dde7780f669e66f19d2ff6005f3ea84008d
open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4 open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4
@@ -195,7 +201,7 @@ SPEC CHECKSUMS:
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904 sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780 SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: ae1517e5e344f5544fb090b079e11f399dfbe4d2 url_launcher_ios: fb12c43172927bb5cf75aeebd073f883801f1993
WeScan: fed582f6c38014d529afb5aa9ffd1bad38fc72b7 WeScan: fed582f6c38014d529afb5aa9ffd1bad38fc72b7
PODFILE CHECKSUM: 7daa35cc908d9fba025075df27cb57a1ba1ebf13 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'; import 'package:paperless_api/paperless_api.dart';
class PaperlessServerInformationState { class ServerInformationState {
final bool isLoaded; final bool isLoaded;
final PaperlessServerInformationModel? information; final PaperlessServerInformationModel? information;
PaperlessServerInformationState({ ServerInformationState({
this.isLoaded = false, this.isLoaded = false,
this.information, 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); _deleted.add(deleted);
} }
void subscribe( void addListener(
dynamic subscriber, { Object subscriber, {
DocumentChangedCallback? onUpdated, DocumentChangedCallback? onUpdated,
DocumentChangedCallback? onDeleted, DocumentChangedCallback? onDeleted,
}) { }) {
@@ -41,7 +41,7 @@ class DocumentChangedNotifier {
); );
} }
void unsubscribe(dynamic subscriber) { void removeListener(Object subscriber) {
_subscribers[subscriber]?.forEach((element) { _subscribers[subscriber]?.forEach((element) {
element.cancel(); 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 'dart:async';
import 'package:paperless_mobile/core/repository/base_repository.dart';
import 'package:paperless_mobile/core/repository/state/indexed_repository_state.dart';
abstract class LabelRepository<T extends Label> extends BaseRepository<T> { import 'package:flutter/widgets.dart';
LabelRepository(IndexedRepositoryState<T> initial) : super(initial); 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 'dart:async';
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';
abstract class SavedViewRepository extends BaseRepository<SavedView> { import 'package:hydrated_bloc/hydrated_bloc.dart';
SavedViewRepository(super.initialState); 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 'dart:io';
import 'package:dio/dio.dart'; 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:paperless_mobile/features/login/model/client_certificate.dart';
import 'package:pretty_dio_logger/pretty_dio_logger.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 { class SessionManager {
final Dio client; final Dio _client;
final List<Interceptor> interceptors; PaperlessServerInformationModel _serverInformation;
PaperlessServerInformationModel serverInformation;
SessionManager([this.interceptors = const []]) Dio get client => _client;
: client = _initDio(interceptors),
serverInformation = PaperlessServerInformationModel(); SessionManager([List<Interceptor> interceptors = const []])
: _client = _initDio(interceptors),
_serverInformation = PaperlessServerInformationModel();
static Dio _initDio(List<Interceptor> interceptors) { static Dio _initDio(List<Interceptor> interceptors) {
//en- and decoded by utf8 by default //en- and decoded by utf8 by default
@@ -63,8 +66,7 @@ class SessionManager {
); );
final adapter = IOHttpClientAdapter() final adapter = IOHttpClientAdapter()
..onHttpClientCreate = (client) => HttpClient(context: context) ..onHttpClientCreate = (client) => HttpClient(context: context)
..badCertificateCallback = ..badCertificateCallback = (X509Certificate cert, String host, int port) => true;
(X509Certificate cert, String host, int port) => true;
client.httpClientAdapter = adapter; client.httpClientAdapter = adapter;
} }
@@ -80,7 +82,7 @@ class SessionManager {
} }
if (serverInformation != null) { if (serverInformation != null) {
this.serverInformation = serverInformation; _serverInformation = serverInformation;
} }
} }
@@ -88,6 +90,6 @@ class SessionManager {
client.httpClientAdapter = IOHttpClientAdapter(); client.httpClientAdapter = IOHttpClientAdapter();
client.options.baseUrl = ''; client.options.baseUrl = '';
client.options.headers.remove(HttpHeaders.authorizationHeader); client.options.headers.remove(HttpHeaders.authorizationHeader);
serverInformation = PaperlessServerInformationModel(); _serverInformation = PaperlessServerInformationModel();
} }
} }

View File

@@ -32,8 +32,7 @@ class ConnectivityStatusServiceImpl implements ConnectivityStatusService {
@override @override
Future<bool> isConnectedToInternet() async { Future<bool> isConnectedToInternet() async {
return _hasActiveInternetConnection( return _hasActiveInternetConnection(await (Connectivity().checkConnectivity()));
await (Connectivity().checkConnectivity()));
} }
@override @override
@@ -72,11 +71,10 @@ class ConnectivityStatusServiceImpl implements ConnectivityStatusService {
return ReachabilityStatus.unknown; return ReachabilityStatus.unknown;
} }
try { try {
SessionManager manager = SessionManager manager = SessionManager([ServerReachabilityErrorInterceptor()])
SessionManager([ServerReachabilityErrorInterceptor()]) ..updateSettings(clientCertificate: clientCertificate)
..updateSettings(clientCertificate: clientCertificate) ..client.options.connectTimeout = const Duration(seconds: 5)
..client.options.connectTimeout = const Duration(seconds: 5) ..client.options.receiveTimeout = const Duration(seconds: 5);
..client.options.receiveTimeout = const Duration(seconds: 5);
final response = await manager.client.get('$serverAddress/api/'); final response = await manager.client.get('$serverAddress/api/');
if (response.statusCode == 200) { if (response.statusCode == 200) {
@@ -84,8 +82,7 @@ class ConnectivityStatusServiceImpl implements ConnectivityStatusService {
} }
return ReachabilityStatus.notReachable; return ReachabilityStatus.notReachable;
} on DioError catch (error) { } on DioError catch (error) {
if (error.type == DioErrorType.unknown && if (error.type == DioErrorType.unknown && error.error is ReachabilityStatus) {
error.error is ReachabilityStatus) {
return error.error as ReachabilityStatus; return error.error as ReachabilityStatus;
} }
} on TlsException catch (error) { } 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/core/model/document_processing_status.dart';
import 'package:paperless_mobile/features/login/model/authentication_information.dart'; import 'package:paperless_mobile/features/login/model/authentication_information.dart';
import 'package:paperless_mobile/constants.dart'; import 'package:paperless_mobile/constants.dart';
import 'package:paperless_mobile/core/database/tables/user_credentials.dart';
import 'package:web_socket_channel/io.dart'; import 'package:web_socket_channel/io.dart';
abstract class StatusService { abstract class StatusService {
Future<void> startListeningBeforeDocumentUpload(String httpUrl, Future<void> startListeningBeforeDocumentUpload(
AuthenticationInformation credentials, String documentFileName); String httpUrl, UserCredentials credentials, String documentFileName);
} }
class WebSocketStatusService implements StatusService { class WebSocketStatusService implements StatusService {
@@ -25,7 +26,7 @@ class WebSocketStatusService implements StatusService {
@override @override
Future<void> startListeningBeforeDocumentUpload( Future<void> startListeningBeforeDocumentUpload(
String httpUrl, String httpUrl,
AuthenticationInformation credentials, UserCredentials credentials,
String documentFileName, String documentFileName,
) async { ) async {
// socket = await WebSocket.connect( // socket = await WebSocket.connect(
@@ -57,7 +58,7 @@ class LongPollingStatusService implements StatusService {
@override @override
Future<void> startListeningBeforeDocumentUpload( Future<void> startListeningBeforeDocumentUpload(
String httpUrl, String httpUrl,
AuthenticationInformation credentials, UserCredentials credentials,
String documentFileName, String documentFileName,
) async { ) async {
// final today = DateTime.now(); // 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/services.dart';
import 'package:flutter_form_builder/flutter_form_builder.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/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'; import 'package:paperless_mobile/extensions/flutter_extensions.dart';
class ErrorReportPage extends StatefulWidget { 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); Navigator.pop(context, true);
}, },
), ),
TextButton( const DialogCancelButton(),
child: const Text('Cancel'),
onPressed: () => Navigator.pop(context, false),
),
], ],
), ),
) ?? ) ??

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_form_builder/flutter_form_builder.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_api/paperless_api.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
@@ -56,7 +56,9 @@ class _FormBuilderRelativeDateRangePickerState
inputFormatters: [ inputFormatters: [
FilteringTextInputFormatter.digitsOnly, FilteringTextInputFormatter.digitsOnly,
], ],
validator: FormBuilderValidators.numeric(), // validator: (value) { //TODO: Check if this is required
// do numeric validation
// },
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
onChanged: (value) { onChanged: (value) {
final parsed = int.tryParse(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/services.dart';
import 'package:flutter_colorpicker/flutter_colorpicker.dart'; import 'package:flutter_colorpicker/flutter_colorpicker.dart';
import 'package:flutter_form_builder/flutter_form_builder.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'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
extension on Color { extension on Color {
@@ -136,11 +138,12 @@ class FormBuilderColorPickerField extends FormBuilderField<Color> {
: LayoutBuilder( : LayoutBuilder(
key: ObjectKey(state.value), key: ObjectKey(state.value),
builder: (context, constraints) { builder: (context, constraints) {
return Icon( return Padding(
Icons.circle, padding: const EdgeInsets.all(8.0),
key: ObjectKey(state.value), child: CircleAvatar(
size: constraints.minHeight, key: ObjectKey(state.value),
color: state.value, backgroundColor: state.value,
),
); );
}, },
), ),
@@ -218,17 +221,11 @@ class FormBuilderColorPickerFieldState
return AlertDialog( return AlertDialog(
// title: null, //const Text('Pick a color!'), // title: null, //const Text('Pick a color!'),
content: SingleChildScrollView( content: _buildColorPicker(),
child: _buildColorPicker(),
),
actions: <Widget>[ actions: <Widget>[
TextButton( const DialogCancelButton(),
onPressed: () => Navigator.pop(context, false), DialogConfirmButton(
child: Text(materialLocalizations.cancel), label: S.of(context)!.ok,
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text(materialLocalizations.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:flutter/material.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.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}); const OfflineBanner({super.key});
@override @override

View File

@@ -36,3 +36,9 @@ extension DateHelpers on DateTime {
yesterday.year == year; 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_bloc/flutter_bloc.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:paperless_mobile/constants.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/core/widgets/paperless_logo.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.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/features/settings/view/settings_page.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.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), leading: const Icon(Icons.bug_report_outlined),
title: Text(S.of(context)!.reportABug), title: Text(S.of(context)!.reportABug),
onTap: () { onTap: () {
launchUrlString( launchUrlString('https://github.com/astubenbord/paperless-mobile/issues/new');
'https://github.com/astubenbord/paperless-mobile/issues/new');
}, },
), ),
ListTile( ListTile(
@@ -69,8 +68,8 @@ class AppDrawer extends StatelessWidget {
), ),
onTap: () => Navigator.of(context).push( onTap: () => Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (context) => BlocProvider.value( builder: (_) => BlocProvider.value(
value: context.read<ApplicationSettingsCubit>(), value: context.read<ServerInformationCubit>(),
child: const SettingsPage(), child: const SettingsPage(),
), ),
), ),

View File

@@ -10,8 +10,7 @@ class ApplicationIntroSlideshow extends StatefulWidget {
const ApplicationIntroSlideshow({super.key}); const ApplicationIntroSlideshow({super.key});
@override @override
State<ApplicationIntroSlideshow> createState() => State<ApplicationIntroSlideshow> createState() => _ApplicationIntroSlideshowState();
_ApplicationIntroSlideshowState();
} }
//TODO: INTL ALL //TODO: INTL ALL
@@ -28,7 +27,9 @@ class _ApplicationIntroSlideshowState extends State<ApplicationIntroSlideshow> {
showDoneButton: true, showDoneButton: true,
next: Text(S.of(context)!.next), next: Text(S.of(context)!.next),
done: Text(S.of(context)!.done), done: Text(S.of(context)!.done),
onDone: () => Navigator.pop(context), onDone: () {
Navigator.pop(context);
},
dotsDecorator: DotsDecorator( dotsDecorator: DotsDecorator(
color: Theme.of(context).colorScheme.onBackground, color: Theme.of(context).colorScheme.onBackground,
activeColor: Theme.of(context).colorScheme.primary, 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 'dart:io';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:open_filex/open_filex.dart'; import 'package:open_filex/open_filex.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/notifier/document_changed_notifier.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_description.dart';
import 'package:paperless_mobile/core/service/file_service.dart'; import 'package:paperless_mobile/core/service/file_service.dart';
import 'package:paperless_mobile/features/notifications/services/local_notification_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'; import 'package:share_plus/share_plus.dart';
part 'document_details_cubit.freezed.dart';
part 'document_details_state.dart'; part 'document_details_state.dart';
class DocumentDetailsCubit extends Cubit<DocumentDetailsState> { class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
final PaperlessDocumentsApi _api; final PaperlessDocumentsApi _api;
final DocumentChangedNotifier _notifier; final DocumentChangedNotifier _notifier;
final LocalNotificationService _notificationService; final LocalNotificationService _notificationService;
final LabelRepository _labelRepository;
final List<StreamSubscription> _subscriptions = [];
DocumentDetailsCubit( DocumentDetailsCubit(
this._api, this._api,
this._labelRepository,
this._notifier, this._notifier,
this._notificationService, { this._notificationService, {
required DocumentModel initialDocument, required DocumentModel initialDocument,
}) : super(DocumentDetailsState(document: initialDocument)) { }) : super(DocumentDetailsState(
_notifier.subscribe(this, onUpdated: replace); 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(); loadSuggestions();
loadMetaData(); loadMetaData();
} }
@@ -39,12 +54,16 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
Future<void> loadSuggestions() async { Future<void> loadSuggestions() async {
final suggestions = await _api.findSuggestions(state.document); final suggestions = await _api.findSuggestions(state.document);
emit(state.copyWith(suggestions: suggestions)); if (!isClosed) {
emit(state.copyWith(suggestions: suggestions));
}
} }
Future<void> loadMetaData() async { Future<void> loadMetaData() async {
final metaData = await _api.getMetaData(state.document); final metaData = await _api.getMetaData(state.document);
emit(state.copyWith(metaData: metaData)); if (!isClosed) {
emit(state.copyWith(metaData: metaData));
}
} }
Future<void> loadFullContent() async { Future<void> loadFullContent() async {
@@ -70,8 +89,8 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
_notifier.notifyUpdated(updatedDocument); _notifier.notifyUpdated(updatedDocument);
} else { } else {
final int autoAsn = await _api.findNextAsn(); final int autoAsn = await _api.findNextAsn();
final updatedDocument = await _api final updatedDocument =
.update(document.copyWith(archiveSerialNumber: () => autoAsn)); await _api.update(document.copyWith(archiveSerialNumber: () => autoAsn));
_notifier.notifyUpdated(updatedDocument); _notifier.notifyUpdated(updatedDocument);
} }
} }
@@ -82,8 +101,7 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
if (state.metaData == null) { if (state.metaData == null) {
await loadMetaData(); await loadMetaData();
} }
final desc = FileDescription.fromPath( final desc = FileDescription.fromPath(state.metaData!.mediaFilename.replaceAll("/", " "));
state.metaData!.mediaFilename.replaceAll("/", " "));
final fileName = "${desc.filename}.pdf"; final fileName = "${desc.filename}.pdf";
final file = File("${cacheDir.path}/$fileName"); final file = File("${cacheDir.path}/$fileName");
@@ -117,8 +135,7 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
await FileService.downloadsDirectory, await FileService.downloadsDirectory,
); );
final desc = FileDescription.fromPath( final desc = FileDescription.fromPath(
state.metaData!.mediaFilename state.metaData!.mediaFilename.replaceAll("/", " "), // Flatten directory structure
.replaceAll("/", " "), // Flatten directory structure
); );
if (!File(filePath).existsSync()) { if (!File(filePath).existsSync()) {
File(filePath).createSync(); File(filePath).createSync();
@@ -183,8 +200,7 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
String _buildDownloadFilePath(bool original, Directory dir) { String _buildDownloadFilePath(bool original, Directory dir) {
final description = FileDescription.fromPath( final description = FileDescription.fromPath(
state.metaData!.mediaFilename state.metaData!.mediaFilename.replaceAll("/", " "), // Flatten directory structure
.replaceAll("/", " "), // Flatten directory structure
); );
final extension = original ? description.extension : 'pdf'; final extension = original ? description.extension : 'pdf';
return "${dir.path}/${description.filename}.$extension"; return "${dir.path}/${description.filename}.$extension";
@@ -192,10 +208,8 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
@override @override
Future<void> close() async { Future<void> close() async {
for (final element in _subscriptions) { _labelRepository.removeListener(this);
await element.cancel(); _notifier.removeListener(this);
}
_notifier.unsubscribe(this);
await super.close(); 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'; part of 'document_details_cubit.dart';
class DocumentDetailsState with EquatableMixin { @freezed
final DocumentModel document; class DocumentDetailsState with _$DocumentDetailsState {
final DocumentMetaData? metaData; const factory DocumentDetailsState({
final bool isFullContentLoaded; required DocumentModel document,
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,
DocumentMetaData? metaData, DocumentMetaData? metaData,
}) { @Default(false) bool isFullContentLoaded,
return DocumentDetailsState( String? fullContent,
document: document ?? this.document, FieldSuggestions? suggestions,
suggestions: suggestions ?? this.suggestions, @Default({}) Map<int, Correspondent> correspondents,
isFullContentLoaded: isFullContentLoaded ?? this.isFullContentLoaded, @Default({}) Map<int, DocumentType> documentTypes,
fullContent: fullContent ?? this.fullContent, @Default({}) Map<int, Tag> tags,
metaData: metaData ?? this.metaData, @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_api/paperless_api.dart';
import 'package:paperless_mobile/core/bloc/connectivity_cubit.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/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/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.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'; 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> { class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
late Future<DocumentMetaData> _metaData; late Future<DocumentMetaData> _metaData;
static const double _itemSpacing = 24; static const double _itemSpacing = 24;
final _pagingScrollController = ScrollController();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -58,116 +60,104 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return WillPopScope( return WillPopScope(
onWillPop: () async { onWillPop: () async {
Navigator.of(context) Navigator.of(context).pop(context.read<DocumentDetailsCubit>().state.document);
.pop(context.read<DocumentDetailsCubit>().state.document);
return false; return false;
}, },
child: DefaultTabController( child: DefaultTabController(
length: 4, length: 4,
child: BlocListener<ConnectivityCubit, ConnectivityState>( child: BlocListener<ConnectivityCubit, ConnectivityState>(
listenWhen: (previous, current) => listenWhen: (previous, current) => !previous.isConnected && current.isConnected,
!previous.isConnected && current.isConnected,
listener: (context, state) { listener: (context, state) {
_loadMetaData(); _loadMetaData();
setState(() {}); setState(() {});
}, },
child: Scaffold( child: Scaffold(
extendBodyBehindAppBar: false, extendBodyBehindAppBar: false,
floatingActionButtonLocation: floatingActionButtonLocation: FloatingActionButtonLocation.endDocked,
FloatingActionButtonLocation.endDocked,
floatingActionButton: widget.allowEdit ? _buildEditButton() : null, floatingActionButton: widget.allowEdit ? _buildEditButton() : null,
bottomNavigationBar: _buildBottomAppBar(), bottomNavigationBar: _buildBottomAppBar(),
body: NestedScrollView( body: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) => [ headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverAppBar( SliverOverlapAbsorber(
title: Text(context handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
.watch<DocumentDetailsCubit>() sliver: SliverAppBar(
.state title: Text(context.watch<DocumentDetailsCubit>().state.document.title),
.document leading: const BackButton(),
.title), pinned: true,
leading: const BackButton(), forceElevated: innerBoxIsScrolled,
pinned: true, collapsedHeight: kToolbarHeight,
forceElevated: innerBoxIsScrolled, expandedHeight: 250.0,
collapsedHeight: kToolbarHeight, flexibleSpace: FlexibleSpaceBar(
expandedHeight: 250.0, background: Stack(
flexibleSpace: FlexibleSpaceBar( alignment: Alignment.topCenter,
background: Stack( children: [
alignment: Alignment.topCenter, BlocBuilder<DocumentDetailsCubit, DocumentDetailsState>(
children: [ builder: (context, state) => Positioned.fill(
BlocBuilder<DocumentDetailsCubit, DocumentDetailsState>( child: DocumentPreview(
builder: (context, state) => Positioned.fill( document: state.document,
child: DocumentPreview( fit: BoxFit.cover,
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,
), ),
), ),
), ),
), 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(
bottom: ColoredTabBar( tabBar: TabBar(
tabBar: TabBar( isScrollable: true,
isScrollable: true, tabs: [
tabs: [ Tab(
Tab( child: Text(
child: Text( S.of(context)!.overview,
S.of(context)!.overview, style: TextStyle(
style: TextStyle( color: Theme.of(context).colorScheme.onPrimaryContainer,
color: Theme.of(context) ),
.colorScheme
.onPrimaryContainer,
), ),
), ),
), Tab(
Tab( child: Text(
child: Text( S.of(context)!.content,
S.of(context)!.content, style: TextStyle(
style: TextStyle( color: Theme.of(context).colorScheme.onPrimaryContainer,
color: Theme.of(context) ),
.colorScheme
.onPrimaryContainer,
), ),
), ),
), Tab(
Tab( child: Text(
child: Text( S.of(context)!.metaData,
S.of(context)!.metaData, style: TextStyle(
style: TextStyle( color: Theme.of(context).colorScheme.onPrimaryContainer,
color: Theme.of(context) ),
.colorScheme
.onPrimaryContainer,
), ),
), ),
), Tab(
Tab( child: Text(
child: Text( S.of(context)!.similarDocuments,
S.of(context)!.similarDocuments, style: TextStyle(
style: TextStyle( color: Theme.of(context).colorScheme.onPrimaryContainer,
color: Theme.of(context) ),
.colorScheme
.onPrimaryContainer,
), ),
), ),
), ],
], ),
), ),
), ),
), ),
@@ -176,29 +166,71 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
builder: (context, state) { builder: (context, state) {
return BlocProvider( return BlocProvider(
create: (context) => SimilarDocumentsCubit( create: (context) => SimilarDocumentsCubit(
context.read(),
context.read(), context.read(),
context.read(), context.read(),
documentId: state.document.id, documentId: state.document.id,
), ),
child: TabBarView( child: Padding(
children: [ padding: const EdgeInsets.symmetric(
DocumentOverviewWidget( vertical: 16,
document: state.document, horizontal: 16,
itemSpacing: _itemSpacing, ),
queryString: widget.titleAndContentQueryString, child: TabBarView(
), children: [
DocumentContentWidget( CustomScrollView(
isFullContentLoaded: state.isFullContentLoaded, slivers: [
document: state.document, SliverOverlapInjector(
fullContent: state.fullContent, handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
queryString: widget.titleAndContentQueryString, ),
), DocumentOverviewWidget(
DocumentMetaDataWidget( document: state.document,
document: state.document, itemSpacing: _itemSpacing,
itemSpacing: _itemSpacing, queryString: widget.titleAndContentQueryString,
), availableCorrespondents: state.correspondents,
const SimilarDocumentsView(), 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() { Widget _buildEditButton() {
return BlocBuilder<DocumentDetailsCubit, DocumentDetailsState>( return BlocBuilder<DocumentDetailsCubit, DocumentDetailsState>(
builder: (context, state) { builder: (context, state) {
final _filteredSuggestions = // final _filteredSuggestions =
state.suggestions.documentDifference(state.document); // state.suggestions?.documentDifference(state.document);
return BlocBuilder<ConnectivityCubit, ConnectivityState>( return BlocBuilder<ConnectivityCubit, ConnectivityState>(
builder: (context, connectivityState) { builder: (context, connectivityState) {
if (!connectivityState.isConnected) { if (!connectivityState.isConnected) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
return b.Badge( return Tooltip(
position: b.BadgePosition.topEnd(top: -12, end: -6), message: S.of(context)!.editDocumentTooltip,
showBadge: _filteredSuggestions.hasSuggestions, preferBelow: false,
child: Tooltip( verticalOffset: 40,
message: S.of(context)!.editDocumentTooltip, child: FloatingActionButton(
preferBelow: false, child: const Icon(Icons.edit),
verticalOffset: 40, onPressed: () => _onEdit(state.document),
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( IconButton(
tooltip: S.of(context)!.deleteDocumentTooltip, tooltip: S.of(context)!.deleteDocumentTooltip,
icon: const Icon(Icons.delete), icon: const Icon(Icons.delete),
onPressed: widget.allowEdit && isConnected onPressed:
? () => _onDelete(state.document) widget.allowEdit && isConnected ? () => _onDelete(state.document) : null,
: null,
).paddedSymmetrically(horizontal: 4), ).paddedSymmetrically(horizontal: 4),
DocumentDownloadButton( DocumentDownloadButton(
document: state.document, document: state.document,
@@ -271,8 +291,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
IconButton( IconButton(
tooltip: S.of(context)!.previewTooltip, tooltip: S.of(context)!.previewTooltip,
icon: const Icon(Icons.visibility), icon: const Icon(Icons.visibility),
onPressed: onPressed: isConnected ? () => _onOpen(state.document) : null,
isConnected ? () => _onOpen(state.document) : null,
).paddedOnly(right: 4.0), ).paddedOnly(right: 4.0),
IconButton( IconButton(
tooltip: S.of(context)!.openInSystemViewer, tooltip: S.of(context)!.openInSystemViewer,
@@ -299,13 +318,10 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
providers: [ providers: [
BlocProvider.value( BlocProvider.value(
value: DocumentEditCubit( value: DocumentEditCubit(
document, context.read(),
documentsApi: context.read(), context.read(),
correspondentRepository: context.read(), context.read(),
documentTypeRepository: context.read(), document: document,
storagePathRepository: context.read(),
tagRepository: context.read(),
notifier: context.read(),
), ),
), ),
BlocProvider<DocumentDetailsCubit>.value( BlocProvider<DocumentDetailsCubit>.value(
@@ -313,8 +329,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
), ),
], ],
child: BlocListener<DocumentEditCubit, DocumentEditState>( child: BlocListener<DocumentEditCubit, DocumentEditState>(
listenWhen: (previous, current) => listenWhen: (previous, current) => previous.document != current.document,
previous.document != current.document,
listener: (context, state) { listener: (context, state) {
cubit.replace(state.document); cubit.replace(state.document);
}, },
@@ -334,8 +349,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
} }
void _onOpenFileInSystemViewer() async { void _onOpenFileInSystemViewer() async {
final status = final status = await context.read<DocumentDetailsCubit>().openDocumentInSystemViewer();
await context.read<DocumentDetailsCubit>().openDocumentInSystemViewer();
if (status == ResultType.done) return; if (status == ResultType.done) return;
if (status == ResultType.noAppToOpen) { if (status == ResultType.noAppToOpen) {
showGenericError(context, S.of(context)!.noAppToDisplayPDFFilesFound); showGenericError(context, S.of(context)!.noAppToDisplayPDFFilesFound);
@@ -344,16 +358,14 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
showGenericError(context, translateError(context, ErrorCode.unknown)); showGenericError(context, translateError(context, ErrorCode.unknown));
} }
if (status == ResultType.permissionDenied) { if (status == ResultType.permissionDenied) {
showGenericError( showGenericError(context, S.of(context)!.couldNotOpenFilePermissionDenied);
context, S.of(context)!.couldNotOpenFilePermissionDenied);
} }
} }
void _onDelete(DocumentModel document) async { void _onDelete(DocumentModel document) async {
final delete = await showDialog( final delete = await showDialog(
context: context, context: context,
builder: (context) => builder: (context) => DeleteDocumentConfirmationDialog(document: document),
DeleteDocumentConfirmationDialog(document: document),
) ?? ) ??
false; false;
if (delete) { if (delete) {
@@ -373,8 +385,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (context) => DocumentView( builder: (context) => DocumentView(
documentBytes: documentBytes: context.read<PaperlessDocumentsApi>().getPreview(document.id),
context.read<PaperlessDocumentsApi>().getPreview(document.id),
), ),
), ),
); );

View File

@@ -20,11 +20,7 @@ class DocumentContentWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SingleChildScrollView( return SliverToBoxAdapter(
padding: const EdgeInsets.symmetric(
vertical: 16,
horizontal: 16,
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.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/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/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart';
@@ -43,9 +43,8 @@ class _DocumentDownloadButtonState extends State<DocumentDownloadButton> {
width: 16, width: 16,
) )
: const Icon(Icons.download), : const Icon(Icons.download),
onPressed: widget.document != null && widget.enabled onPressed:
? () => _onDownload(widget.document!) widget.document != null && widget.enabled ? () => _onDownload(widget.document!) : null,
: null,
).paddedOnly(right: 4); ).paddedOnly(right: 4);
} }
@@ -69,10 +68,7 @@ class _DocumentDownloadButtonState extends State<DocumentDownloadButton> {
setState(() => _isDownloadPending = true); setState(() => _isDownloadPending = true);
await context.read<DocumentDetailsCubit>().downloadDocument( await context.read<DocumentDetailsCubit>().downloadDocument(
downloadOriginal: downloadOriginal, downloadOriginal: downloadOriginal,
locale: context locale: context.read<GlobalSettings>().preferredLocaleSubtag,
.read<ApplicationSettingsCubit>()
.state
.preferredLocaleSubtag,
); );
// showSnackBar(context, S.of(context)!.documentSuccessfullyDownloaded); // showSnackBar(context, S.of(context)!.documentSuccessfullyDownloaded);
} on PaperlessServerException catch (error, stackTrace) { } on PaperlessServerException catch (error, stackTrace) {

View File

@@ -31,50 +31,43 @@ class _DocumentMetaDataWidgetState extends State<DocumentMetaDataWidget> {
if (state.metaData == null) { if (state.metaData == null) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
return SingleChildScrollView( return SliverList(
child: Padding( delegate: SliverChildListDelegate(
padding: const EdgeInsets.symmetric( [
vertical: 16, ArchiveSerialNumberField(
horizontal: 16, document: widget.document,
), ).paddedOnly(bottom: widget.itemSpacing),
child: Column( DetailsItem.text(
crossAxisAlignment: CrossAxisAlignment.start, DateFormat().format(widget.document.modified),
children: [ context: context,
ArchiveSerialNumberField( label: S.of(context)!.modifiedAt,
document: widget.document, ).paddedOnly(bottom: widget.itemSpacing),
).paddedOnly(bottom: widget.itemSpacing), DetailsItem.text(
DetailsItem.text( DateFormat().format(widget.document.added),
DateFormat().format(widget.document.modified), context: context,
context: context, label: S.of(context)!.addedAt,
label: S.of(context)!.modifiedAt, ).paddedOnly(bottom: widget.itemSpacing),
).paddedOnly(bottom: widget.itemSpacing), DetailsItem.text(
DetailsItem.text( state.metaData!.mediaFilename,
DateFormat().format(widget.document.added), context: context,
context: context, label: S.of(context)!.mediaFilename,
label: S.of(context)!.addedAt, ).paddedOnly(bottom: widget.itemSpacing),
).paddedOnly(bottom: widget.itemSpacing), DetailsItem.text(
DetailsItem.text( state.metaData!.originalChecksum,
state.metaData!.mediaFilename, context: context,
context: context, label: S.of(context)!.originalMD5Checksum,
label: S.of(context)!.mediaFilename, ).paddedOnly(bottom: widget.itemSpacing),
).paddedOnly(bottom: widget.itemSpacing), DetailsItem.text(
DetailsItem.text( formatBytes(state.metaData!.originalSize, 2),
state.metaData!.originalChecksum, context: context,
context: context, label: S.of(context)!.originalFileSize,
label: S.of(context)!.originalMD5Checksum, ).paddedOnly(bottom: widget.itemSpacing),
).paddedOnly(bottom: widget.itemSpacing), DetailsItem.text(
DetailsItem.text( state.metaData!.originalMimeType,
formatBytes(state.metaData!.originalSize, 2), context: context,
context: context, label: S.of(context)!.originalMIMEType,
label: S.of(context)!.originalFileSize, ).paddedOnly(bottom: widget.itemSpacing),
).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 { class DocumentOverviewWidget extends StatelessWidget {
final DocumentModel document; 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 String? queryString;
final double itemSpacing; final double itemSpacing;
const DocumentOverviewWidget({ const DocumentOverviewWidget({
@@ -18,72 +22,74 @@ class DocumentOverviewWidget extends StatelessWidget {
required this.document, required this.document,
this.queryString, this.queryString,
required this.itemSpacing, required this.itemSpacing,
required this.availableCorrespondents,
required this.availableDocumentTypes,
required this.availableTags,
required this.availableStoragePaths,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListView( return SliverList(
padding: const EdgeInsets.symmetric( delegate: SliverChildListDelegate(
vertical: 16, [
horizontal: 16, DetailsItem(
), label: S.of(context)!.title,
children: [ content: HighlightedText(
DetailsItem( text: document.title,
label: S.of(context)!.title, highlights: queryString?.split(" ") ?? [],
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>(
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
id: document.documentType,
), ),
).paddedOnly(bottom: itemSpacing), ).paddedOnly(bottom: itemSpacing),
), DetailsItem.text(
Visibility( DateFormat.yMMMMd().format(document.created),
visible: document.correspondent != null, context: context,
child: DetailsItem( label: S.of(context)!.createdAt,
label: S.of(context)!.correspondent,
content: LabelText<Correspondent>(
style: Theme.of(context).textTheme.bodyLarge,
id: document.correspondent,
),
).paddedOnly(bottom: itemSpacing), ).paddedOnly(bottom: itemSpacing),
), Visibility(
Visibility( visible: document.documentType != null,
visible: document.storagePath != null, child: DetailsItem(
child: DetailsItem( label: S.of(context)!.documentType,
label: S.of(context)!.storagePath, content: LabelText<DocumentType>(
content: StoragePathWidget( style: Theme.of(context).textTheme.bodyLarge,
pathId: document.storagePath, label: availableDocumentTypes[document.documentType],
),
).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,
), ),
), ).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 'dart:async';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:equatable/equatable.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart';
part 'document_edit_state.dart'; part 'document_edit_state.dart';
part 'document_edit_cubit.freezed.dart';
class DocumentEditCubit extends Cubit<DocumentEditState> { class DocumentEditCubit extends Cubit<DocumentEditState> {
final DocumentModel _initialDocument; final DocumentModel _initialDocument;
final PaperlessDocumentsApi _docsApi; final PaperlessDocumentsApi _docsApi;
final LabelRepository _labelRepository;
final DocumentChangedNotifier _notifier; final DocumentChangedNotifier _notifier;
final LabelRepository<Correspondent> _correspondentRepository;
final LabelRepository<DocumentType> _documentTypeRepository;
final LabelRepository<StoragePath> _storagePathRepository;
final LabelRepository<Tag> _tagRepository;
final List<StreamSubscription> _subscriptions = [];
DocumentEditCubit( DocumentEditCubit(
DocumentModel document, { this._labelRepository,
required PaperlessDocumentsApi documentsApi, this._docsApi,
required LabelRepository<Correspondent> correspondentRepository, this._notifier, {
required LabelRepository<DocumentType> documentTypeRepository, required DocumentModel document,
required LabelRepository<StoragePath> storagePathRepository,
required LabelRepository<Tag> tagRepository,
required DocumentChangedNotifier notifier,
}) : _initialDocument = document, }) : _initialDocument = document,
_docsApi = documentsApi, super(DocumentEditState(document: document)) {
_correspondentRepository = correspondentRepository, _notifier.addListener(this, onUpdated: replace);
_documentTypeRepository = documentTypeRepository, _labelRepository.addListener(
_storagePathRepository = storagePathRepository, this,
_tagRepository = tagRepository, onChanged: (labels) => emit(state.copyWith(
_notifier = notifier, correspondents: labels.correspondents,
super( documentTypes: labels.documentTypes,
DocumentEditState( storagePaths: labels.storagePaths,
document: document, tags: labels.tags,
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)),
),
); );
} }
@@ -70,20 +40,20 @@ class DocumentEditCubit extends Cubit<DocumentEditState> {
// Reload changed labels (documentCount property changes with removal/add) // Reload changed labels (documentCount property changes with removal/add)
if (document.documentType != _initialDocument.documentType) { if (document.documentType != _initialDocument.documentType) {
_documentTypeRepository _labelRepository.findDocumentType(
.find((document.documentType ?? _initialDocument.documentType)!); (document.documentType ?? _initialDocument.documentType)!);
} }
if (document.correspondent != _initialDocument.correspondent) { if (document.correspondent != _initialDocument.correspondent) {
_correspondentRepository _labelRepository.findCorrespondent(
.find((document.correspondent ?? _initialDocument.correspondent)!); (document.correspondent ?? _initialDocument.correspondent)!);
} }
if (document.storagePath != _initialDocument.storagePath) { if (document.storagePath != _initialDocument.storagePath) {
_storagePathRepository _labelRepository.findStoragePath(
.find((document.storagePath ?? _initialDocument.storagePath)!); (document.storagePath ?? _initialDocument.storagePath)!);
} }
if (!const DeepCollectionEquality.unordered() if (!const DeepCollectionEquality.unordered()
.equals(document.tags, _initialDocument.tags)) { .equals(document.tags, _initialDocument.tags)) {
_tagRepository.findAll(document.tags); _labelRepository.findAllTags(document.tags);
} }
} }
@@ -93,10 +63,8 @@ class DocumentEditCubit extends Cubit<DocumentEditState> {
@override @override
Future<void> close() { Future<void> close() {
for (final sub in _subscriptions) { _notifier.removeListener(this);
sub.cancel(); _labelRepository.removeListener(this);
}
_notifier.unsubscribe(this);
return super.close(); 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'; part of 'document_edit_cubit.dart';
class DocumentEditState extends Equatable { @freezed
final DocumentModel document; class DocumentEditState with _$DocumentEditState {
const factory DocumentEditState({
final Map<int, Correspondent> correspondents; required DocumentModel document,
final Map<int, DocumentType> documentTypes; @Default({}) Map<int, Correspondent> correspondents,
final Map<int, StoragePath> storagePaths; @Default({}) Map<int, DocumentType> documentTypes,
final Map<int, Tag> tags; @Default({}) Map<int, StoragePath> storagePaths,
@Default({}) Map<int, Tag> tags,
const DocumentEditState({ }) = _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,
);
}
} }

View File

@@ -1,10 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart';
import 'package:form_builder_validators/form_builder_validators.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/label_repository.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_document_type_page.dart';
import 'package:paperless_mobile/features/edit_label/view/impl/add_storage_path_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/tags/view/widgets/tags_form_field.dart';
import 'package:paperless_mobile/features/labels/view/widgets/label_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/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart';
class DocumentEditPage extends StatefulWidget { class DocumentEditPage extends StatefulWidget {
final FieldSuggestions suggestions; final FieldSuggestions? suggestions;
const DocumentEditPage({ const DocumentEditPage({
Key? key, Key? key,
required this.suggestions, required this.suggestions,
@@ -43,13 +45,13 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
final GlobalKey<FormBuilderState> _formKey = GlobalKey(); final GlobalKey<FormBuilderState> _formKey = GlobalKey();
bool _isSubmitLoading = false; bool _isSubmitLoading = false;
late final FieldSuggestions _filteredSuggestions; late final FieldSuggestions? _filteredSuggestions;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_filteredSuggestions = widget.suggestions _filteredSuggestions =
.documentDifference(context.read<DocumentEditCubit>().state.document); widget.suggestions?.documentDifference(context.read<DocumentEditCubit>().state.document);
} }
@override @override
@@ -93,69 +95,137 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
ListView( ListView(
children: [ children: [
_buildTitleFormField(state.document.title).padded(), _buildTitleFormField(state.document.title).padded(),
_buildCreatedAtFormField(state.document.created) _buildCreatedAtFormField(state.document.created).padded(),
.padded(), // Correspondent form field
_buildCorrespondentFormField( Column(
state.document.correspondent, children: [
state.correspondents, LabelFormField<Correspondent>(
).padded(), showAnyAssignedOption: false,
_buildDocumentTypeFormField( showNotAssignedOption: false,
state.document.documentType, addLabelPageBuilder: (initialValue) => RepositoryProvider.value(
state.documentTypes, value: context.read<LabelRepository>(),
).padded(), child: AddCorrespondentPage(
_buildStoragePathFormField( initialName: initialValue,
state.document.storagePath, ),
state.storagePaths, ),
).padded(), addLabelText: S.of(context)!.addCorrespondent,
TagFormField( labelText: S.of(context)!.correspondent,
initialValue: IdsTagsQuery.included( options: context.watch<DocumentEditCubit>().state.correspondents,
state.document.tags.toList()), initialValue: state.document.correspondent != null
notAssignedSelectable: false, ? IdQueryParameter.fromId(state.document.correspondent!)
anyAssignedSelectable: false, : const IdQueryParameter.unset(),
excludeAllowed: false, name: fkCorrespondent,
name: fkTags, prefixIcon: const Icon(Icons.person_outlined),
selectableOptions: state.tags, ),
suggestions: _filteredSuggestions.tags if (_filteredSuggestions?.hasSuggestedCorrespondents ?? false)
.toSet() _buildSuggestionsSkeleton<int>(
.difference(state.document.tags.toSet()) suggestions: _filteredSuggestions!.correspondents,
.isNotEmpty itemBuilder: (context, itemData) => ActionChip(
? _buildSuggestionsSkeleton<int>( label: Text(state.correspondents[itemData]!.name),
suggestions: _filteredSuggestions.tags, onPressed: () {
itemBuilder: (context, itemData) { _formKey.currentState?.fields[fkCorrespondent]?.didChange(
final tag = state.tags[itemData]!; IdQueryParameter.fromId(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})));
}
},
); );
}, },
) ),
: null, ),
],
).padded(), ).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 // Prevent tags from being hidden by fab
const SizedBox(height: 64), 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 { Future<void> _onSubmit(DocumentModel document) async {
if (_formKey.currentState?.saveAndValidate() ?? false) { if (_formKey.currentState?.saveAndValidate() ?? false) {
final values = _formKey.currentState!.value; final values = _formKey.currentState!.value;
var mergedDocument = document.copyWith( var mergedDocument = document.copyWith(
title: values[fkTitle], title: values[fkTitle],
created: values[fkCreatedDate], created: values[fkCreatedDate],
documentType: () => (values[fkDocumentType] as IdQueryParameter).id, documentType: () => (values[fkDocumentType] as SetIdQueryParameter).id,
correspondent: () => (values[fkCorrespondent] as IdQueryParameter).id, correspondent: () => (values[fkCorrespondent] as SetIdQueryParameter).id,
storagePath: () => (values[fkStoragePath] as IdQueryParameter).id, storagePath: () => (values[fkStoragePath] as SetIdQueryParameter).id,
tags: (values[fkTags] as IdsTagsQuery).includedIds, tags: (values[fkTags] as IdsTagsQuery).include,
content: values[fkContent]); content: values[fkContent],
);
setState(() { setState(() {
_isSubmitLoading = true; _isSubmitLoading = true;
}); });
@@ -303,7 +287,12 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
Widget _buildTitleFormField(String? initialTitle) { Widget _buildTitleFormField(String? initialTitle) {
return FormBuilderTextField( return FormBuilderTextField(
name: fkTitle, name: fkTitle,
validator: FormBuilderValidators.required(), validator: (value) {
if (value?.trim().isEmpty ?? true) {
return S.of(context)!.thisFieldIsRequired;
}
return null;
},
decoration: InputDecoration( decoration: InputDecoration(
label: Text(S.of(context)!.title), label: Text(S.of(context)!.title),
), ),
@@ -326,13 +315,12 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
format: DateFormat.yMMMMd(), format: DateFormat.yMMMMd(),
initialEntryMode: DatePickerEntryMode.calendar, initialEntryMode: DatePickerEntryMode.calendar,
), ),
if (_filteredSuggestions.hasSuggestedDates) if (_filteredSuggestions?.hasSuggestedDates ?? false)
_buildSuggestionsSkeleton<DateTime>( _buildSuggestionsSkeleton<DateTime>(
suggestions: _filteredSuggestions.dates, suggestions: _filteredSuggestions!.dates,
itemBuilder: (context, itemData) => ActionChip( itemBuilder: (context, itemData) => ActionChip(
label: Text(DateFormat.yMMMd().format(itemData)), label: Text(DateFormat.yMMMd().format(itemData)),
onPressed: () => _formKey.currentState?.fields[fkCreatedDate] onPressed: () => _formKey.currentState?.fields[fkCreatedDate]?.didChange(itemData),
?.didChange(itemData),
), ),
), ),
], ],
@@ -361,11 +349,63 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
itemBuilder: (context, index) => ColoredChipWrapper( itemBuilder: (context, index) => ColoredChipWrapper(
child: itemBuilder(context, suggestions.elementAt(index)), child: itemBuilder(context, suggestions.elementAt(index)),
), ),
separatorBuilder: (BuildContext context, int index) => separatorBuilder: (BuildContext context, int index) => const SizedBox(width: 4.0),
const SizedBox(width: 4.0),
), ),
), ),
], ],
).padded(); ).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/delegate/customizable_sliver_persistent_header_delegate.dart';
import 'package:paperless_mobile/core/global/constants.dart'; import 'package:paperless_mobile/core/global/constants.dart';
import 'package:paperless_mobile/core/repository/label_repository.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_description.dart';
import 'package:paperless_mobile/core/service/file_service.dart'; import 'package:paperless_mobile/core/service/file_service.dart';
import 'package:paperless_mobile/features/app_drawer/view/app_drawer.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>( final uploadResult = await Navigator.of(context).push<DocumentUploadResult>(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => LabelRepositoriesProvider( builder: (_) => BlocProvider(
child: BlocProvider( create: (context) => DocumentUploadCubit(
create: (context) => DocumentUploadCubit( context.read(),
documentApi: context.read<PaperlessDocumentsApi>(), context.read(),
correspondentRepository: ),
context.read<LabelRepository<Correspondent>>(), child: DocumentUploadPreparationPage(
documentTypeRepository: fileBytes: file.bytes,
context.read<LabelRepository<DocumentType>>(), fileExtension: file.extension,
tagRepository: context.read<LabelRepository<Tag>>(),
),
child: DocumentUploadPreparationPage(
fileBytes: file.bytes,
fileExtension: file.extension,
),
), ),
), ),
), ),
@@ -316,22 +309,16 @@ class _ScannerPageState extends State<ScannerPage>
} }
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => LabelRepositoriesProvider( builder: (_) => BlocProvider(
child: BlocProvider( create: (context) => DocumentUploadCubit(
create: (context) => DocumentUploadCubit( context.read(),
documentApi: context.read<PaperlessDocumentsApi>(), context.read(),
correspondentRepository: ),
context.read<LabelRepository<Correspondent>>(), child: DocumentUploadPreparationPage(
documentTypeRepository: fileBytes: file.readAsBytesSync(),
context.read<LabelRepository<DocumentType>>(), filename: fileDescription.filename,
tagRepository: context.read<LabelRepository<Tag>>(), title: fileDescription.filename,
), fileExtension: fileDescription.extension,
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:collection/collection.dart';
import 'package:hydrated_bloc/hydrated_bloc.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: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/paged_document_view/cubit/paged_documents_state.dart';
import 'package:paperless_mobile/features/settings/model/view_type.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_state.dart';
part 'document_search_cubit.g.dart'; class DocumentSearchCubit extends Cubit<DocumentSearchState> with DocumentPagingBlocMixin {
class DocumentSearchCubit extends HydratedCubit<DocumentSearchState>
with DocumentPagingBlocMixin {
@override @override
final PaperlessDocumentsApi api; final PaperlessDocumentsApi api;
final LabelRepository _labelRepository;
@override @override
final DocumentChangedNotifier notifier; final DocumentChangedNotifier notifier;
DocumentSearchCubit(this.api, this.notifier) final LocalUserAppState _userAppState;
: super(const DocumentSearchState()) { DocumentSearchCubit(
notifier.subscribe( 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, this,
onDeleted: remove, onDeleted: remove,
onUpdated: replace, onUpdated: replace,
@@ -42,11 +62,13 @@ class DocumentSearchCubit extends HydratedCubit<DocumentSearchState>
state.copyWith( state.copyWith(
searchHistory: [ searchHistory: [
query, query,
...state.searchHistory ...state.searchHistory.whereNot((previousQuery) => previousQuery == query)
.whereNot((previousQuery) => previousQuery == query)
], ],
), ),
); );
_userAppState
..documentSearchHistory = state.searchHistory
..save();
} }
void updateViewType(ViewType viewType) { void updateViewType(ViewType viewType) {
@@ -56,11 +78,12 @@ class DocumentSearchCubit extends HydratedCubit<DocumentSearchState>
void removeHistoryEntry(String entry) { void removeHistoryEntry(String entry) {
emit( emit(
state.copyWith( state.copyWith(
searchHistory: state.searchHistory searchHistory: state.searchHistory.whereNot((element) => element == entry).toList(),
.whereNot((element) => element == entry)
.toList(),
), ),
); );
_userAppState
..documentSearchHistory = state.searchHistory
..save();
} }
Future<void> suggest(String query) async { Future<void> suggest(String query) async {
@@ -80,26 +103,22 @@ class DocumentSearchCubit extends HydratedCubit<DocumentSearchState>
} }
void reset() { void reset() {
emit(state.copyWith( emit(
view: SearchView.suggestions, state.copyWith(
suggestions: [], view: SearchView.suggestions,
isLoading: false, suggestions: [],
)); isLoading: false,
),
);
} }
@override @override
Future<void> close() { Future<void> close() {
notifier.unsubscribe(this); notifier.removeListener(this);
_labelRepository.removeListener(this);
return super.close(); return super.close();
} }
@override @override
DocumentSearchState? fromJson(Map<String, dynamic> json) { Future<void> onFilterUpdated(DocumentFilter filter) async {}
return DocumentSearchState.fromJson(json);
}
@override
Map<String, dynamic>? toJson(DocumentSearchState state) {
return state.toJson();
}
} }

View File

@@ -13,15 +13,25 @@ class DocumentSearchState extends DocumentPagingState {
final List<String> suggestions; final List<String> suggestions;
@JsonKey() @JsonKey()
final ViewType viewType; 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({ const DocumentSearchState({
this.view = SearchView.suggestions, this.view = SearchView.suggestions,
this.searchHistory = const [], this.searchHistory = const [],
this.suggestions = const [], this.suggestions = const [],
this.viewType = ViewType.detailed, this.viewType = ViewType.detailed,
super.filter, super.filter = const DocumentFilter(),
super.hasLoaded, super.hasLoaded,
super.isLoading, super.isLoading,
super.value, super.value,
this.correspondents = const {},
this.documentTypes = const {},
this.tags = const {},
this.storagePaths = const {},
}); });
@override @override
@@ -31,6 +41,10 @@ class DocumentSearchState extends DocumentPagingState {
suggestions, suggestions,
view, view,
viewType, viewType,
correspondents,
documentTypes,
tags,
storagePaths,
]; ];
@override @override
@@ -57,6 +71,10 @@ class DocumentSearchState extends DocumentPagingState {
List<String>? suggestions, List<String>? suggestions,
SearchView? view, SearchView? view,
ViewType? viewType, ViewType? viewType,
Map<int, Correspondent>? correspondents,
Map<int, DocumentType>? documentTypes,
Map<int, Tag>? tags,
Map<int, StoragePath>? storagePaths,
}) { }) {
return DocumentSearchState( return DocumentSearchState(
value: value ?? this.value, value: value ?? this.value,
@@ -67,6 +85,10 @@ class DocumentSearchState extends DocumentPagingState {
view: view ?? this.view, view: view ?? this.view,
suggestions: suggestions ?? this.suggestions, suggestions: suggestions ?? this.suggestions,
viewType: viewType ?? this.viewType, 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:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/document_search/cubit/document_search_cubit.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/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/adaptive_documents_view.dart';
import 'package:paperless_mobile/features/documents/view/widgets/selection/view_type_selection_widget.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/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/routes/document_details_route.dart'; import 'package:paperless_mobile/routes/document_details_route.dart';
import 'dart:math' as math; import 'dart:math' as math;
Future<void> showDocumentSearchPage(BuildContext context) { Future<void> showDocumentSearchPage(BuildContext context) {
final currentUser =
Hive.box<GlobalSettings>(HiveBoxes.globalSettings).getValue()!.currentLoggedInUser;
return Navigator.of(context).push( return Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (context) => BlocProvider( builder: (context) => BlocProvider(
create: (context) => DocumentSearchCubit( create: (context) => DocumentSearchCubit(
context.read(), context.read(),
context.read(), context.read(),
context.read(),
Hive.box<LocalUserAppState>(HiveBoxes.localUserAppState).get(currentUser)!,
), ),
child: const DocumentSearchPage(), child: const DocumentSearchPage(),
), ),
@@ -69,13 +76,14 @@ class _DocumentSearchPageState extends State<DocumentSearchPage> {
controller: _queryController, controller: _queryController,
onChanged: (query) { onChanged: (query) {
_debounceTimer?.cancel(); _debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 700), () { _debounceTimer = Timer(const Duration(milliseconds: 500), () {
context.read<DocumentSearchCubit>().suggest(query); context.read<DocumentSearchCubit>().suggest(query);
}); });
}, },
textInputAction: TextInputAction.search, textInputAction: TextInputAction.search,
onSubmitted: (query) { onSubmitted: (query) {
FocusScope.of(context).unfocus(); FocusScope.of(context).unfocus();
_debounceTimer?.cancel();
context.read<DocumentSearchCubit>().search(query); context.read<DocumentSearchCubit>().search(query);
}, },
), ),
@@ -110,9 +118,8 @@ class _DocumentSearchPageState extends State<DocumentSearchPage> {
} }
Widget _buildSuggestionsView(DocumentSearchState state) { Widget _buildSuggestionsView(DocumentSearchState state) {
final suggestions = state.suggestions final suggestions =
.whereNot((element) => state.searchHistory.contains(element)) state.suggestions.whereNot((element) => state.searchHistory.contains(element)).toList();
.toList();
final historyMatches = state.searchHistory final historyMatches = state.searchHistory
.where( .where(
(element) => element.startsWith(query), (element) => element.startsWith(query),
@@ -194,8 +201,7 @@ class _DocumentSearchPageState extends State<DocumentSearchPage> {
builder: (context, state) { builder: (context, state) {
return ViewTypeSelectionWidget( return ViewTypeSelectionWidget(
viewType: state.viewType, viewType: state.viewType,
onChanged: (type) => onChanged: (type) => context.read<DocumentSearchCubit>().updateViewType(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:flutter/material.dart';
import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_cancel_button.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'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class RemoveHistoryEntryDialog extends StatelessWidget { class RemoveHistoryEntryDialog extends StatelessWidget {
@@ -13,12 +14,10 @@ class RemoveHistoryEntryDialog extends StatelessWidget {
content: Text(S.of(context)!.removeQueryFromSearchHistory), content: Text(S.of(context)!.removeQueryFromSearchHistory),
actions: [ actions: [
const DialogCancelButton(), const DialogCancelButton(),
TextButton( DialogConfirmButton(
child: Text(S.of(context)!.remove), style: DialogConfirmButtonStyle.danger,
onPressed: () { label: S.of(context)!.remove,
Navigator.pop(context, true); )
},
),
], ],
); );
} }

View File

@@ -1,11 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/bloc/paperless_server_information_cubit.dart'; import 'package:hive_flutter/adapters.dart';
import 'package:paperless_mobile/core/bloc/paperless_server_information_state.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/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/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'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class SliverSearchBar extends StatelessWidget { class SliverSearchBar extends StatelessWidget {
@@ -23,12 +27,12 @@ class SliverSearchBar extends StatelessWidget {
floating: floating, floating: floating,
pinned: pinned, pinned: pinned,
delegate: CustomizableSliverPersistentHeaderDelegate( delegate: CustomizableSliverPersistentHeaderDelegate(
minExtent: 56 + 8, minExtent: kToolbarHeight,
maxExtent: 56 + 8, maxExtent: kToolbarHeight,
child: Padding( child: Container(
padding: const EdgeInsets.all(8.0), margin: const EdgeInsets.symmetric(horizontal: 8.0),
child: SearchBar( child: s.SearchBar(
height: 56, height: kToolbarHeight,
supportingText: S.of(context)!.searchDocuments, supportingText: S.of(context)!.searchDocuments,
onTap: () => showDocumentSearchPage(context), onTap: () => showDocumentSearchPage(context),
leadingIcon: IconButton( leadingIcon: IconButton(
@@ -36,18 +40,25 @@ class SliverSearchBar extends StatelessWidget {
onPressed: Scaffold.of(context).openDrawer, onPressed: Scaffold.of(context).openDrawer,
), ),
trailingIcon: IconButton( trailingIcon: IconButton(
icon: BlocBuilder<PaperlessServerInformationCubit, icon: GlobalSettingsBuilder(
PaperlessServerInformationState>( builder: (context, settings) {
builder: (context, state) { return ValueListenableBuilder(
return CircleAvatar( valueListenable:
child: Text(state.information?.userInitials ?? ''), Hive.box<LocalUserAccount>(HiveBoxes.localUserAccount).listenable(),
builder: (context, box, _) {
final account = box.get(settings.currentLoggedInUser!)!;
return UserAvatar(userId: settings.currentLoggedInUser!, account: account);
},
); );
}, },
), ),
onPressed: () { onPressed: () {
showDialog( showDialog(
context: context, 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:flutter/foundation.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/label_repository.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'; part 'document_upload_state.dart';
class DocumentUploadCubit extends Cubit<DocumentUploadState> { class DocumentUploadCubit extends Cubit<DocumentUploadState> {
final PaperlessDocumentsApi _documentApi; final PaperlessDocumentsApi _documentApi;
final LabelRepository<Tag> _tagRepository; final LabelRepository _labelRepository;
final LabelRepository<Correspondent> _correspondentRepository;
final LabelRepository<DocumentType> _documentTypeRepository;
final List<StreamSubscription> _subs = []; DocumentUploadCubit(this._labelRepository, this._documentApi)
: super(const DocumentUploadState()) {
DocumentUploadCubit({ _labelRepository.addListener(
required PaperlessDocumentsApi documentApi, this,
required LabelRepository<Tag> tagRepository, onChanged: (labels) {
required LabelRepository<Correspondent> correspondentRepository, emit(state.copyWith(
required LabelRepository<DocumentType> documentTypeRepository, correspondents: labels.correspondents,
}) : _documentApi = documentApi, documentTypes: labels.documentTypes,
_tagRepository = tagRepository, tags: labels.tags,
_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)),
));
} }
Future<String?> upload( Future<String?> upload(
@@ -65,9 +49,7 @@ class DocumentUploadCubit extends Cubit<DocumentUploadState> {
@override @override
Future<void> close() async { Future<void> close() async {
for (final sub in _subs) { _labelRepository.removeListener(this);
await sub.cancel();
}
return super.close(); return super.close();
} }
} }

View File

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

View File

@@ -1,31 +1,68 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hydrated_bloc/hydrated_bloc.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: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/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_cubit.g.dart';
part 'documents_state.dart';
class DocumentsCubit extends HydratedCubit<DocumentsState> class DocumentsCubit extends Cubit<DocumentsState> with DocumentPagingBlocMixin {
with DocumentPagingBlocMixin {
@override @override
final PaperlessDocumentsApi api; final PaperlessDocumentsApi api;
final LabelRepository _labelRepository;
@override @override
final DocumentChangedNotifier notifier; final DocumentChangedNotifier notifier;
DocumentsCubit(this.api, this.notifier) : super(const DocumentsState()) { final LocalUserAppState _userState;
notifier.subscribe(
DocumentsCubit(
this.api,
this.notifier,
this._labelRepository,
this._userState,
) : super(DocumentsState(
filter: _userState.currentDocumentFilter,
viewType: _userState.documentsPageViewType,
)) {
notifier.addListener(
this, this,
onUpdated: replace, onUpdated: (document) {
onDeleted: remove, 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(); 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) { void toggleDocumentSelection(DocumentModel model) {
debugPrint("[DocumentsCubit] toggleSelection"); debugPrint("[DocumentsCubit] toggleSelection");
if (state.selectedIds.contains(model.id)) { if (state.selectedIds.contains(model.id)) {
emit( emit(
state.copyWith( state.copyWith(
selection: state.selection selection: state.selection.where((element) => element.id != model.id).toList(),
.where((element) => element.id != model.id)
.toList(),
), ),
); );
} else { } else {
@@ -84,23 +105,22 @@ class DocumentsCubit extends HydratedCubit<DocumentsState>
return res; return res;
} }
@override
DocumentsState? fromJson(Map<String, dynamic> json) {
return DocumentsState.fromJson(json);
}
@override
Map<String, dynamic>? toJson(DocumentsState state) {
return state.toJson();
}
@override @override
Future<void> close() { Future<void> close() {
notifier.unsubscribe(this); notifier.removeListener(this);
_labelRepository.removeListener(this);
return super.close(); return super.close();
} }
void setViewType(ViewType viewType) { void setViewType(ViewType viewType) {
emit(state.copyWith(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() @JsonSerializable()
class DocumentsState extends DocumentPagingState { class DocumentsState extends DocumentPagingState {
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeToJson: false, includeFromJson: false)
final List<DocumentModel> selection; 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; final ViewType viewType;
const DocumentsState({ const DocumentsState({
@@ -14,6 +23,10 @@ class DocumentsState extends DocumentPagingState {
super.filter = const DocumentFilter(), super.filter = const DocumentFilter(),
super.hasLoaded = false, super.hasLoaded = false,
super.isLoading = 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(); List<int> get selectedIds => selection.map((e) => e.id).toList();
@@ -25,6 +38,10 @@ class DocumentsState extends DocumentPagingState {
DocumentFilter? filter, DocumentFilter? filter,
List<DocumentModel>? selection, List<DocumentModel>? selection,
ViewType? viewType, ViewType? viewType,
Map<int, Correspondent>? correspondents,
Map<int, DocumentType>? documentTypes,
Map<int, Tag>? tags,
Map<int, StoragePath>? storagePaths,
}) { }) {
return DocumentsState( return DocumentsState(
hasLoaded: hasLoaded ?? this.hasLoaded, hasLoaded: hasLoaded ?? this.hasLoaded,
@@ -33,18 +50,21 @@ class DocumentsState extends DocumentPagingState {
filter: filter ?? this.filter, filter: filter ?? this.filter,
selection: selection ?? this.selection, selection: selection ?? this.selection,
viewType: viewType ?? this.viewType, 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 @override
List<Object?> get props => [ List<Object?> get props => [
selection, selection,
viewType, viewType,
correspondents,
documentTypes,
tags,
storagePaths,
...super.props, ...super.props,
]; ];
@@ -62,4 +82,9 @@ class DocumentsState extends DocumentPagingState {
value: value, 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:badges/badges.dart' as b;
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/bloc/connectivity_cubit.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/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/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/app_drawer/view/app_drawer.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/document_search/view/sliver_search_bar.dart';
import 'package:paperless_mobile/features/documents/cubit/documents_cubit.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/adaptive_documents_view.dart';
import 'package:paperless_mobile/features/documents/view/widgets/documents_empty_state.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/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/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/selection/view_type_selection_widget.dart';
import 'package:paperless_mobile/features/documents/view/widgets/sort_documents_button.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/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/add_saved_view_page.dart';
import 'package:paperless_mobile/features/saved_view/view/saved_view_list.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(); State<DocumentsPage> createState() => _DocumentsPageState();
} }
class _DocumentsPageState extends State<DocumentsPage> class _DocumentsPageState extends State<DocumentsPage> with SingleTickerProviderStateMixin {
with SingleTickerProviderStateMixin { final SliverOverlapAbsorberHandle searchBarHandle = SliverOverlapAbsorberHandle();
final SliverOverlapAbsorberHandle searchBarHandle = final SliverOverlapAbsorberHandle tabBarHandle = SliverOverlapAbsorberHandle();
SliverOverlapAbsorberHandle();
final SliverOverlapAbsorberHandle tabBarHandle =
SliverOverlapAbsorberHandle();
late final TabController _tabController; late final TabController _tabController;
int _currentTab = 0; int _currentTab = 0;
@@ -86,8 +81,7 @@ class _DocumentsPageState extends State<DocumentsPage>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocListener<TaskStatusCubit, TaskStatusState>( return BlocListener<TaskStatusCubit, TaskStatusState>(
listenWhen: (previous, current) => listenWhen: (previous, current) => !previous.isSuccess && current.isSuccess,
!previous.isSuccess && current.isSuccess,
listener: (context, state) { listener: (context, state) {
showSnackBar( showSnackBar(
context, context,
@@ -104,8 +98,7 @@ class _DocumentsPageState extends State<DocumentsPage>
}, },
child: BlocConsumer<ConnectivityCubit, ConnectivityState>( child: BlocConsumer<ConnectivityCubit, ConnectivityState>(
listenWhen: (previous, current) => listenWhen: (previous, current) =>
previous != ConnectivityState.connected && previous != ConnectivityState.connected && current == ConnectivityState.connected,
current == ConnectivityState.connected,
listener: (context, state) { listener: (context, state) {
try { try {
context.read<DocumentsCubit>().reload(); context.read<DocumentsCubit>().reload();
@@ -115,42 +108,45 @@ class _DocumentsPageState extends State<DocumentsPage>
}, },
builder: (context, connectivityState) { builder: (context, connectivityState) {
return SafeArea( return SafeArea(
top: context.read<DocumentsCubit>().state.selection.isEmpty,
child: Scaffold( child: Scaffold(
drawer: const AppDrawer(), drawer: const AppDrawer(),
floatingActionButton: BlocBuilder<DocumentsCubit, DocumentsState>( floatingActionButton: BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, state) { builder: (context, state) {
final appliedFiltersCount = state.filter.appliedFiltersCount; final appliedFiltersCount = state.filter.appliedFiltersCount;
return b.Badge( final show = state.selection.isEmpty;
position: b.BadgePosition.topEnd(top: -12, end: -6), return AnimatedScale(
showBadge: appliedFiltersCount > 0, scale: show ? 1 : 0,
badgeContent: Text( duration: const Duration(milliseconds: 200),
'$appliedFiltersCount', curve: Curves.easeIn,
style: const TextStyle( child: b.Badge(
color: Colors.white, 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, resizeToAvoidBottomInset: true,
body: WillPopScope( body: WillPopScope(
onWillPop: () async { onWillPop: () async {
if (context if (context.read<DocumentsCubit>().state.selection.isNotEmpty) {
.read<DocumentsCubit>()
.state
.selection
.isNotEmpty) {
context.read<DocumentsCubit>().resetSelection(); context.read<DocumentsCubit>().resetSelection();
} }
return false; return false;
@@ -167,7 +163,8 @@ class _DocumentsPageState extends State<DocumentsPage>
if (state.selection.isNotEmpty) { if (state.selection.isNotEmpty) {
// Show selection app bar when selection mode is active // Show selection app bar when selection mode is active
return DocumentSelectionSliverAppBar( return DocumentSelectionSliverAppBar(
state: state); state: state,
);
} }
return const SliverSearchBar(floating: true); return const SliverSearchBar(floating: true);
}, },
@@ -184,8 +181,7 @@ class _DocumentsPageState extends State<DocumentsPage>
} }
return SliverPersistentHeader( return SliverPersistentHeader(
pinned: true, pinned: true,
delegate: delegate: CustomizableSliverPersistentHeaderDelegate(
CustomizableSliverPersistentHeaderDelegate(
minExtent: kTextTabBarHeight, minExtent: kTextTabBarHeight,
maxExtent: kTextTabBarHeight, maxExtent: kTextTabBarHeight,
child: ColoredTabBar( child: ColoredTabBar(
@@ -209,22 +205,15 @@ class _DocumentsPageState extends State<DocumentsPage>
if (metrics.maxScrollExtent == 0) { if (metrics.maxScrollExtent == 0) {
return true; return true;
} }
final desiredTab = final desiredTab = (metrics.pixels / metrics.maxScrollExtent).round();
(metrics.pixels / metrics.maxScrollExtent) if (metrics.axis == Axis.horizontal && _currentTab != desiredTab) {
.round();
if (metrics.axis == Axis.horizontal &&
_currentTab != desiredTab) {
setState(() => _currentTab = desiredTab); setState(() => _currentTab = desiredTab);
} }
return false; return false;
}, },
child: TabBarView( child: TabBarView(
controller: _tabController, controller: _tabController,
physics: context physics: context.watch<DocumentsCubit>().state.selection.isNotEmpty
.watch<DocumentsCubit>()
.state
.selection
.isNotEmpty
? const NeverScrollableScrollPhysics() ? const NeverScrollableScrollPhysics()
: null, : null,
children: [ children: [
@@ -292,25 +281,20 @@ class _DocumentsPageState extends State<DocumentsPage>
final currState = context.read<DocumentsCubit>().state; final currState = context.read<DocumentsCubit>().state;
final max = notification.metrics.maxScrollExtent; final max = notification.metrics.maxScrollExtent;
if (max == 0 || if (max == 0 || _currentTab != 0 || currState.isLoading || currState.isLastPageLoaded) {
_currentTab != 0 || return false;
currState.isLoading ||
currState.isLastPageLoaded) {
return true;
} }
final offset = notification.metrics.pixels; final offset = notification.metrics.pixels;
if (offset >= max * 0.7) { if (offset >= max * 0.7) {
context context.read<DocumentsCubit>().loadMore().onError<PaperlessServerException>(
.read<DocumentsCubit>()
.loadMore()
.onError<PaperlessServerException>(
(error, stackTrace) => showErrorMessage( (error, stackTrace) => showErrorMessage(
context, context,
error, error,
stackTrace, stackTrace,
), ),
); );
return true;
} }
return false; return false;
}, },
@@ -338,8 +322,7 @@ class _DocumentsPageState extends State<DocumentsPage>
return SliverAdaptiveDocumentsView( return SliverAdaptiveDocumentsView(
viewType: state.viewType, viewType: state.viewType,
onTap: _openDetails, onTap: _openDetails,
onSelected: onSelected: context.read<DocumentsCubit>().toggleDocumentSelection,
context.read<DocumentsCubit>().toggleDocumentSelection,
hasInternetConnection: connectivityState.isConnected, hasInternetConnection: connectivityState.isConnected,
onTagSelected: _addTagToFilter, onTagSelected: _addTagToFilter,
onCorrespondentSelected: _addCorrespondentToFilter, onCorrespondentSelected: _addCorrespondentToFilter,
@@ -350,6 +333,10 @@ class _DocumentsPageState extends State<DocumentsPage>
isLabelClickable: true, isLabelClickable: true,
isLoading: state.isLoading, isLoading: state.isLoading,
selectedDocumentIds: state.selectedIds, 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() { Widget _buildViewActions() {
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Row( child: BlocBuilder<DocumentsCubit, DocumentsState>(
mainAxisAlignment: MainAxisAlignment.spaceBetween, builder: (context, state) {
children: [ return Row(
const SortDocumentsButton(), mainAxisAlignment: MainAxisAlignment.spaceBetween,
BlocBuilder<DocumentsCubit, DocumentsState>( children: [
builder: (context, state) { SortDocumentsButton(
return ViewTypeSelectionWidget( enabled: state.selection.isEmpty,
),
ViewTypeSelectionWidget(
viewType: state.viewType, viewType: state.viewType,
onChanged: context.read<DocumentsCubit>().setViewType, onChanged: context.read<DocumentsCubit>().setViewType,
); ),
}, ],
) );
], },
).paddedSymmetrically(horizontal: 8, vertical: 4), ).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 { void _onCreateSavedView(DocumentFilter filter) async {
final newView = await Navigator.of(context).push<SavedView?>( final newView = await Navigator.of(context).push<SavedView?>(
MaterialPageRoute( MaterialPageRoute(
builder: (context) => LabelsBlocProvider( builder: (context) => BlocBuilder<SavedViewCubit, SavedViewState>(
child: AddSavedViewPage( builder: (context, state) {
currentFilter: filter, 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], snapSizes: const [0.9, 1],
initialChildSize: .9, initialChildSize: .9,
maxChildSize: 1, maxChildSize: 1,
builder: (context, controller) => LabelsBlocProvider( builder: (context, controller) => BlocBuilder<DocumentsCubit, DocumentsState>(
child: DocumentFilterPanel( builder: (context, state) {
initialFilter: context.read<DocumentsCubit>().state.filter, return DocumentFilterPanel(
scrollController: controller, initialFilter: context.read<DocumentsCubit>().state.filter,
draggableSheetController: draggableSheetController, 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) { if (filterIntent.shouldReset) {
await context.read<DocumentsCubit>().resetFilter(); await context.read<DocumentsCubit>().resetFilter();
} else { } else {
await context await context.read<DocumentsCubit>().updateFilter(filter: filterIntent.filter!);
.read<DocumentsCubit>()
.updateFilter(filter: filterIntent.filter!);
} }
} on PaperlessServerException catch (error, stackTrace) { } on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace); showErrorMessage(context, error, stackTrace);
@@ -478,20 +454,21 @@ class _DocumentsPageState extends State<DocumentsPage>
void _addTagToFilter(int tagId) { void _addTagToFilter(int tagId) {
try { try {
final tagsQuery = final tagsQuery = context.read<DocumentsCubit>().state.filter.tags is IdsTagsQuery
context.read<DocumentsCubit>().state.filter.tags is IdsTagsQuery ? context.read<DocumentsCubit>().state.filter.tags as IdsTagsQuery
? context.read<DocumentsCubit>().state.filter.tags as IdsTagsQuery : const IdsTagsQuery();
: const IdsTagsQuery(); if (tagsQuery.include.contains(tagId)) {
if (tagsQuery.includedIds.contains(tagId)) {
context.read<DocumentsCubit>().updateCurrentFilter( context.read<DocumentsCubit>().updateCurrentFilter(
(filter) => filter.copyWith( (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 { } else {
context.read<DocumentsCubit>().updateCurrentFilter( context.read<DocumentsCubit>().updateCurrentFilter(
(filter) => filter.copyWith( (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) { void _addCorrespondentToFilter(int? correspondentId) {
final cubit = context.read<DocumentsCubit>(); final cubit = context.read<DocumentsCubit>();
try { try {
if (cubit.state.filter.correspondent.id == correspondentId) { final correspondent = cubit.state.filter.correspondent;
cubit.updateCurrentFilter( if (correspondent is SetIdQueryParameter) {
(filter) => if (correspondentId == null || correspondent.id == correspondentId) {
filter.copyWith(correspondent: const IdQueryParameter.unset()), cubit.updateCurrentFilter(
); (filter) => filter.copyWith(correspondent: const IdQueryParameter.unset()),
} else { );
cubit.updateCurrentFilter( } else {
(filter) => filter.copyWith( cubit.updateCurrentFilter(
correspondent: IdQueryParameter.fromId(correspondentId)), (filter) => filter.copyWith(correspondent: IdQueryParameter.fromId(correspondentId)),
); );
}
} }
} on PaperlessServerException catch (error, stackTrace) { } on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace); showErrorMessage(context, error, stackTrace);
@@ -522,16 +500,17 @@ class _DocumentsPageState extends State<DocumentsPage>
void _addDocumentTypeToFilter(int? documentTypeId) { void _addDocumentTypeToFilter(int? documentTypeId) {
final cubit = context.read<DocumentsCubit>(); final cubit = context.read<DocumentsCubit>();
try { try {
if (cubit.state.filter.documentType.id == documentTypeId) { final documentType = cubit.state.filter.documentType;
cubit.updateCurrentFilter( if (documentType is SetIdQueryParameter) {
(filter) => if (documentTypeId == null || documentType.id == documentTypeId) {
filter.copyWith(documentType: const IdQueryParameter.unset()), cubit.updateCurrentFilter(
); (filter) => filter.copyWith(documentType: const IdQueryParameter.unset()),
} else { );
cubit.updateCurrentFilter( } else {
(filter) => filter.copyWith( cubit.updateCurrentFilter(
documentType: IdQueryParameter.fromId(documentTypeId)), (filter) => filter.copyWith(documentType: IdQueryParameter.fromId(documentTypeId)),
); );
}
} }
} on PaperlessServerException catch (error, stackTrace) { } on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace); showErrorMessage(context, error, stackTrace);
@@ -541,16 +520,17 @@ class _DocumentsPageState extends State<DocumentsPage>
void _addStoragePathToFilter(int? pathId) { void _addStoragePathToFilter(int? pathId) {
final cubit = context.read<DocumentsCubit>(); final cubit = context.read<DocumentsCubit>();
try { try {
if (cubit.state.filter.correspondent.id == pathId) { final path = cubit.state.filter.documentType;
cubit.updateCurrentFilter( if (path is SetIdQueryParameter) {
(filter) => if (pathId == null || path.id == pathId) {
filter.copyWith(storagePath: const IdQueryParameter.unset()), cubit.updateCurrentFilter(
); (filter) => filter.copyWith(storagePath: const IdQueryParameter.unset()),
} else { );
cubit.updateCurrentFilter( } else {
(filter) => cubit.updateCurrentFilter(
filter.copyWith(storagePath: IdQueryParameter.fromId(pathId)), (filter) => filter.copyWith(storagePath: IdQueryParameter.fromId(pathId)),
); );
}
} }
} on PaperlessServerException catch (error, stackTrace) { } on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace); showErrorMessage(context, error, stackTrace);

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,11 @@ abstract class DocumentItem extends StatelessWidget {
final bool isLabelClickable; final bool isLabelClickable;
final bool enableHeroAnimation; 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 tagId)? onTagSelected;
final void Function(int? correspondentId)? onCorrespondentSelected; final void Function(int? correspondentId)? onCorrespondentSelected;
final void Function(int? documentTypeId)? onDocumentTypeSelected; final void Function(int? documentTypeId)? onDocumentTypeSelected;
@@ -28,5 +33,9 @@ abstract class DocumentItem extends StatelessWidget {
this.onDocumentTypeSelected, this.onDocumentTypeSelected,
this.onStoragePathSelected, this.onStoragePathSelected,
required this.enableHeroAnimation, 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/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.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/document_preview.dart';
import 'package:paperless_mobile/features/documents/view/widgets/items/document_item.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/correspondent/view/widgets/correspondent_widget.dart';
import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_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.onTagSelected,
super.onTap, super.onTap,
super.enableHeroAnimation = true, super.enableHeroAnimation = true,
required super.tags,
required super.correspondents,
required super.documentTypes,
required super.storagePaths,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return DocumentTypeBlocProvider( return Material(
child: ListTile( child: ListTile(
dense: true, dense: true,
selected: isSelected, selected: isSelected,
@@ -46,7 +46,7 @@ class DocumentListItem extends DocumentItem {
absorbing: isSelectionActive, absorbing: isSelectionActive,
child: CorrespondentWidget( child: CorrespondentWidget(
isClickable: isLabelClickable, isClickable: isLabelClickable,
correspondentId: document.correspondent, correspondent: correspondents[document.correspondent],
onSelected: onCorrespondentSelected, onSelected: onCorrespondentSelected,
), ),
), ),
@@ -61,62 +61,59 @@ class DocumentListItem extends DocumentItem {
absorbing: isSelectionActive, absorbing: isSelectionActive,
child: TagsWidget( child: TagsWidget(
isClickable: isLabelClickable, isClickable: isLabelClickable,
tagIds: document.tags, tags: document.tags
.where((e) => tags.containsKey(e))
.map((e) => tags[e]!)
.toList(),
isMultiLine: false, isMultiLine: false,
onTagSelected: (id) => onTagSelected?.call(id), onTagSelected: (id) => onTagSelected?.call(id),
), ),
) ),
], ],
), ),
subtitle: Padding( subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 4),
child: child: RichText(
BlocBuilder<LabelCubit<DocumentType>, LabelState<DocumentType>>( maxLines: 1,
builder: (context, docTypes) { overflow: TextOverflow.ellipsis,
return RichText( text: TextSpan(
maxLines: 1, text: DateFormat.yMMMd().format(document.created),
overflow: TextOverflow.ellipsis, style: Theme.of(context)
text: TextSpan( .textTheme
text: DateFormat.yMMMd().format(document.created), .labelSmall
style: Theme.of(context) ?.apply(color: Colors.grey),
.textTheme children: document.documentType != null
.labelSmall ? [
?.apply(color: Colors.grey), const TextSpan(text: '\u30FB'),
children: document.documentType != null TextSpan(
? [ text: documentTypes[document.documentType]?.name,
const TextSpan(text: '\u30FB'), ),
TextSpan( ]
text: : null,
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,
// ),
// ),
// ],
// ],
// ),
), ),
),
// 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, isThreeLine: document.tags.isNotEmpty,
leading: AspectRatio( leading: AspectRatio(
aspectRatio: _a4AspectRatio, 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/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/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/tags/view/widgets/tags_form_field.dart';
import 'package:paperless_mobile/features/labels/view/widgets/label_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/generated/l10n/app_localizations.dart';
@@ -49,6 +50,11 @@ class DocumentFilterForm extends StatefulWidget {
final DocumentFilter initialFilter; final DocumentFilter initialFilter;
final ScrollController? scrollController; final ScrollController? scrollController;
final EdgeInsets padding; 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({ const DocumentFilterForm({
super.key, super.key,
this.header, this.header,
@@ -56,6 +62,10 @@ class DocumentFilterForm extends StatefulWidget {
required this.initialFilter, required this.initialFilter,
this.scrollController, this.scrollController,
this.padding = const EdgeInsets.symmetric(vertical: 8, horizontal: 16), this.padding = const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
required this.correspondents,
required this.documentTypes,
required this.tags,
required this.storagePaths,
}); });
@override @override
@@ -80,7 +90,7 @@ class _DocumentFilterFormState extends State<DocumentFilterForm> {
slivers: [ slivers: [
if (widget.header != null) widget.header!, if (widget.header != null) widget.header!,
..._buildFormFieldList(), ..._buildFormFieldList(),
SliverToBoxAdapter( const SliverToBoxAdapter(
child: SizedBox( child: SizedBox(
height: 32, height: 32,
), ),
@@ -145,47 +155,32 @@ class _DocumentFilterFormState extends State<DocumentFilterForm> {
} }
Widget _buildDocumentTypeFormField() { Widget _buildDocumentTypeFormField() {
return BlocBuilder<LabelCubit<DocumentType>, LabelState<DocumentType>>( return LabelFormField<DocumentType>(
builder: (context, state) { name: DocumentFilterForm.fkDocumentType,
return LabelFormField<DocumentType>( options: widget.documentTypes,
formBuilderState: widget.formKey.currentState, labelText: S.of(context)!.documentType,
name: DocumentFilterForm.fkDocumentType, initialValue: widget.initialFilter.documentType,
labelOptions: state.labels, prefixIcon: const Icon(Icons.description_outlined),
textFieldLabel: S.of(context)!.documentType,
initialValue: widget.initialFilter.documentType,
prefixIcon: const Icon(Icons.description_outlined),
);
},
); );
} }
Widget _buildCorrespondentFormField() { Widget _buildCorrespondentFormField() {
return BlocBuilder<LabelCubit<Correspondent>, LabelState<Correspondent>>( return LabelFormField<Correspondent>(
builder: (context, state) { name: DocumentFilterForm.fkCorrespondent,
return LabelFormField<Correspondent>( options: widget.correspondents,
formBuilderState: widget.formKey.currentState, labelText: S.of(context)!.correspondent,
name: DocumentFilterForm.fkCorrespondent, initialValue: widget.initialFilter.correspondent,
labelOptions: state.labels, prefixIcon: const Icon(Icons.person_outline),
textFieldLabel: S.of(context)!.correspondent,
initialValue: widget.initialFilter.correspondent,
prefixIcon: const Icon(Icons.person_outline),
);
},
); );
} }
Widget _buildStoragePathFormField() { Widget _buildStoragePathFormField() {
return BlocBuilder<LabelCubit<StoragePath>, LabelState<StoragePath>>( return LabelFormField<StoragePath>(
builder: (context, state) { name: DocumentFilterForm.fkStoragePath,
return LabelFormField<StoragePath>( options: widget.storagePaths,
formBuilderState: widget.formKey.currentState, labelText: S.of(context)!.storagePath,
name: DocumentFilterForm.fkStoragePath, initialValue: widget.initialFilter.storagePath,
labelOptions: state.labels, prefixIcon: const Icon(Icons.folder_outlined),
textFieldLabel: 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() { Widget _buildTagsFormField() {
return BlocBuilder<LabelCubit<Tag>, LabelState<Tag>>( return TagsFormField(
builder: (context, state) { name: DocumentModel.tagsKey,
return TagFormField( initialValue: widget.initialFilter.tags,
name: DocumentModel.tagsKey, options: widget.tags,
initialValue: widget.initialFilter.tags, allowExclude: false,
allowCreation: false, allowOnlySelection: false,
selectableOptions: state.labels, allowCreation: false,
);
},
); );
} }
} }

View File

@@ -13,11 +13,20 @@ class DocumentFilterPanel extends StatefulWidget {
final DocumentFilter initialFilter; final DocumentFilter initialFilter;
final ScrollController scrollController; final ScrollController scrollController;
final DraggableScrollableController draggableSheetController; 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({ const DocumentFilterPanel({
Key? key, Key? key,
required this.initialFilter, required this.initialFilter,
required this.scrollController, required this.scrollController,
required this.draggableSheetController, required this.draggableSheetController,
required this.correspondents,
required this.documentTypes,
required this.tags,
required this.storagePaths,
}) : super(key: key); }) : super(key: key);
@override @override
@@ -38,10 +47,8 @@ class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
void animateTitleByDrag() { void animateTitleByDrag() {
setState( setState(
() { () => _heightAnimationValue =
_heightAnimationValue = dp( dp(((max(0.9, widget.draggableSheetController.size) - 0.9) / 0.1), 5),
((max(0.9, widget.draggableSheetController.size) - 0.9) / 0.1), 5);
},
); );
} }
@@ -96,6 +103,10 @@ class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
scrollController: widget.scrollController, scrollController: widget.scrollController,
initialFilter: widget.initialFilter, initialFilter: widget.initialFilter,
header: _buildPanelHeader(), 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 { class SortFieldSelectionBottomSheet extends StatefulWidget {
final SortOrder initialSortOrder; final SortOrder initialSortOrder;
final SortField? initialSortField; 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; final Future Function(SortField? field, SortOrder order) onSubmit;
@@ -18,6 +22,10 @@ class SortFieldSelectionBottomSheet extends StatefulWidget {
required this.initialSortOrder, required this.initialSortOrder,
required this.initialSortField, required this.initialSortField,
required this.onSubmit, required this.onSubmit,
required this.correspondents,
required this.documentTypes,
required this.tags,
required this.storagePaths,
}); });
@override @override
@@ -67,31 +75,20 @@ class _SortFieldSelectionBottomSheetState
Column( Column(
children: [ children: [
_buildSortOption(SortField.archiveSerialNumber), _buildSortOption(SortField.archiveSerialNumber),
BlocBuilder<LabelCubit<Correspondent>, _buildSortOption(
LabelState<Correspondent>>( SortField.correspondentName,
builder: (context, state) { enabled: widget.correspondents.values.fold<bool>(
return _buildSortOption( false,
SortField.correspondentName, (previousValue, element) =>
enabled: state.labels.values.fold<bool>( previousValue || (element.documentCount ?? 0) > 0),
false,
(previousValue, element) =>
previousValue ||
(element.documentCount ?? 0) > 0),
);
},
), ),
_buildSortOption(SortField.title), _buildSortOption(SortField.title),
BlocBuilder<LabelCubit<DocumentType>, LabelState<DocumentType>>( _buildSortOption(
builder: (context, state) { SortField.documentType,
return _buildSortOption( enabled: widget.documentTypes.values.fold<bool>(
SortField.documentType, false,
enabled: state.labels.values.fold<bool>( (previousValue, element) =>
false, previousValue || (element.documentCount ?? 0) > 0),
(previousValue, element) =>
previousValue ||
(element.documentCount ?? 0) > 0),
);
},
), ),
_buildSortOption(SortField.created), _buildSortOption(SortField.created),
_buildSortOption(SortField.added), _buildSortOption(SortField.added),

View File

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

View File

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

View File

@@ -1,12 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/src/widgets/framework.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter/src/widgets/placeholder.dart';
import 'package:paperless_api/paperless_api.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/cubit/documents_cubit.dart';
import 'package:paperless_mobile/features/documents/view/widgets/selection/bulk_delete_confirmation_dialog.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/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart';
import 'package:provider/provider.dart';
class DocumentSelectionSliverAppBar extends StatelessWidget { class DocumentSelectionSliverAppBar extends StatelessWidget {
final DocumentsState state; final DocumentsState state;
@@ -15,7 +17,11 @@ class DocumentSelectionSliverAppBar extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SliverAppBar( return SliverAppBar(
stretch: false,
pinned: true, pinned: true,
floating: true,
snap: true,
backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
title: Text( title: Text(
S.of(context)!.countSelected(state.selection.length), 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>( 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, position: PopupMenuPosition.under,
initialValue: viewType, initialValue: viewType,
icon: Icon(icon), icon: Icon(icon),
@@ -70,7 +74,10 @@ class ViewTypeSelectionWidget extends StatelessWidget {
child: ListTile( child: ListTile(
selected: selected, selected: selected,
trailing: selected ? const Icon(Icons.done) : null, trailing: selected ? const Icon(Icons.done) : null,
title: Text(label), title: Text(
label,
maxLines: 1,
),
iconColor: Theme.of(context).colorScheme.onSurface, iconColor: Theme.of(context).colorScheme.onSurface,
textColor: Theme.of(context).colorScheme.onSurface, textColor: Theme.of(context).colorScheme.onSurface,
leading: Icon(icon), 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'; import 'package:paperless_mobile/features/labels/cubit/label_cubit.dart';
class SortDocumentsButton extends StatelessWidget { class SortDocumentsButton extends StatelessWidget {
final bool enabled;
const SortDocumentsButton({ const SortDocumentsButton({
super.key, super.key,
this.enabled = true,
}); });
@override @override
@@ -24,47 +26,47 @@ class SortDocumentsButton extends StatelessWidget {
? Icons.arrow_upward ? Icons.arrow_upward
: Icons.arrow_downward), : Icons.arrow_downward),
label: Text(translateSortField(context, state.filter.sortField)), label: Text(translateSortField(context, state.filter.sortField)),
onPressed: () { onPressed: enabled
showModalBottomSheet( ? () {
elevation: 2, showModalBottomSheet(
context: context, elevation: 2,
isScrollControlled: true, context: context,
shape: const RoundedRectangleBorder( isScrollControlled: true,
borderRadius: BorderRadius.only( shape: const RoundedRectangleBorder(
topLeft: Radius.circular(16), borderRadius: BorderRadius.only(
topRight: Radius.circular(16), 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>>(),
), ),
), ),
BlocProvider( builder: (_) => BlocProvider<DocumentsCubit>.value(
create: (context) => LabelCubit<Correspondent>( value: context.read<DocumentsCubit>(),
context.read<LabelRepository<Correspondent>>(), child: MultiBlocProvider(
), providers: [
), BlocProvider(
], create: (context) => LabelCubit(context.read()),
child: SortFieldSelectionBottomSheet( ),
initialSortField: state.filter.sortField, ],
initialSortOrder: state.filter.sortOrder, child: SortFieldSelectionBottomSheet(
onSubmit: (field, order) => initialSortField: state.filter.sortField,
context.read<DocumentsCubit>().updateCurrentFilter( initialSortOrder: state.filter.sortOrder,
(filter) => filter.copyWith( onSubmit: (field, order) => context
sortField: field, .read<DocumentsCubit>()
sortOrder: order, .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 'dart:async';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/label_repository.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_state.dart';
part 'edit_label_cubit.freezed.dart';
class EditLabelCubit<T extends Label> extends Cubit<EditLabelState<T>> { class EditLabelCubit extends Cubit<EditLabelState>
final LabelRepository<T> _repository; with LabelCubitMixin<EditLabelState> {
@override
final LabelRepository labelRepository;
StreamSubscription? _subscription; EditLabelCubit(this.labelRepository) : super(const EditLabelState()) {
labelRepository.addListener(
EditLabelCubit(LabelRepository<T> repository) this,
: _repository = repository, onChanged: (labels) => state.copyWith(
super(EditLabelState<T>(labels: repository.current?.values ?? {})) { correspondents: labels.correspondents,
_subscription = repository.values.listen( documentTypes: labels.documentTypes,
(event) => emit(EditLabelState(labels: event?.values ?? {})), 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 @override
Future<void> close() { Future<void> close() {
_subscription?.cancel(); labelRepository.removeListener(this);
return super.close(); return super.close();
} }
} }

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