Updated onboarding, reformatted files, improved referenced documents view, updated error handling

This commit is contained in:
Anton Stubenbord
2022-11-03 22:15:36 +01:00
parent 2f2312d5f3
commit 40133b6e0e
117 changed files with 1788 additions and 1021 deletions

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
assets/images/success.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

View File

@@ -9,13 +9,20 @@ class ConnectivityCubit extends Cubit<ConnectivityState> {
final ConnectivityStatusService connectivityStatusService;
late final StreamSubscription<bool> _sub;
ConnectivityCubit(this.connectivityStatusService) : super(ConnectivityState.undefined);
ConnectivityCubit(this.connectivityStatusService)
: super(ConnectivityState.undefined);
Future<void> initialize() async {
final bool isConnected = await connectivityStatusService.isConnectedToInternet();
emit(isConnected ? ConnectivityState.connected : ConnectivityState.notConnected);
_sub = connectivityStatusService.connectivityChanges().listen((isConnected) {
emit(isConnected ? ConnectivityState.connected : ConnectivityState.notConnected);
final bool isConnected =
await connectivityStatusService.isConnectedToInternet();
emit(isConnected
? ConnectivityState.connected
: ConnectivityState.notConnected);
_sub =
connectivityStatusService.connectivityChanges().listen((isConnected) {
emit(isConnected
? ConnectivityState.connected
: ConnectivityState.notConnected);
});
}

View File

@@ -0,0 +1,46 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injectable/injectable.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
///
/// Class for handling generic errors which usually only require to inform the user via a Snackbar
/// or similar that an error has occurred.
///
@singleton
class GlobalErrorCubit extends Cubit<GlobalErrorState> {
static const _waitBeforeNextErrorDuration = Duration(seconds: 5);
GlobalErrorCubit() : super(GlobalErrorState.initial);
///
/// Adds a new error to this bloc. If the new error is equal to the current error, the new error
/// will not be published unless the previous error occured over 5 seconds ago.
///
void add(ErrorMessage error) {
final now = DateTime.now();
if (error != state.error || (error == state.error && _canEmitNewError())) {
emit(GlobalErrorState(error: error, errorTimestamp: now));
}
}
bool _canEmitNewError() {
if (state.errorTimestamp != null) {
return DateTime.now().difference(state.errorTimestamp!).inSeconds >= 5;
}
return true;
}
void reset() {
emit(GlobalErrorState.initial);
}
}
class GlobalErrorState {
static const GlobalErrorState initial = GlobalErrorState();
final ErrorMessage? error;
final DateTime? errorTimestamp;
const GlobalErrorState({this.error, this.errorTimestamp});
bool get hasError => error != null;
}

View File

@@ -1,40 +1,75 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/bloc/global_error_cubit.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:paperless_mobile/features/labels/model/label.model.dart';
import 'package:paperless_mobile/features/labels/repository/label_repository.dart';
abstract class LabelCubit<T extends Label> extends Cubit<Map<int, T>> {
final LabelRepository labelRepository;
LabelCubit(this.labelRepository) : super({});
final GlobalErrorCubit errorCubit;
LabelCubit(this.labelRepository, this.errorCubit) : super({});
@protected
void loadFrom(Iterable<T> items) => emit(Map.fromIterable(items, key: (e) => (e as T).id!));
void loadFrom(Iterable<T> items) =>
emit(Map.fromIterable(items, key: (e) => (e as T).id!));
Future<T> add(T item) async {
Future<T> add(
T item, {
bool propagateEventOnError = true,
}) async {
assert(item.id == null);
try {
final addedItem = await save(item);
final newState = {...state};
newState.putIfAbsent(addedItem.id!, () => addedItem);
emit(newState);
return addedItem;
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
}
return Future.error(error);
}
}
Future<T> replace(T item) async {
Future<T> replace(
T item, {
bool propagateEventOnError = true,
}) async {
assert(item.id != null);
try {
final updatedItem = await update(item);
final newState = {...state};
newState[item.id!] = updatedItem;
emit(newState);
return updatedItem;
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
}
return Future.error(error);
}
}
Future<void> remove(T item) async {
Future<void> remove(
T item, {
bool propagateEventOnError = true,
}) async {
assert(item.id != null);
if (state.containsKey(item.id)) {
try {
final deletedId = await delete(item);
final newState = {...state};
newState.remove(deletedId);
emit(newState);
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
}
return Future.error(error);
}
}
}

View File

@@ -22,13 +22,19 @@ class AuthenticationInterceptor implements InterceptorContract {
}
return request.copyWith(
//Append server Url
url: Uri.parse(authState.authentication!.serverUrl + request.url.toString()),
url: Uri.parse(
authState.authentication!.serverUrl + request.url.toString()),
headers: authState.authentication!.token.isEmpty
? request.headers
: {...request.headers, 'Authorization': 'Token ${authState.authentication!.token}'},
: {
...request.headers,
'Authorization': 'Token ${authState.authentication!.token}'
},
);
}
@override
Future<BaseResponse> interceptResponse({required BaseResponse response}) async => response;
Future<BaseResponse> interceptResponse(
{required BaseResponse response}) async =>
response;
}

View File

@@ -4,24 +4,22 @@ import 'package:paperless_mobile/features/login/bloc/authentication_cubit.dart';
import 'package:http_interceptor/http_interceptor.dart';
import 'package:injectable/injectable.dart';
@Deprecated("Delegated to TimeoutClient!")
@injectable
class ConnectionStateInterceptor implements InterceptorContract {
final AuthenticationCubit authenticationCubit;
final ConnectivityStatusService connectivityStatusService;
ConnectionStateInterceptor(this.authenticationCubit, this.connectivityStatusService);
ConnectionStateInterceptor(
this.authenticationCubit, this.connectivityStatusService);
@override
Future<BaseRequest> interceptRequest({required BaseRequest request}) async {
if (!(await connectivityStatusService.isConnectedToInternet())) {
throw const ErrorMessage(ErrorCode.deviceOffline);
}
final isServerReachable = await connectivityStatusService.isServerReachable(request.url.origin);
if (!isServerReachable) {
throw const ErrorMessage(ErrorCode.serverUnreachable);
}
return request;
}
@override
Future<BaseResponse> interceptResponse({required BaseResponse response}) async => response;
Future<BaseResponse> interceptResponse(
{required BaseResponse response}) async =>
response;
}

View File

@@ -14,12 +14,15 @@ class LanguageHeaderInterceptor implements InterceptorContract {
if (appSettingsCubit.state.preferredLocaleSubtag == "en") {
languages = "en";
} else {
languages = appSettingsCubit.state.preferredLocaleSubtag + ",en;q=0.7,en-US;q=0.6";
languages = appSettingsCubit.state.preferredLocaleSubtag +
",en;q=0.7,en-US;q=0.6";
}
request.headers.addAll({"Accept-Language": languages});
return request;
}
@override
Future<BaseResponse> interceptResponse({required BaseResponse response}) async => response;
Future<BaseResponse> interceptResponse(
{required BaseResponse response}) async =>
response;
}

View File

@@ -7,11 +7,14 @@ const interceptedRoutes = ['thumb/'];
@injectable
class ResponseConversionInterceptor implements InterceptorContract {
@override
Future<BaseRequest> interceptRequest({required BaseRequest request}) async => request;
Future<BaseRequest> interceptRequest({required BaseRequest request}) async =>
request;
@override
Future<BaseResponse> interceptResponse({required BaseResponse response}) async {
final String requestUrl = response.request?.url.toString().split("?").first ?? '';
Future<BaseResponse> interceptResponse(
{required BaseResponse response}) async {
final String requestUrl =
response.request?.url.toString().split("?").first ?? '';
if (response.request?.method == "GET" &&
interceptedRoutes.any((element) => requestUrl.endsWith(element))) {
final resp = response as Response;

View File

@@ -3,7 +3,8 @@ import 'dart:typed_data';
import 'dart:convert';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:paperless_mobile/core/type/json.dart';
import 'package:paperless_mobile/core/service/connectivity_status.service.dart';
import 'package:paperless_mobile/core/type/types.dart';
import 'package:paperless_mobile/di_initializer.dart';
import 'package:http/http.dart';
import 'package:injectable/injectable.dart';
@@ -14,13 +15,17 @@ import 'package:injectable/injectable.dart';
@Injectable(as: BaseClient)
@Named("timeoutClient")
class TimeoutClient implements BaseClient {
final ConnectivityStatusService connectivityStatusService;
static const Duration requestTimeout = Duration(seconds: 25);
TimeoutClient(this.connectivityStatusService);
@override
Future<StreamedResponse> send(BaseRequest request) async {
return getIt<BaseClient>().send(request).timeout(
requestTimeout,
onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
onTimeout: () =>
Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
);
}
@@ -32,32 +37,38 @@ class TimeoutClient implements BaseClient {
@override
Future<Response> delete(Uri url,
{Map<String, String>? headers, Object? body, Encoding? encoding}) async {
await _handleOfflineState();
return _handle400Error(
await getIt<BaseClient>()
.delete(url, headers: headers, body: body, encoding: encoding)
.timeout(
requestTimeout,
onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
onTimeout: () =>
Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
),
);
}
@override
Future<Response> get(Uri url, {Map<String, String>? headers}) async {
await _handleOfflineState();
return _handle400Error(
await getIt<BaseClient>().get(url, headers: headers).timeout(
requestTimeout,
onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
onTimeout: () =>
Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
),
);
}
@override
Future<Response> head(Uri url, {Map<String, String>? headers}) async {
await _handleOfflineState();
return _handle400Error(
await getIt<BaseClient>().head(url, headers: headers).timeout(
requestTimeout,
onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
onTimeout: () =>
Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
),
);
}
@@ -65,12 +76,14 @@ class TimeoutClient implements BaseClient {
@override
Future<Response> patch(Uri url,
{Map<String, String>? headers, Object? body, Encoding? encoding}) async {
await _handleOfflineState();
return _handle400Error(
await getIt<BaseClient>()
.patch(url, headers: headers, body: body, encoding: encoding)
.timeout(
requestTimeout,
onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
onTimeout: () =>
Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
),
);
}
@@ -78,10 +91,14 @@ class TimeoutClient implements BaseClient {
@override
Future<Response> post(Uri url,
{Map<String, String>? headers, Object? body, Encoding? encoding}) async {
await _handleOfflineState();
return _handle400Error(
await getIt<BaseClient>().post(url, headers: headers, body: body, encoding: encoding).timeout(
await getIt<BaseClient>()
.post(url, headers: headers, body: body, encoding: encoding)
.timeout(
requestTimeout,
onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
onTimeout: () =>
Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
),
);
}
@@ -89,27 +106,35 @@ class TimeoutClient implements BaseClient {
@override
Future<Response> put(Uri url,
{Map<String, String>? headers, Object? body, Encoding? encoding}) async {
await _handleOfflineState();
return _handle400Error(
await getIt<BaseClient>().put(url, headers: headers, body: body, encoding: encoding).timeout(
await getIt<BaseClient>()
.put(url, headers: headers, body: body, encoding: encoding)
.timeout(
requestTimeout,
onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
onTimeout: () =>
Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
),
);
}
@override
Future<String> read(Uri url, {Map<String, String>? headers}) async {
await _handleOfflineState();
return getIt<BaseClient>().read(url, headers: headers).timeout(
requestTimeout,
onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
onTimeout: () =>
Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
);
}
@override
Future<Uint8List> readBytes(Uri url, {Map<String, String>? headers}) {
Future<Uint8List> readBytes(Uri url, {Map<String, String>? headers}) async {
await _handleOfflineState();
return getIt<BaseClient>().readBytes(url, headers: headers).timeout(
requestTimeout,
onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
onTimeout: () =>
Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
);
}
@@ -117,11 +142,12 @@ class TimeoutClient implements BaseClient {
if (response.statusCode == 400) {
// try to parse contained error message, otherwise return response
final JSON json = jsonDecode(utf8.decode(response.bodyBytes));
final Map<String, String> errorMessages = {};
final PaperlessValidationErrors errorMessages = {};
//TODO: This could be simplified, look at error message format of paperless-ngx
for (final entry in json.entries) {
if (entry.value is List) {
errorMessages.putIfAbsent(entry.key, () => (entry.value as List).cast<String>().first);
errorMessages.putIfAbsent(
entry.key, () => (entry.value as List).cast<String>().first);
} else if (entry.value is String) {
errorMessages.putIfAbsent(entry.key, () => entry.value);
} else {
@@ -132,4 +158,10 @@ class TimeoutClient implements BaseClient {
}
return response;
}
Future<void> _handleOfflineState() async {
if (!(await connectivityStatusService.isConnectedToInternet())) {
throw const ErrorMessage(ErrorCode.deviceOffline);
}
}
}

View File

@@ -17,12 +17,15 @@ class ConnectivityStatusServiceImpl implements ConnectivityStatusService {
@override
Stream<bool> connectivityChanges() {
return connectivity.onConnectivityChanged.map(_hasActiveInternetConnection).asBroadcastStream();
return connectivity.onConnectivityChanged
.map(_hasActiveInternetConnection)
.asBroadcastStream();
}
@override
Future<bool> isConnectedToInternet() async {
return _hasActiveInternetConnection(await (Connectivity().checkConnectivity()));
return _hasActiveInternetConnection(
await (Connectivity().checkConnectivity()));
}
@override

View File

@@ -13,8 +13,8 @@ import 'package:injectable/injectable.dart';
import 'package:web_socket_channel/io.dart';
abstract class StatusService {
Future<void> startListeningBeforeDocumentUpload(
String httpUrl, AuthenticationInformation credentials, String documentFileName);
Future<void> startListeningBeforeDocumentUpload(String httpUrl,
AuthenticationInformation credentials, String documentFileName);
}
@Singleton(as: StatusService)
@@ -86,9 +86,11 @@ class LongPollingStatusService implements StatusService {
do {
final response = await httpClient.get(
Uri.parse('$httpUrl/api/documents/?query=$documentFileName added:${formatDate(today)}'),
Uri.parse(
'$httpUrl/api/documents/?query=$documentFileName added:${formatDate(today)}'),
);
final data = PagedSearchResult.fromJson(jsonDecode(response.body), DocumentModel.fromJson);
final data = PagedSearchResult.fromJson(
jsonDecode(response.body), DocumentModel.fromJson);
if (data.count > 0) {
consumptionFinished = true;
final docId = data.results[0].id;

View File

@@ -34,11 +34,13 @@ class LocalVault {
}
Future<ClientCertificate?> loadCertificate() async {
return loadAuthenticationInformation().then((value) => value?.clientCertificate);
return loadAuthenticationInformation()
.then((value) => value?.clientCertificate);
}
Future<bool> storeApplicationSettings(ApplicationSettingsState settings) {
return sharedPreferences.setString(applicationSettingsKey, json.encode(settings.toJson()));
return sharedPreferences.setString(
applicationSettingsKey, json.encode(settings.toJson()));
}
Future<ApplicationSettingsState?> loadApplicationSettings() async {

View File

@@ -1 +0,0 @@
typedef JSON = Map<String, dynamic>;

2
lib/core/type/types.dart Normal file
View File

@@ -0,0 +1,2 @@
typedef JSON = Map<String, dynamic>;
typedef PaperlessValidationErrors = Map<String, String>;

View File

@@ -4,7 +4,7 @@ import 'dart:typed_data';
import 'package:paperless_mobile/core/logic/timeout_client.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:paperless_mobile/core/type/json.dart';
import 'package:paperless_mobile/core/type/types.dart';
import 'package:paperless_mobile/di_initializer.dart';
import 'package:http/http.dart';
import 'package:path_provider/path_provider.dart';
@@ -45,7 +45,10 @@ Future<List<T>> getCollection<T>(
if (body['count'] == 0) {
return <T>[];
} else {
return body['results'].cast<JSON>().map<T>((result) => fromJson(result)).toList();
return body['results']
.cast<JSON>()
.map<T>((result) => fromJson(result))
.toList();
}
}
}

View File

@@ -5,9 +5,12 @@ import 'package:flutter/material.dart';
class ElevatedConfirmationButton extends StatefulWidget {
factory ElevatedConfirmationButton.icon(BuildContext context,
{required void Function() onPressed, required Icon icon, required Widget label}) {
{required void Function() onPressed,
required Icon icon,
required Widget label}) {
final double scale = MediaQuery.maybeOf(context)?.textScaleFactor ?? 1;
final double gap = scale <= 1 ? 8 : lerpDouble(8, 4, math.min(scale - 1, 1))!;
final double gap =
scale <= 1 ? 8 : lerpDouble(8, 4, math.min(scale - 1, 1))!;
return ElevatedConfirmationButton(
child: Row(
mainAxisSize: MainAxisSize.min,
@@ -30,10 +33,12 @@ class ElevatedConfirmationButton extends StatefulWidget {
final Widget child;
final Widget confirmWidget;
@override
State<ElevatedConfirmationButton> createState() => _ElevatedConfirmationButtonState();
State<ElevatedConfirmationButton> createState() =>
_ElevatedConfirmationButtonState();
}
class _ElevatedConfirmationButtonState extends State<ElevatedConfirmationButton> {
class _ElevatedConfirmationButtonState
extends State<ElevatedConfirmationButton> {
bool _clickedOnce = false;
double? _originalWidth;
final GlobalKey _originalWidgetKey = GlobalKey();
@@ -46,8 +51,10 @@ class _ElevatedConfirmationButtonState extends State<ElevatedConfirmationButton>
backgroundColor: MaterialStateProperty.all(widget.color),
),
onPressed: () {
_originalWidth =
(_originalWidgetKey.currentContext?.findRenderObject() as RenderBox).size.width;
_originalWidth = (_originalWidgetKey.currentContext
?.findRenderObject() as RenderBox)
.size
.width;
setState(() => _clickedOnce = true);
},
child: widget.child,

View File

@@ -38,10 +38,13 @@ class DocumentsListLoadingWidget extends StatelessWidget {
titleLengths[r.nextInt(titleLengths.length - 1)];
return ListTile(
isThreeLine: true,
leading: Container(
leading: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
color: Colors.white,
height: 50,
width: 50,
width: 35,
),
),
title: Container(
padding: const EdgeInsets.symmetric(vertical: 2.0),
@@ -65,7 +68,7 @@ class DocumentsListLoadingWidget extends StatelessWidget {
spacing: 2.0,
children: List.generate(
tagCount,
(index) => Chip(
(index) => InputChip(
label: Text(tags[r.nextInt(tags.length)]),
),
),

View File

@@ -71,8 +71,9 @@ class HighlightedText extends StatelessWidget {
int _start = 0;
String _text = caseSensitive ? text : text.toLowerCase();
List<String> _highlights =
caseSensitive ? highlights : highlights.map((e) => e.toLowerCase()).toList();
List<String> _highlights = caseSensitive
? highlights
: highlights.map((e) => e.toLowerCase()).toList();
while (true) {
Map<int, String> _highlightsMap = {}; //key (index), value (highlight).
@@ -95,7 +96,8 @@ class HighlightedText extends StatelessWidget {
_spans.add(_highlightSpan(_currentHighlight));
_start += _currentHighlight.length;
} else {
_spans.add(_normalSpan(text.substring(_start, _currentIndex), context));
_spans
.add(_normalSpan(text.substring(_start, _currentIndex), context));
_spans.add(_highlightSpan(_currentHighlight));
_start = _currentIndex + _currentHighlight.length;
}

View File

@@ -11,7 +11,9 @@ class OfflineWidget extends StatelessWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.mood_bad, size: (Theme.of(context).iconTheme.size ?? 24) * 3),
Icon(Icons.wifi_off,
color: Theme.of(context).disabledColor,
size: (Theme.of(context).iconTheme.size ?? 24) * 3),
Text(
S.of(context).offlineWidgetText,
textAlign: TextAlign.center,

View File

@@ -18,7 +18,8 @@ abstract class RegisterModule {
@singleton
LocalAuthentication get localAuthentication => LocalAuthentication();
@singleton
EncryptedSharedPreferences get encryptedSharedPreferences => EncryptedSharedPreferences();
EncryptedSharedPreferences get encryptedSharedPreferences =>
EncryptedSharedPreferences();
@singleton
SecurityContext get securityContext => SecurityContext();
@singleton
@@ -28,7 +29,8 @@ abstract class RegisterModule {
/// Factory method creating an [HttpClient] with the currently registered [SecurityContext].
///
HttpClient getHttpClient(SecurityContext securityContext) =>
HttpClient(context: securityContext)..connectionTimeout = const Duration(seconds: 10);
HttpClient(context: securityContext)
..connectionTimeout = const Duration(seconds: 10);
///
/// Factory method creating a [InterceptedClient] on top of the currently registered [HttpClient].
@@ -50,6 +52,6 @@ abstract class RegisterModule {
client: IOClient(client),
);
CacheManager getCacheManager(BaseClient client) =>
CacheManager(Config('cacheKey', fileService: HttpFileService(httpClient: client)));
CacheManager getCacheManager(BaseClient client) => CacheManager(
Config('cacheKey', fileService: HttpFileService(httpClient: client)));
}

View File

@@ -1,13 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:introduction_screen/introduction_screen.dart';
import 'package:paperless_mobile/di_initializer.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/app_intro/widgets/biometric_authentication_intro_slide.dart';
import 'package:paperless_mobile/features/app_intro/widgets/configuration_done_intro_slide.dart';
import 'package:paperless_mobile/features/app_intro/widgets/welcome_intro_slide.dart';
import 'package:paperless_mobile/features/home/view/home_page.dart';
import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart';
import 'package:intro_slider/intro_slider.dart';
import 'package:paperless_mobile/features/settings/view/widgets/biometric_authentication_setting.dart';
import 'package:paperless_mobile/features/settings/view/widgets/language_selection_setting.dart';
import 'package:paperless_mobile/features/settings/view/widgets/theme_mode_setting.dart';
import 'package:paperless_mobile/generated/l10n.dart';
class ApplicationIntroSlideshow extends StatelessWidget {
const ApplicationIntroSlideshow({super.key});
@@ -16,24 +15,82 @@ class ApplicationIntroSlideshow extends StatelessWidget {
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async => false,
child: IntroSlider(
renderDoneBtn: TextButton(
child: Text("GO"), //TODO: INTL
onPressed: () {
Navigator.pop(context);
},
),
backgroundColorAllTabs: Theme.of(context).canvasColor,
onDonePress: () => Navigator.of(context)
.pushReplacement(MaterialPageRoute(builder: (context) => const HomePage())),
listCustomTabs: [
const WelcomeIntroSlide(),
BlocProvider.value(
child: BlocProvider.value(
value: getIt<ApplicationSettingsCubit>(),
child: const BiometricAuthenticationIntroSlide(),
child: IntroductionScreen(
globalBackgroundColor: Theme.of(context).canvasColor,
showDoneButton: true,
next: Text(S.of(context).onboardingNextButtonLabel),
done: Text(S.of(context).onboardingDoneButtonLabel),
onDone: () => Navigator.pop(context),
dotsDecorator: DotsDecorator(
color: Theme.of(context).colorScheme.onBackground,
activeColor: Theme.of(context).colorScheme.primary,
activeSize: Size(16.0, 8.0),
activeShape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(25.0)),
),
),
pages: [
PageViewModel(
titleWidget: Text(
"Always right at your fingertip",
style: Theme.of(context).textTheme.titleLarge,
),
image: Padding(
padding: const EdgeInsets.all(8.0),
child: Image.asset("assets/images/organize_documents.png"),
),
bodyWidget: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Text(
"Organizing documents was never this easy",
textAlign: TextAlign.center,
),
],
),
),
PageViewModel(
titleWidget: Text(
"Accessible only by you",
style: Theme.of(context).textTheme.titleLarge,
),
image: Padding(
padding: const EdgeInsets.all(8.0),
child: Image.asset("assets/images/secure_documents.png"),
),
bodyWidget: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Text(
"Secure your documents with biometric authentication and client certificates",
textAlign: TextAlign.center,
),
],
),
),
PageViewModel(
titleWidget: Text(
"You're almost done",
style: Theme.of(context).textTheme.titleLarge,
),
image: Padding(
padding: const EdgeInsets.all(8.0),
child: Image.asset("assets/images/success.png"),
),
bodyWidget: Column(
children: const [
BiometricAuthenticationSetting(),
LanguageSelectionSetting(),
ThemeModeSetting(),
],
),
),
],
),
const ConfigurationDoneIntroSlide(),
].padded(const EdgeInsets.all(16.0)),
),
);
}

View File

@@ -16,7 +16,8 @@ class BiometricAuthenticationIntroSlide extends StatefulWidget {
_BiometricAuthenticationIntroSlideState();
}
class _BiometricAuthenticationIntroSlideState extends State<BiometricAuthenticationIntroSlide> {
class _BiometricAuthenticationIntroSlideState
extends State<BiometricAuthenticationIntroSlide> {
@override
Widget build(BuildContext context) {
//TODO: INTL
@@ -58,9 +59,12 @@ class _BiometricAuthenticationIntroSlideState extends State<BiometricAuthenticat
return ElevatedButton(
child: Text("Enable"),
onPressed: () {
final settings = BlocProvider.of<ApplicationSettingsCubit>(context).state;
final settings =
BlocProvider.of<ApplicationSettingsCubit>(context)
.state;
getIt<AuthenticationService>()
.authenticateLocalUser("Please authenticate to secure Paperless Mobile")
.authenticateLocalUser(
"Please authenticate to secure Paperless Mobile")
.then((isEnabled) {
if (!isEnabled) {
showSnackBar(context,

View File

@@ -1,25 +1,26 @@
import 'package:flutter/material.dart';
import 'package:flutter/src/widgets/framework.dart';
class WelcomeIntroSlide extends StatelessWidget {
const WelcomeIntroSlide({super.key});
@override
Widget build(BuildContext context) {
//TODO: INTL
return Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
"Welcome to Paperless Mobile!",
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
Text(
"Manage and add your documents on the go!",
Padding(
padding: const EdgeInsets.all(16),
child: Text(
"Manage, share and create documents on the go without any compromises!",
textAlign: TextAlign.center,
style: TextStyle(color: Theme.of(context).hintColor),
),
),
Align(child: Image.asset("assets/logos/paperless_logo_green.png")),
],
);
}

View File

@@ -1,6 +1,8 @@
import 'dart:typed_data';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/bloc/global_error_cubit.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_state.dart';
import 'package:paperless_mobile/features/documents/model/document.model.dart';
import 'package:paperless_mobile/features/documents/model/document_filter.dart';
@@ -11,8 +13,10 @@ import 'package:injectable/injectable.dart';
@singleton
class DocumentsCubit extends Cubit<DocumentsState> {
final DocumentRepository documentRepository;
final GlobalErrorCubit errorCubit;
DocumentsCubit(this.documentRepository) : super(DocumentsState.initial);
DocumentsCubit(this.documentRepository, this.errorCubit)
: super(DocumentsState.initial);
Future<void> addDocument(
Uint8List bytes,
@@ -23,7 +27,9 @@ class DocumentsCubit extends Cubit<DocumentsState> {
int? correspondent,
List<int>? tags,
DateTime? createdAt,
bool propagateEventOnError = true,
}) async {
try {
await documentRepository.create(
bytes,
fileName,
@@ -33,91 +39,205 @@ class DocumentsCubit extends Cubit<DocumentsState> {
tags: tags,
createdAt: createdAt,
);
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
} else {
rethrow;
}
}
// documentRepository
// .waitForConsumptionFinished(fileName, title)
// .then((value) => onConsumptionFinished(value));
}
Future<void> removeDocument(DocumentModel document) async {
Future<void> removeDocument(
DocumentModel document, {
bool propagateEventOnError = true,
}) async {
try {
await documentRepository.delete(document);
return await reloadDocuments();
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
} else {
rethrow;
}
}
}
Future<void> bulkRemoveDocuments(List<DocumentModel> documents) async {
Future<void> bulkRemoveDocuments(List<DocumentModel> documents,
{bool propagateEventOnError = true}) async {
try {
await documentRepository.bulkDelete(documents);
return await reloadDocuments();
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
} else {
rethrow;
}
}
}
Future<void> updateDocument(DocumentModel document) async {
Future<void> updateDocument(
DocumentModel document, {
bool propagateEventOnError = true,
}) async {
try {
await documentRepository.update(document);
await reloadDocuments();
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
} else {
rethrow;
}
}
}
Future<void> loadDocuments() async {
Future<void> loadDocuments({
bool propagateEventOnError = true,
}) async {
try {
final result = await documentRepository.find(state.filter);
emit(DocumentsState(
isLoaded: true,
value: [...state.value, result],
filter: state.filter,
));
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
} else {
rethrow;
}
}
}
Future<void> reloadDocuments() async {
Future<void> reloadDocuments({
bool propagateEventOnError = true,
}) async {
if (state.currentPageNumber >= 5) {
return _bulkReloadDocuments();
}
var newPages = <PagedSearchResult>[];
try {
for (final page in state.value) {
final result = await documentRepository.find(state.filter.copyWith(page: page.pageKey));
final result = await documentRepository
.find(state.filter.copyWith(page: page.pageKey));
newPages.add(result);
}
emit(DocumentsState(isLoaded: true, value: newPages, filter: state.filter));
emit(DocumentsState(
isLoaded: true, value: newPages, filter: state.filter));
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
} else {
rethrow;
}
}
}
Future<void> _bulkReloadDocuments() async {
final result = await documentRepository
.find(state.filter.copyWith(page: 1, pageSize: state.documents.length));
emit(DocumentsState(isLoaded: true, value: [result], filter: state.filter));
Future<void> _bulkReloadDocuments({
bool propagateEventOnError = true,
}) async {
try {
final result = await documentRepository.find(
state.filter.copyWith(page: 1, pageSize: state.documents.length));
emit(DocumentsState(
isLoaded: true, value: [result], filter: state.filter));
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
} else {
rethrow;
}
}
}
Future<void> loadMore() async {
Future<void> loadMore({
bool propagateEventOnError = true,
}) async {
if (state.isLastPageLoaded) {
return;
}
final newFilter = state.filter.copyWith(page: state.filter.page + 1);
try {
final result = await documentRepository.find(newFilter);
emit(DocumentsState(isLoaded: true, value: [...state.value, result], filter: newFilter));
emit(DocumentsState(
isLoaded: true, value: [...state.value, result], filter: newFilter));
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
} else {
rethrow;
}
}
}
Future<void> assignAsn(DocumentModel document) async {
Future<void> assignAsn(
DocumentModel document, {
bool propagateEventOnError = true,
}) async {
try {
if (document.archiveSerialNumber == null) {
final int asn = await documentRepository.findNextAsn();
updateDocument(document.copyWith(archiveSerialNumber: asn));
}
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
} else {
rethrow;
}
}
}
///
/// Update filter state and automatically reload documents. Always resets page to 1.
/// Use [DocumentsCubit.loadMore] to load more data.
Future<void> updateFilter({
final DocumentFilter filter = DocumentFilter.initial,
}) async {
Future<void> updateFilter(
{final DocumentFilter filter = DocumentFilter.initial,
bool propagateEventOnError = true}) async {
try {
final result = await documentRepository.find(filter.copyWith(page: 1));
emit(DocumentsState(filter: filter, value: [result], isLoaded: true));
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
} else {
rethrow;
}
}
}
///
/// Convenience method which allows to directly use [DocumentFilter.copyWith] on the current filter.
///
Future<void> updateCurrentFilter(final DocumentFilter Function(DocumentFilter) transformFn) {
Future<void> updateCurrentFilter(
final DocumentFilter Function(DocumentFilter) transformFn, {
bool propagateEventOnError = true,
}) async {
try {
return updateFilter(filter: transformFn(state.filter));
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
return errorCubit.add(error);
} else {
rethrow;
}
}
}
void toggleDocumentSelection(DocumentModel model) {
if (state.selection.contains(model)) {
emit(
state.copyWith(
selection: state.selection.where((element) => element.id != model.id).toList(),
selection: state.selection
.where((element) => element.id != model.id)
.toList(),
),
);
} else {

View File

@@ -59,7 +59,8 @@ class DocumentsState extends Equatable {
}
List<DocumentModel> get documents {
return value.fold([], (previousValue, element) => [...previousValue, ...element.results]);
return value.fold(
[], (previousValue, element) => [...previousValue, ...element.results]);
}
DocumentsState copyWith({

View File

@@ -1,4 +1,6 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/bloc/global_error_cubit.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:paperless_mobile/di_initializer.dart';
import 'package:paperless_mobile/features/documents/bloc/saved_view_state.dart';
import 'package:paperless_mobile/features/documents/model/saved_view.model.dart';
@@ -7,13 +9,25 @@ import 'package:injectable/injectable.dart';
@singleton
class SavedViewCubit extends Cubit<SavedViewState> {
SavedViewCubit() : super(SavedViewState(value: {}));
final GlobalErrorCubit errorCubit;
SavedViewCubit(this.errorCubit) : super(SavedViewState(value: {}));
void selectView(SavedView? view) {
void selectView(SavedView? view, {bool propagateEventOnError = true}) {
try {
emit(SavedViewState(value: state.value, selectedSavedViewId: view?.id));
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
}
rethrow;
}
}
Future<SavedView> add(SavedView view) async {
Future<SavedView> add(
SavedView view, {
bool propagateEventOnError = true,
}) async {
try {
final savedView = await getIt<SavedViewsRepository>().save(view);
emit(
SavedViewState(
@@ -22,26 +36,52 @@ class SavedViewCubit extends Cubit<SavedViewState> {
),
);
return savedView;
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
}
rethrow;
}
}
Future<int> remove(SavedView view) async {
Future<int> remove(
SavedView view, {
bool propagateEventOnError = true,
}) async {
try {
final id = await getIt<SavedViewsRepository>().delete(view);
final newValue = {...state.value};
newValue.removeWhere((key, value) => key == id);
emit(
SavedViewState(
value: newValue,
selectedSavedViewId:
view.id == state.selectedSavedViewId ? null : state.selectedSavedViewId,
selectedSavedViewId: view.id == state.selectedSavedViewId
? null
: state.selectedSavedViewId,
),
);
return id;
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
}
rethrow;
}
}
Future<void> initialize() async {
Future<void> initialize({
bool propagateEventOnError = true,
}) async {
try {
final views = await getIt<SavedViewsRepository>().getAll();
final values = {for (var element in views) element.id!: element};
emit(SavedViewState(value: values));
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
}
rethrow;
}
}
void resetSelection() {

View File

@@ -1,4 +1,4 @@
import 'package:paperless_mobile/core/type/json.dart';
import 'package:paperless_mobile/core/type/types.dart';
class BulkEditAction {
final List<int> documents;

View File

@@ -1,7 +1,7 @@
// ignore_for_file: non_constant_identifier_names
import 'package:equatable/equatable.dart';
import 'package:paperless_mobile/core/type/json.dart';
import 'package:paperless_mobile/core/type/types.dart';
import 'package:paperless_mobile/features/documents/model/query_parameters/id_query_parameter.dart';
import 'package:paperless_mobile/features/documents/model/query_parameters/tags_query.dart';

View File

@@ -70,19 +70,23 @@ class DocumentFilter with EquatableMixin {
// Add/subtract one day in the following because paperless uses gt/lt not gte/lte
if (addedDateAfter != null) {
sb.write("&added__date__gt=${dateFormat.format(addedDateAfter!.subtract(_oneDay))}");
sb.write(
"&added__date__gt=${dateFormat.format(addedDateAfter!.subtract(_oneDay))}");
}
if (addedDateBefore != null) {
sb.write("&added__date__lt=${dateFormat.format(addedDateBefore!.add(_oneDay))}");
sb.write(
"&added__date__lt=${dateFormat.format(addedDateBefore!.add(_oneDay))}");
}
if (createdDateAfter != null) {
sb.write("&created__date__gt=${dateFormat.format(createdDateAfter!.subtract(_oneDay))}");
sb.write(
"&created__date__gt=${dateFormat.format(createdDateAfter!.subtract(_oneDay))}");
}
if (createdDateBefore != null) {
sb.write("&created__date__lt=${dateFormat.format(createdDateBefore!.add(_oneDay))}");
sb.write(
"&created__date__lt=${dateFormat.format(createdDateBefore!.add(_oneDay))}");
}
return sb.toString();

View File

@@ -1,5 +1,5 @@
import 'package:equatable/equatable.dart';
import 'package:paperless_mobile/core/type/json.dart';
import 'package:paperless_mobile/core/type/types.dart';
import 'package:paperless_mobile/features/documents/model/document_filter.dart';
import 'package:paperless_mobile/features/documents/model/query_parameters/correspondent_query.dart';
import 'package:paperless_mobile/features/documents/model/query_parameters/document_type_query.dart';
@@ -83,15 +83,20 @@ class FilterRule with EquatableMixin {
: TagsQuery.fromIds([...filter.tags.ids, int.parse(value!)]),
);
case createdBeforeRule:
return filter.copyWith(createdDateBefore: value == null ? null : DateTime.parse(value!));
return filter.copyWith(
createdDateBefore: value == null ? null : DateTime.parse(value!));
case createdAfterRule:
return filter.copyWith(createdDateAfter: value == null ? null : DateTime.parse(value!));
return filter.copyWith(
createdDateAfter: value == null ? null : DateTime.parse(value!));
case addedBeforeRule:
return filter.copyWith(addedDateBefore: value == null ? null : DateTime.parse(value!));
return filter.copyWith(
addedDateBefore: value == null ? null : DateTime.parse(value!));
case addedAfterRule:
return filter.copyWith(addedDateAfter: value == null ? null : DateTime.parse(value!));
return filter.copyWith(
addedDateAfter: value == null ? null : DateTime.parse(value!));
case titleAndContentRule:
return filter.copyWith(queryText: value, queryType: QueryType.titleAndContent);
return filter.copyWith(
queryText: value, queryType: QueryType.titleAndContent);
case extendedRule:
return filter.copyWith(queryText: value, queryType: QueryType.extended);
//TODO: Add currently unused rules
@@ -109,25 +114,29 @@ class FilterRule with EquatableMixin {
filterRules.add(FilterRule(correspondentRule, null));
}
if (filter.correspondent.isSet) {
filterRules.add(FilterRule(correspondentRule, filter.correspondent.id!.toString()));
filterRules.add(
FilterRule(correspondentRule, filter.correspondent.id!.toString()));
}
if (filter.documentType.onlyNotAssigned) {
filterRules.add(FilterRule(documentTypeRule, null));
}
if (filter.documentType.isSet) {
filterRules.add(FilterRule(documentTypeRule, filter.documentType.id!.toString()));
filterRules.add(
FilterRule(documentTypeRule, filter.documentType.id!.toString()));
}
if (filter.storagePath.onlyNotAssigned) {
filterRules.add(FilterRule(storagePathRule, null));
}
if (filter.storagePath.isSet) {
filterRules.add(FilterRule(storagePathRule, filter.storagePath.id!.toString()));
filterRules
.add(FilterRule(storagePathRule, filter.storagePath.id!.toString()));
}
if (filter.tags.onlyNotAssigned) {
filterRules.add(FilterRule(tagRule, null));
}
if (filter.tags.isSet) {
filterRules.addAll(filter.tags.ids.map((id) => FilterRule(tagRule, id.toString())));
filterRules.addAll(
filter.tags.ids.map((id) => FilterRule(tagRule, id.toString())));
}
if (filter.queryText != null) {
@@ -147,16 +156,20 @@ class FilterRule with EquatableMixin {
}
}
if (filter.createdDateAfter != null) {
filterRules.add(FilterRule(createdAfterRule, dateFormat.format(filter.createdDateAfter!)));
filterRules.add(FilterRule(
createdAfterRule, dateFormat.format(filter.createdDateAfter!)));
}
if (filter.createdDateBefore != null) {
filterRules.add(FilterRule(createdBeforeRule, dateFormat.format(filter.createdDateBefore!)));
filterRules.add(FilterRule(
createdBeforeRule, dateFormat.format(filter.createdDateBefore!)));
}
if (filter.addedDateAfter != null) {
filterRules.add(FilterRule(addedAfterRule, dateFormat.format(filter.addedDateAfter!)));
filterRules.add(FilterRule(
addedAfterRule, dateFormat.format(filter.addedDateAfter!)));
}
if (filter.addedDateBefore != null) {
filterRules.add(FilterRule(addedBeforeRule, dateFormat.format(filter.addedDateBefore!)));
filterRules.add(FilterRule(
addedBeforeRule, dateFormat.format(filter.addedDateBefore!)));
}
return filterRules;
}

View File

@@ -1,5 +1,5 @@
import 'package:equatable/equatable.dart';
import 'package:paperless_mobile/core/type/json.dart';
import 'package:paperless_mobile/core/type/types.dart';
import 'package:paperless_mobile/features/documents/model/document.model.dart';
const pageRegex = r".*page=(\d+).*";
@@ -45,7 +45,8 @@ class PagedSearchResult<T> extends Equatable {
required this.results,
});
factory PagedSearchResult.fromJson(Map<dynamic, dynamic> json, T Function(JSON) fromJson) {
factory PagedSearchResult.fromJson(
Map<dynamic, dynamic> json, T Function(JSON) fromJson) {
return PagedSearchResult(
count: json['count'],
next: json['next'],

View File

@@ -1,5 +1,5 @@
import 'package:equatable/equatable.dart';
import 'package:paperless_mobile/core/type/json.dart';
import 'package:paperless_mobile/core/type/types.dart';
import 'package:paperless_mobile/features/documents/model/document_filter.dart';
import 'package:paperless_mobile/features/documents/model/filter_rule.model.dart';
import 'package:paperless_mobile/features/documents/model/query_parameters/sort_field.dart';
@@ -33,8 +33,14 @@ class SavedView with EquatableMixin {
}
@override
List<Object?> get props =>
[name, showOnDashboard, showInSidebar, sortField, sortReverse, filterRules];
List<Object?> get props => [
name,
showOnDashboard,
showInSidebar,
sortField,
sortReverse,
filterRules
];
SavedView.fromJson(JSON json)
: this(
@@ -42,11 +48,14 @@ class SavedView with EquatableMixin {
name: json['name'],
showOnDashboard: json['show_on_dashboard'],
showInSidebar: json['show_in_sidebar'],
sortField:
SortField.values.where((order) => order.queryString == json['sort_field']).first,
sortField: SortField.values
.where((order) => order.queryString == json['sort_field'])
.first,
sortReverse: json['sort_reverse'],
filterRules:
json['filter_rules'].cast<JSON>().map<FilterRule>(FilterRule.fromJson).toList(),
filterRules: json['filter_rules']
.cast<JSON>()
.map<FilterRule>(FilterRule.fromJson)
.toList(),
);
DocumentFilter toDocumentFilter() {

View File

@@ -1,4 +1,4 @@
import 'package:paperless_mobile/core/type/json.dart';
import 'package:paperless_mobile/core/type/types.dart';
import 'package:paperless_mobile/features/documents/model/document.model.dart';
class SimilarDocumentModel extends DocumentModel {

View File

@@ -25,7 +25,8 @@ abstract class DocumentRepository {
Future<List<int>> bulkDelete(List<DocumentModel> models);
Future<Uint8List> getPreview(int docId);
String getThumbnailUrl(int docId);
Future<DocumentModel> waitForConsumptionFinished(String filename, String title);
Future<DocumentModel> waitForConsumptionFinished(
String filename, String title);
Future<Uint8List> download(DocumentModel document);
Future<List<String>> autocomplete(String query, [int limit = 10]);

View File

@@ -7,7 +7,7 @@ import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:paperless_mobile/core/store/local_vault.dart';
import 'package:paperless_mobile/core/type/json.dart';
import 'package:paperless_mobile/core/type/types.dart';
import 'package:paperless_mobile/core/util.dart';
import 'package:paperless_mobile/di_initializer.dart';
import 'package:paperless_mobile/extensions/dart_extensions.dart';
@@ -69,10 +69,10 @@ class DocumentRepositoryImpl implements DocumentRepository {
fields.tryPutIfAbsent('title', () => title);
fields.tryPutIfAbsent('created', () => formatDateNullable(createdAt));
fields.tryPutIfAbsent(
'correspondent', () => correspondent == null ? null : json.encode(correspondent));
fields.tryPutIfAbsent(
'document_type', () => documentType == null ? null : json.encode(documentType));
fields.tryPutIfAbsent('correspondent',
() => correspondent == null ? null : json.encode(correspondent));
fields.tryPutIfAbsent('document_type',
() => documentType == null ? null : json.encode(documentType));
for (final key in fields.keys) {
bodyBuffer.write(_buildMultipartField(key, fields[key]!, boundary));
@@ -90,7 +90,8 @@ class DocumentRepositoryImpl implements DocumentRepository {
final closing = "\r\n--" + boundary + "--\r\n";
// Set headers
request.headers.set(HttpHeaders.contentTypeHeader, "multipart/form-data; boundary=" + boundary);
request.headers.set(HttpHeaders.contentTypeHeader,
"multipart/form-data; boundary=" + boundary);
request.headers.set(HttpHeaders.contentLengthHeader,
"${bodyBuffer.length + closing.length + documentBytes.lengthInBytes}");
request.headers.set(HttpHeaders.authorizationHeader, "Token ${auth.token}");
@@ -105,7 +106,8 @@ class DocumentRepositoryImpl implements DocumentRepository {
final response = await request.close();
if (response.statusCode != 200) {
throw ErrorMessage(ErrorCode.documentUploadFailed, httpStatusCode: response.statusCode);
throw ErrorMessage(ErrorCode.documentUploadFailed,
httpStatusCode: response.statusCode);
}
}
@@ -121,19 +123,23 @@ class DocumentRepositoryImpl implements DocumentRepository {
String _boundaryString() {
Random _random = Random();
var prefix = 'dart-http-boundary-';
var list = List<int>.generate(70 - prefix.length,
(index) => boundaryCharacters[_random.nextInt(boundaryCharacters.length)],
var list = List<int>.generate(
70 - prefix.length,
(index) =>
boundaryCharacters[_random.nextInt(boundaryCharacters.length)],
growable: false);
return '$prefix${String.fromCharCodes(list)}';
}
@override
Future<DocumentModel> update(DocumentModel doc) async {
final response = await httpClient.put(Uri.parse("/api/documents/${doc.id}/"),
final response = await httpClient.put(
Uri.parse("/api/documents/${doc.id}/"),
body: json.encode(doc.toJson()),
headers: {"Content-Type": "application/json"}).timeout(requestTimeout);
if (response.statusCode == 200) {
return DocumentModel.fromJson(jsonDecode(utf8.decode(response.bodyBytes)));
return DocumentModel.fromJson(
jsonDecode(utf8.decode(response.bodyBytes)));
} else {
throw const ErrorMessage(ErrorCode.documentUpdateFailed);
}
@@ -158,7 +164,8 @@ class DocumentRepositoryImpl implements DocumentRepository {
@override
Future<int> delete(DocumentModel doc) async {
final response = await httpClient.delete(Uri.parse("/api/documents/${doc.id}/"));
final response =
await httpClient.delete(Uri.parse("/api/documents/${doc.id}/"));
if (response.statusCode == 204) {
return Future.value(doc.id);
@@ -221,13 +228,16 @@ class DocumentRepositoryImpl implements DocumentRepository {
}
@override
Future<DocumentModel> waitForConsumptionFinished(String fileName, String title) async {
Future<DocumentModel> waitForConsumptionFinished(
String fileName, String title) async {
// Always wait 5 seconds, processing usually takes longer...
//await Future.delayed(const Duration(seconds: 5));
PagedSearchResult<DocumentModel> results = await find(DocumentFilter.latestDocument);
PagedSearchResult<DocumentModel> results =
await find(DocumentFilter.latestDocument);
while ((results.results.isEmpty ||
(results.results[0].originalFileName != fileName && results.results[0].title != title))) {
(results.results[0].originalFileName != fileName &&
results.results[0].title != title))) {
//TODO: maybe implement more intelligent retry logic or find workaround for websocket authentication...
await Future.delayed(const Duration(seconds: 2));
results = await find(DocumentFilter.latestDocument);
@@ -242,20 +252,23 @@ class DocumentRepositoryImpl implements DocumentRepository {
@override
Future<Uint8List> download(DocumentModel document) async {
//TODO: Check if this works...
final response = await httpClient.get(Uri.parse("/api/documents/${document.id}/download/"));
final response = await httpClient
.get(Uri.parse("/api/documents/${document.id}/download/"));
return response.bodyBytes;
}
@override
Future<DocumentMetaData> getMetaData(DocumentModel document) async {
final response = await httpClient.get(Uri.parse("/api/documents/${document.id}/metadata/"));
return DocumentMetaData.fromJson(jsonDecode(utf8.decode(response.bodyBytes)));
final response = await httpClient
.get(Uri.parse("/api/documents/${document.id}/metadata/"));
return DocumentMetaData.fromJson(
jsonDecode(utf8.decode(response.bodyBytes)));
}
@override
Future<List<String>> autocomplete(String query, [int limit = 10]) async {
final response =
await httpClient.get(Uri.parse("/api/search/autocomplete/?query=$query&limit=$limit}"));
final response = await httpClient
.get(Uri.parse("/api/search/autocomplete/?query=$query&limit=$limit}"));
if (response.statusCode == 200) {
return jsonDecode(utf8.decode(response.bodyBytes)) as List<String>;
}
@@ -264,8 +277,8 @@ class DocumentRepositoryImpl implements DocumentRepository {
@override
Future<List<SimilarDocumentModel>> findSimilar(int docId) async {
final response =
await httpClient.get(Uri.parse("/api/documents/?more_like=$docId&pageSize=10"));
final response = await httpClient
.get(Uri.parse("/api/documents/?more_like=$docId&pageSize=10"));
if (response.statusCode == 200) {
return PagedSearchResult<SimilarDocumentModel>.fromJson(
jsonDecode(utf8.decode(response.bodyBytes)),

View File

@@ -39,15 +39,18 @@ class SavedViewRepositoryImpl implements SavedViewsRepository {
if (response.statusCode == 201) {
return SavedView.fromJson(jsonDecode(utf8.decode(response.bodyBytes)));
}
throw ErrorMessage(ErrorCode.createSavedViewError, httpStatusCode: response.statusCode);
throw ErrorMessage(ErrorCode.createSavedViewError,
httpStatusCode: response.statusCode);
}
@override
Future<int> delete(SavedView view) async {
final response = await httpClient.delete(Uri.parse("/api/saved_views/${view.id}/"));
final response =
await httpClient.delete(Uri.parse("/api/saved_views/${view.id}/"));
if (response.statusCode == 204) {
return view.id!;
}
throw ErrorMessage(ErrorCode.deleteSavedViewError, httpStatusCode: response.statusCode);
throw ErrorMessage(ErrorCode.deleteSavedViewError,
httpStatusCode: response.statusCode);
}
}

View File

@@ -32,9 +32,14 @@ import 'package:share_plus/share_plus.dart';
class DocumentDetailsPage extends StatefulWidget {
final int documentId;
final bool allowEdit;
final bool isLabelClickable;
const DocumentDetailsPage({
Key? key,
required this.documentId,
this.allowEdit = true,
this.isLabelClickable = true,
}) : super(key: key);
@override
@@ -42,7 +47,8 @@ class DocumentDetailsPage extends StatefulWidget {
}
class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
static final DateFormat _detailedDateFormat = DateFormat("MMM d, yyyy HH:mm:ss");
static final DateFormat _detailedDateFormat =
DateFormat("MMM d, yyyy HH:mm:ss");
bool _isDownloadPending = false;
bool _isAssignAsnPending = false;
@@ -52,36 +58,42 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
return BlocBuilder<DocumentsCubit, DocumentsState>(
// buildWhen required because rebuild would happen after delete causing error.
buildWhen: (previous, current) {
return current.documents.where((element) => element.id == widget.documentId).isNotEmpty;
return current.documents
.where((element) => element.id == widget.documentId)
.isNotEmpty;
},
builder: (context, state) {
final document = state.documents.where((doc) => doc.id == widget.documentId).first;
return SafeArea(
bottom: true,
child: DefaultTabController(
final document =
state.documents.where((doc) => doc.id == widget.documentId).first;
return DefaultTabController(
length: 3,
child: Scaffold(
floatingActionButtonLocation: FloatingActionButtonLocation.endDocked,
floatingActionButton: FloatingActionButton(
floatingActionButtonLocation:
FloatingActionButtonLocation.endDocked,
floatingActionButton: widget.allowEdit
? FloatingActionButton(
child: const Icon(Icons.edit),
onPressed: () => _onEdit(document),
),
)
: null,
bottomNavigationBar: BottomAppBar(
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
IconButton(
icon: const Icon(Icons.delete),
onPressed: () => _onDelete(document),
).padded(const EdgeInsets.symmetric(horizontal: 8.0)),
onPressed:
widget.allowEdit ? () => _onDelete(document) : null,
).padded(const EdgeInsets.symmetric(horizontal: 4)),
IconButton(
icon: const Icon(Icons.download),
onPressed: Platform.isAndroid ? () => _onDownload(document) : null,
),
onPressed:
Platform.isAndroid ? () => _onDownload(document) : null,
).padded(const EdgeInsets.only(right: 4)),
IconButton(
icon: const Icon(Icons.open_in_new),
onPressed: () => _onOpen(document),
).padded(const EdgeInsets.symmetric(horizontal: 8.0)),
).padded(const EdgeInsets.only(right: 4)),
IconButton(
icon: const Icon(Icons.share),
onPressed: () => _onShare(document),
@@ -108,28 +120,35 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
fit: BoxFit.cover,
),
bottom: ColoredTabBar(
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
backgroundColor:
Theme.of(context).colorScheme.primaryContainer,
tabBar: TabBar(
tabs: [
Tab(
child: Text(
S.of(context).documentDetailsPageTabOverviewLabel,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer),
color: Theme.of(context)
.colorScheme
.onPrimaryContainer),
),
),
Tab(
child: Text(
S.of(context).documentDetailsPageTabContentLabel,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer),
color: Theme.of(context)
.colorScheme
.onPrimaryContainer),
),
),
Tab(
child: Text(
S.of(context).documentDetailsPageTabMetaDataLabel,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer),
color: Theme.of(context)
.colorScheme
.onPrimaryContainer),
),
),
],
@@ -139,14 +158,15 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
],
body: TabBarView(
children: [
_buildDocumentOverview(document, state.filter.titleAndContentMatchString),
_buildDocumentContentView(document, state.filter.titleAndContentMatchString),
_buildDocumentOverview(
document, state.filter.titleAndContentMatchString),
_buildDocumentContentView(
document, state.filter.titleAndContentMatchString),
_buildDocumentMetaDataView(document),
].padded(),
),
),
),
),
);
},
);
@@ -163,18 +183,25 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
return ListView(
children: [
_DetailsItem.text(_detailedDateFormat.format(document.modified),
label: S.of(context).documentModifiedPropertyLabel, context: context),
label: S.of(context).documentModifiedPropertyLabel,
context: context),
_separator(),
_DetailsItem.text(_detailedDateFormat.format(document.added),
label: S.of(context).documentAddedPropertyLabel, context: context),
label: S.of(context).documentAddedPropertyLabel,
context: context),
_separator(),
_DetailsItem(
label: S.of(context).documentArchiveSerialNumberPropertyLongLabel,
content: document.archiveSerialNumber != null
? Text(document.archiveSerialNumber.toString())
: OutlinedButton(
child: Text(S.of(context).documentDetailsPageAssignAsnButtonLabel),
onPressed: () => BlocProvider.of<DocumentsCubit>(context).assignAsn(document),
child: Text(S
.of(context)
.documentDetailsPageAssignAsnButtonLabel),
onPressed: widget.allowEdit
? () => BlocProvider.of<DocumentsCubit>(context)
.assignAsn(document)
: null,
),
),
_separator(),
@@ -191,7 +218,8 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
),
_separator(),
_DetailsItem.text(formatBytes(meta.originalSize, 2),
label: S.of(context).documentMetaDataOriginalFileSizeLabel, context: context),
label: S.of(context).documentMetaDataOriginalFileSizeLabel,
context: context),
_separator(),
_DetailsItem.text(
meta.originalMimeType,
@@ -239,6 +267,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
_separator(),
_DetailsItem(
content: DocumentTypeWidget(
isClickable: widget.isLabelClickable,
documentTypeId: document.documentType,
afterSelected: () {
Navigator.pop(context);
@@ -250,6 +279,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
_DetailsItem(
label: S.of(context).documentCorrespondentPropertyLabel,
content: CorrespondentWidget(
isClickable: widget.isLabelClickable,
correspondentId: document.correspondent,
afterSelected: () {
Navigator.pop(context);
@@ -260,6 +290,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
_DetailsItem(
label: S.of(context).documentStoragePathPropertyLabel,
content: StoragePathWidget(
isClickable: widget.isLabelClickable,
pathId: document.storagePath,
afterSelected: () {
Navigator.pop(context);
@@ -272,6 +303,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
content: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: TagsWidget(
isClickable: widget.isLabelClickable,
tagIds: document.tags,
),
),
@@ -321,13 +353,15 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
Future<void> _onDownload(DocumentModel document) async {
if (!Platform.isAndroid) {
showSnackBar(context, "This feature is currently only supported on Android!");
showSnackBar(
context, "This feature is currently only supported on Android!");
return;
}
setState(() => _isDownloadPending = true);
getIt<DocumentRepository>().download(document).then((bytes) async {
final Directory dir =
(await getExternalStorageDirectories(type: StorageDirectory.downloads))!.first;
final Directory dir = (await getExternalStorageDirectories(
type: StorageDirectory.downloads))!
.first;
String filePath = "${dir.path}/${document.originalFileName}";
//TODO: Add replacement mechanism here (ask user if file should be replaced if exists)
await File(filePath).writeAsBytes(bytes);
@@ -340,7 +374,8 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
/// Downloads file to temporary directory, from which it can then be shared.
///
Future<void> _onShare(DocumentModel document) async {
Uint8List documentBytes = await getIt<DocumentRepository>().download(document);
Uint8List documentBytes =
await getIt<DocumentRepository>().download(document);
final dir = await getTemporaryDirectory();
final String path = "${dir.path}/${document.originalFileName}";
await File(path).writeAsBytes(documentBytes);
@@ -360,13 +395,15 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
Future<void> _onDelete(DocumentModel document) async {
showDialog(
context: context,
builder: (context) => DeleteDocumentConfirmationDialog(document: document)).then((delete) {
builder: (context) =>
DeleteDocumentConfirmationDialog(document: document))
.then((delete) {
if (delete ?? false) {
BlocProvider.of<DocumentsCubit>(context).removeDocument(document).then((value) {
BlocProvider.of<DocumentsCubit>(context)
.removeDocument(document)
.then((value) {
Navigator.pop(context);
showSnackBar(context, S.of(context).documentDeleteSuccessMessage);
}).onError<ErrorMessage>((error, _) {
showSnackBar(context, translateError(context, error.code));
});
}
});
@@ -384,14 +421,17 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
if (bytes <= 0) return "0 B";
const suffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
var i = (log(bytes) / log(1024)).floor();
return ((bytes / pow(1024, i)).toStringAsFixed(decimals)) + ' ' + suffixes[i];
return ((bytes / pow(1024, i)).toStringAsFixed(decimals)) +
' ' +
suffixes[i];
}
}
class _DetailsItem extends StatelessWidget {
final String label;
final Widget content;
const _DetailsItem({Key? key, required this.label, required this.content}) : super(key: key);
const _DetailsItem({Key? key, required this.label, required this.content})
: super(key: key);
@override
Widget build(BuildContext context) {
@@ -402,7 +442,10 @@ class _DetailsItem extends StatelessWidget {
children: [
Text(
label,
style: Theme.of(context).textTheme.headline5?.copyWith(fontWeight: FontWeight.bold),
style: Theme.of(context)
.textTheme
.headline5
?.copyWith(fontWeight: FontWeight.bold),
),
content,
],

View File

@@ -78,7 +78,8 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
});
await getIt<DocumentsCubit>().updateDocument(updatedDocument);
Navigator.pop(context);
showSnackBar(context, "Document successfully updated."); //TODO: INTL
showSnackBar(
context, "Document successfully updated."); //TODO: INTL
}
},
icon: const Icon(Icons.save),
@@ -111,18 +112,21 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
return LabelFormField<DocumentType, DocumentTypeQuery>(
notAssignedSelectable: false,
formBuilderState: _formKey.currentState,
labelCreationWidgetBuilder: (currentInput) => BlocProvider.value(
labelCreationWidgetBuilder: (currentInput) =>
BlocProvider.value(
value: BlocProvider.of<DocumentTypeCubit>(context),
child: AddDocumentTypePage(
initialName: currentInput,
),
),
label: S.of(context).documentDocumentTypePropertyLabel,
initialValue: DocumentTypeQuery.fromId(widget.document.documentType),
initialValue:
DocumentTypeQuery.fromId(widget.document.documentType),
state: state,
name: fkDocumentType,
queryParameterIdBuilder: DocumentTypeQuery.fromId,
queryParameterNotAssignedBuilder: DocumentTypeQuery.notAssigned,
queryParameterNotAssignedBuilder:
DocumentTypeQuery.notAssigned,
prefixIcon: const Icon(Icons.description_outlined),
);
},
@@ -132,16 +136,19 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
return LabelFormField<Correspondent, CorrespondentQuery>(
notAssignedSelectable: false,
formBuilderState: _formKey.currentState,
labelCreationWidgetBuilder: (initialValue) => BlocProvider.value(
labelCreationWidgetBuilder: (initialValue) =>
BlocProvider.value(
value: BlocProvider.of<CorrespondentCubit>(context),
child: AddCorrespondentPage(initalValue: initialValue),
),
label: S.of(context).documentCorrespondentPropertyLabel,
state: state,
initialValue: CorrespondentQuery.fromId(widget.document.correspondent),
initialValue:
CorrespondentQuery.fromId(widget.document.correspondent),
name: fkCorrespondent,
queryParameterIdBuilder: CorrespondentQuery.fromId,
queryParameterNotAssignedBuilder: CorrespondentQuery.notAssigned,
queryParameterNotAssignedBuilder:
CorrespondentQuery.notAssigned,
prefixIcon: const Icon(Icons.person_outlined),
);
},
@@ -151,16 +158,19 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
return LabelFormField<StoragePath, StoragePathQuery>(
notAssignedSelectable: false,
formBuilderState: _formKey.currentState,
labelCreationWidgetBuilder: (initialValue) => BlocProvider.value(
labelCreationWidgetBuilder: (initialValue) =>
BlocProvider.value(
value: BlocProvider.of<StoragePathCubit>(context),
child: AddStoragePathPage(initalValue: initialValue),
),
label: S.of(context).documentStoragePathPropertyLabel,
state: state,
initialValue: StoragePathQuery.fromId(widget.document.storagePath),
initialValue:
StoragePathQuery.fromId(widget.document.storagePath),
name: fkStoragePath,
queryParameterIdBuilder: StoragePathQuery.fromId,
queryParameterNotAssignedBuilder: StoragePathQuery.notAssigned,
queryParameterNotAssignedBuilder:
StoragePathQuery.notAssigned,
prefixIcon: const Icon(Icons.folder_outlined),
);
},

View File

@@ -46,12 +46,7 @@ class _DocumentsPageState extends State<DocumentsPage> {
super.initState();
final documentsCubit = BlocProvider.of<DocumentsCubit>(context);
if (!documentsCubit.state.isLoaded) {
documentsCubit.loadDocuments().onError<ErrorMessage>(
(error, stackTrace) => showSnackBar(
context,
translateError(context, error.code),
),
);
documentsCubit.loadDocuments();
}
_pagingController.addPageRequestListener(_loadNewPage);
}
@@ -64,8 +59,8 @@ class _DocumentsPageState extends State<DocumentsPage> {
Future<void> _loadNewPage(int pageKey) async {
final documentsCubit = BlocProvider.of<DocumentsCubit>(context);
final pageCount =
documentsCubit.state.inferPageCount(pageSize: documentsCubit.state.filter.pageSize);
final pageCount = documentsCubit.state
.inferPageCount(pageSize: documentsCubit.state.filter.pageSize);
if (pageCount <= pageKey + 1) {
_pagingController.nextPageKey = null;
}
@@ -78,11 +73,8 @@ class _DocumentsPageState extends State<DocumentsPage> {
Future<void> _onRefresh() {
final documentsCubit = BlocProvider.of<DocumentsCubit>(context);
return documentsCubit
.updateFilter(filter: documentsCubit.state.filter.copyWith(page: 1))
.onError<ErrorMessage>((error, _) {
showSnackBar(context, translateError(context, error.code));
});
return documentsCubit.updateFilter(
filter: documentsCubit.state.filter.copyWith(page: 1));
}
@override
@@ -103,7 +95,8 @@ class _DocumentsPageState extends State<DocumentsPage> {
},
child: BlocConsumer<ConnectivityCubit, ConnectivityState>(
listenWhen: (previous, current) =>
previous != ConnectivityState.connected && current == ConnectivityState.connected,
previous != ConnectivityState.connected &&
current == ConnectivityState.connected,
listener: (context, state) {
BlocProvider.of<DocumentsCubit>(context).loadDocuments();
},
@@ -114,7 +107,6 @@ class _DocumentsPageState extends State<DocumentsPage> {
child: const InfoDrawer(),
),
resizeToAvoidBottomInset: true,
appBar: connectivityState == ConnectivityState.connected ? null : const OfflineBanner(),
body: SlidingUpPanel(
backdropEnabled: true,
parallaxEnabled: true,
@@ -122,7 +114,8 @@ class _DocumentsPageState extends State<DocumentsPage> {
controller: _panelController,
defaultPanelState: PanelState.CLOSED,
minHeight: 48,
maxHeight: MediaQuery.of(context).size.height - kBottomNavigationBarHeight,
maxHeight: MediaQuery.of(context).size.height -
kBottomNavigationBarHeight,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
@@ -157,7 +150,8 @@ class _DocumentsPageState extends State<DocumentsPage> {
state: state,
onSelected: _onSelected,
pagingController: _pagingController,
hasInternetConnection: connectivityState == ConnectivityState.connected,
hasInternetConnection:
connectivityState == ConnectivityState.connected,
);
break;
case ViewType.grid:
@@ -166,7 +160,8 @@ class _DocumentsPageState extends State<DocumentsPage> {
state: state,
onSelected: _onSelected,
pagingController: _pagingController,
hasInternetConnection: connectivityState == ConnectivityState.connected);
hasInternetConnection:
connectivityState == ConnectivityState.connected);
break;
}
@@ -191,9 +186,12 @@ class _DocumentsPageState extends State<DocumentsPage> {
const SortDocumentsButton(),
IconButton(
icon: Icon(
_viewType == ViewType.grid ? Icons.list : Icons.grid_view,
_viewType == ViewType.grid
? Icons.list
: Icons.grid_view,
),
onPressed: () => setState(() => _viewType = _viewType.toggle()),
onPressed: () =>
setState(() => _viewType = _viewType.toggle()),
),
],
),

View File

@@ -26,7 +26,8 @@ class DeleteDocumentConfirmationDialog extends StatelessWidget {
),
),
const SizedBox(height: 16),
Text(S.of(context).documentsPageSelectionBulkDeleteDialogContinueText),
Text(
S.of(context).documentsPageSelectionBulkDeleteDialogContinueText),
],
),
actions: [
@@ -36,7 +37,8 @@ class DeleteDocumentConfirmationDialog extends StatelessWidget {
),
TextButton(
style: ButtonStyle(
foregroundColor: MaterialStateProperty.all(Theme.of(context).colorScheme.error),
foregroundColor:
MaterialStateProperty.all(Theme.of(context).colorScheme.error),
),
onPressed: () {
Navigator.pop(context, true);

View File

@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/logic/error_code_localization_mapper.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:paperless_mobile/core/widgets/empty_state.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart';
@@ -7,6 +9,7 @@ import 'package:paperless_mobile/features/documents/bloc/documents_state.dart';
import 'package:paperless_mobile/features/documents/bloc/saved_view_cubit.dart';
import 'package:paperless_mobile/features/documents/model/document_filter.dart';
import 'package:paperless_mobile/generated/l10n.dart';
import 'package:paperless_mobile/util.dart';
class DocumentsEmptyState extends StatelessWidget {
final DocumentsState state;

View File

@@ -51,8 +51,10 @@ class DocumentGridItem extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CorrespondentWidget(correspondentId: document.correspondent),
DocumentTypeWidget(documentTypeId: document.documentType),
CorrespondentWidget(
correspondentId: document.correspondent),
DocumentTypeWidget(
documentTypeId: document.documentType),
Text(
document.title,
maxLines: document.tags.isEmpty ? 3 : 2,
@@ -64,7 +66,8 @@ class DocumentGridItem extends StatelessWidget {
tagIds: document.tags,
isMultiLine: false,
),
Text(DateFormat.yMMMd(Intl.getCurrentLocale()).format(document.created)),
Text(DateFormat.yMMMd(Intl.getCurrentLocale())
.format(document.created)),
],
),
),

View File

@@ -7,12 +7,13 @@ import 'package:paperless_mobile/features/documents/view/widgets/list/document_l
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
class DocumentListView extends StatelessWidget {
final void Function(DocumentModel model) onTap;
final void Function(DocumentModel) onTap;
final void Function(DocumentModel) onSelected;
final PagingController<int, DocumentModel> pagingController;
final DocumentsState state;
final bool hasInternetConnection;
final bool isLabelClickable;
const DocumentListView({
super.key,
required this.onTap,
@@ -20,24 +21,28 @@ class DocumentListView extends StatelessWidget {
required this.state,
required this.onSelected,
required this.hasInternetConnection,
this.isLabelClickable = true,
});
@override
Widget build(BuildContext context) {
return PagedSliverList<int, DocumentModel>(
pagingController: pagingController,
builderDelegate: PagedChildBuilderDelegate(
animateTransitions: true,
itemBuilder: (context, item, index) {
itemBuilder: (context, document, index) {
return DocumentListItem(
document: item,
isLabelClickable: isLabelClickable,
document: document,
onTap: onTap,
isSelected: state.selection.contains(item),
isSelected: state.selection.contains(document),
onSelected: onSelected,
isAtLeastOneSelected: state.selection.isNotEmpty,
);
},
noItemsFoundIndicatorBuilder: (context) =>
hasInternetConnection ? const DocumentsListLoadingWidget() : const OfflineWidget(),
noItemsFoundIndicatorBuilder: (context) => hasInternetConnection
? const DocumentsListLoadingWidget()
: const OfflineWidget(),
),
);
}

View File

@@ -5,12 +5,13 @@ import 'package:paperless_mobile/features/labels/correspondent/view/widgets/corr
import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart';
class DocumentListItem extends StatelessWidget {
static const a4AspectRatio = 1 / 1.4142;
static const _a4AspectRatio = 1 / 1.4142;
final DocumentModel document;
final bool isSelected;
final void Function(DocumentModel) onTap;
final void Function(DocumentModel)? onSelected;
final bool isSelected;
final bool isAtLeastOneSelected;
final bool isLabelClickable;
const DocumentListItem({
Key? key,
@@ -19,6 +20,7 @@ class DocumentListItem extends StatelessWidget {
this.onSelected,
required this.isSelected,
required this.isAtLeastOneSelected,
this.isLabelClickable = true,
}) : super(key: key);
@override
@@ -39,6 +41,7 @@ class DocumentListItem extends StatelessWidget {
AbsorbPointer(
absorbing: isAtLeastOneSelected,
child: CorrespondentWidget(
isClickable: isLabelClickable,
correspondentId: document.correspondent,
afterSelected: () {},
),
@@ -57,6 +60,7 @@ class DocumentListItem extends StatelessWidget {
child: AbsorbPointer(
absorbing: isAtLeastOneSelected,
child: TagsWidget(
isClickable: isLabelClickable,
tagIds: document.tags,
isMultiLine: false,
),
@@ -64,7 +68,7 @@ class DocumentListItem extends StatelessWidget {
),
isThreeLine: document.tags.isNotEmpty,
leading: AspectRatio(
aspectRatio: a4AspectRatio,
aspectRatio: _a4AspectRatio,
child: GestureDetector(
child: DocumentPreview(
id: document.id,

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:paperless_mobile/core/logic/error_code_localization_mapper.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_state.dart';
@@ -24,6 +26,7 @@ import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_form_fie
import 'package:paperless_mobile/features/labels/view/widgets/label_form_field.dart';
import 'package:paperless_mobile/generated/l10n.dart';
import 'package:intl/intl.dart';
import 'package:paperless_mobile/util.dart';
import 'package:sliding_up_panel/sliding_up_panel.dart';
enum DateRangeSelection { before, after }
@@ -108,7 +111,8 @@ class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
alignment: Alignment.topRight,
child: TextButton.icon(
icon: const Icon(Icons.refresh),
label: Text(S.of(context).documentsFilterPageResetFilterLabel),
label: Text(
S.of(context).documentsFilterPageResetFilterLabel),
onPressed: () => _resetFilter(context),
),
),
@@ -126,7 +130,8 @@ class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
),
TextButton(
onPressed: _onApplyFilter,
child: Text(S.of(context).documentsFilterPageApplyFilterLabel),
child: Text(
S.of(context).documentsFilterPageApplyFilterLabel),
),
],
).padded(),
@@ -225,7 +230,8 @@ class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
}
Widget _buildQueryFormField(DocumentsState state) {
final queryType = _formKey.currentState?.getRawValue(QueryTypeFormField.fkQueryType) ??
final queryType =
_formKey.currentState?.getRawValue(QueryTypeFormField.fkQueryType) ??
QueryType.titleAndContent;
late String label;
switch (queryType) {
@@ -233,7 +239,8 @@ class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
label = S.of(context).documentsFilterPageQueryOptionsTitleLabel;
break;
case QueryType.titleAndContent:
label = S.of(context).documentsFilterPageQueryOptionsTitleAndContentLabel;
label =
S.of(context).documentsFilterPageQueryOptionsTitleAndContentLabel;
break;
case QueryType.extended:
label = S.of(context).documentsFilterPageQueryOptionsExtendedLabel;
@@ -255,7 +262,8 @@ class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
).padded();
}
Widget _buildDateRangePickerHelper(DocumentsState state, String formFieldKey) {
Widget _buildDateRangePickerHelper(
DocumentsState state, String formFieldKey) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
@@ -279,10 +287,12 @@ class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
),
onPressed: () {
final now = DateTime.now();
final firstDayOfLastMonth = DateUtils.addMonthsToMonthDate(now, -1);
final firstDayOfLastMonth =
DateUtils.addMonthsToMonthDate(now, -1);
_formKey.currentState?.fields[formFieldKey]?.didChange(
DateTimeRange(
start: DateTime(firstDayOfLastMonth.year, firstDayOfLastMonth.month, now.day),
start: DateTime(firstDayOfLastMonth.year,
firstDayOfLastMonth.month, now.day),
end: DateTime.now(),
),
);
@@ -294,7 +304,8 @@ class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
),
onPressed: () {
final now = DateTime.now();
final firstDayOfLastMonth = DateUtils.addMonthsToMonthDate(now, -3);
final firstDayOfLastMonth =
DateUtils.addMonthsToMonthDate(now, -3);
_formKey.currentState?.fields[formFieldKey]?.didChange(
DateTimeRange(
start: DateTime(
@@ -313,7 +324,8 @@ class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
),
onPressed: () {
final now = DateTime.now();
final firstDayOfLastMonth = DateUtils.addMonthsToMonthDate(now, -12);
final firstDayOfLastMonth =
DateUtils.addMonthsToMonthDate(now, -12);
_formKey.currentState?.fields[formFieldKey]?.didChange(
DateTimeRange(
start: DateTime(
@@ -345,7 +357,8 @@ class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
data: Theme.of(context).copyWith(
dialogBackgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBarTheme: Theme.of(context).appBarTheme.copyWith(
iconTheme: IconThemeData(color: Theme.of(context).primaryColor),
iconTheme:
IconThemeData(color: Theme.of(context).primaryColor),
),
colorScheme: Theme.of(context).colorScheme.copyWith(
onPrimary: Theme.of(context).primaryColor,
@@ -355,8 +368,10 @@ class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
child: child!,
),
format: DateFormat.yMMMd(Localizations.localeOf(context).toString()),
fieldStartLabelText: S.of(context).documentsFilterPageDateRangeFieldStartLabel,
fieldEndLabelText: S.of(context).documentsFilterPageDateRangeFieldEndLabel,
fieldStartLabelText:
S.of(context).documentsFilterPageDateRangeFieldStartLabel,
fieldEndLabelText:
S.of(context).documentsFilterPageDateRangeFieldEndLabel,
firstDate: DateTime.fromMicrosecondsSinceEpoch(0),
lastDate: DateTime.now(),
name: fkCreatedAt,
@@ -365,7 +380,8 @@ class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
labelText: S.of(context).documentCreatedPropertyLabel,
suffixIcon: IconButton(
icon: const Icon(Icons.clear),
onPressed: () => _formKey.currentState?.fields[fkCreatedAt]?.didChange(null),
onPressed: () =>
_formKey.currentState?.fields[fkCreatedAt]?.didChange(null),
),
),
),
@@ -388,7 +404,8 @@ class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
data: Theme.of(context).copyWith(
dialogBackgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBarTheme: Theme.of(context).appBarTheme.copyWith(
iconTheme: IconThemeData(color: Theme.of(context).primaryColor),
iconTheme:
IconThemeData(color: Theme.of(context).primaryColor),
),
colorScheme: Theme.of(context).colorScheme.copyWith(
onPrimary: Theme.of(context).primaryColor,
@@ -398,8 +415,10 @@ class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
child: child!,
),
format: DateFormat.yMMMd(Localizations.localeOf(context).toString()),
fieldStartLabelText: S.of(context).documentsFilterPageDateRangeFieldStartLabel,
fieldEndLabelText: S.of(context).documentsFilterPageDateRangeFieldEndLabel,
fieldStartLabelText:
S.of(context).documentsFilterPageDateRangeFieldStartLabel,
fieldEndLabelText:
S.of(context).documentsFilterPageDateRangeFieldEndLabel,
firstDate: DateTime.fromMicrosecondsSinceEpoch(0),
lastDate: DateTime.now(),
name: fkAddedAt,
@@ -408,7 +427,8 @@ class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
labelText: S.of(context).documentAddedPropertyLabel,
suffixIcon: IconButton(
icon: const Icon(Icons.clear),
onPressed: () => _formKey.currentState?.fields[fkAddedAt]?.didChange(null),
onPressed: () =>
_formKey.currentState?.fields[fkAddedAt]?.didChange(null),
),
),
),
@@ -444,16 +464,16 @@ class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
separatorBuilder: (context, index) => const SizedBox(
width: 8.0,
),
itemBuilder: (context, index) =>
_buildActionChip(_sortFields[index], state.filter.sortField, context),
itemBuilder: (context, index) => _buildActionChip(
_sortFields[index], state.filter.sortField, context),
),
),
],
).padded();
}
Widget _buildActionChip(
SortField sortField, SortField? currentlySelectedOrder, BuildContext context) {
Widget _buildActionChip(SortField sortField,
SortField? currentlySelectedOrder, BuildContext context) {
String text;
switch (sortField) {
case SortField.archiveSerialNumber:
@@ -488,8 +508,8 @@ class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
color: Colors.green,
)
: null,
onPressed: () =>
docBloc.updateFilter(filter: docBloc.state.filter.copyWith(sortField: sortField)),
onPressed: () => docBloc.updateFilter(
filter: docBloc.state.filter.copyWith(sortField: sortField)),
);
}
@@ -510,7 +530,9 @@ class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
addedDateAfter: (v[fkAddedAt] as DateTimeRange?)?.start,
queryType: v[QueryTypeFormField.fkQueryType] as QueryType,
);
BlocProvider.of<DocumentsCubit>(context).updateFilter(filter: newFilter).then((value) {
BlocProvider.of<DocumentsCubit>(context)
.updateFilter(filter: newFilter)
.then((value) {
BlocProvider.of<SavedViewCubit>(context).resetSelection();
FocusScope.of(context).unfocus();
widget.panelController.close();

View File

@@ -20,19 +20,23 @@ class QueryTypeFormField extends StatelessWidget {
itemBuilder: (context) => [
PopupMenuItem(
child: ListTile(
title: Text(S.of(context).documentsFilterPageQueryOptionsTitleAndContentLabel),
title: Text(S
.of(context)
.documentsFilterPageQueryOptionsTitleAndContentLabel),
),
value: QueryType.titleAndContent,
),
PopupMenuItem(
child: ListTile(
title: Text(S.of(context).documentsFilterPageQueryOptionsTitleLabel),
title:
Text(S.of(context).documentsFilterPageQueryOptionsTitleLabel),
),
value: QueryType.title,
),
PopupMenuItem(
child: ListTile(
title: Text(S.of(context).documentsFilterPageQueryOptionsExtendedLabel),
title: Text(
S.of(context).documentsFilterPageQueryOptionsExtendedLabel),
),
value: QueryType.extended,
),

View File

@@ -76,7 +76,8 @@ class _AddSavedViewPageState extends State<AddSavedViewPage> {
SavedView.fromDocumentFilter(
widget.currentFilter,
name: _formKey.currentState?.value[fkName] as String,
showOnDashboard: _formKey.currentState?.value[fkShowOnDashboard] as bool,
showOnDashboard:
_formKey.currentState?.value[fkShowOnDashboard] as bool,
showInSidebar: _formKey.currentState?.value[fkShowInSidebar] as bool,
),
);

View File

@@ -6,7 +6,8 @@ import 'package:paperless_mobile/generated/l10n.dart';
class BulkDeleteConfirmationDialog extends StatelessWidget {
static const _bulletPoint = "\u2022";
final DocumentsState state;
const BulkDeleteConfirmationDialog({Key? key, required this.state}) : super(key: key);
const BulkDeleteConfirmationDialog({Key? key, required this.state})
: super(key: key);
@override
Widget build(BuildContext context) {
@@ -19,8 +20,12 @@ class BulkDeleteConfirmationDialog extends StatelessWidget {
Text(
//TODO: use plurals, didn't use because of crash... investigate later.
state.selection.length == 1
? S.of(context).documentsPageSelectionBulkDeleteDialogWarningTextOne
: S.of(context).documentsPageSelectionBulkDeleteDialogWarningTextMany,
? S
.of(context)
.documentsPageSelectionBulkDeleteDialogWarningTextOne
: S
.of(context)
.documentsPageSelectionBulkDeleteDialogWarningTextMany,
),
const SizedBox(height: 16),
ConstrainedBox(
@@ -31,7 +36,8 @@ class BulkDeleteConfirmationDialog extends StatelessWidget {
),
),
const SizedBox(height: 16),
Text(S.of(context).documentsPageSelectionBulkDeleteDialogContinueText),
Text(
S.of(context).documentsPageSelectionBulkDeleteDialogContinueText),
],
),
actions: [
@@ -41,7 +47,8 @@ class BulkDeleteConfirmationDialog extends StatelessWidget {
),
TextButton(
style: ButtonStyle(
foregroundColor: MaterialStateProperty.all(Theme.of(context).colorScheme.error),
foregroundColor:
MaterialStateProperty.all(Theme.of(context).colorScheme.error),
),
onPressed: () {
Navigator.pop(context, true);

View File

@@ -36,10 +36,11 @@ class _DocumentsPageAppBarState extends State<DocumentsPageAppBar> {
expandedHeight: kToolbarHeight,
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => BlocProvider.of<DocumentsCubit>(context).resetSelection(),
onPressed: () =>
BlocProvider.of<DocumentsCubit>(context).resetSelection(),
),
title:
Text('${documentsState.selection.length} ${S.of(context).documentsSelectedText}'),
title: Text(
'${documentsState.selection.length} ${S.of(context).documentsSelectedText}'),
actions: [
IconButton(
icon: const Icon(Icons.delete),
@@ -79,9 +80,8 @@ class _DocumentsPageAppBarState extends State<DocumentsPageAppBar> {
if (shouldDelete ?? false) {
BlocProvider.of<DocumentsCubit>(context)
.bulkRemoveDocuments(documentsState.selection)
.then((_) => showSnackBar(context, S.of(context).documentsPageBulkDeleteSuccessfulText))
.onError<ErrorMessage>(
(error, _) => showSnackBar(context, translateError(context, error.code)));
.then((_) => showSnackBar(
context, S.of(context).documentsPageBulkDeleteSuccessfulText));
}
}

View File

@@ -44,7 +44,8 @@ class SavedViewSelectionWidget extends StatelessWidget {
child: FilterChip(
label: Text(state.value.values.toList()[index].name),
selected: view.id == state.selectedSavedViewId,
onSelected: (isSelected) => _onSelected(isSelected, context, view),
onSelected: (isSelected) =>
_onSelected(isSelected, context, view),
),
);
},
@@ -76,21 +77,19 @@ class SavedViewSelectionWidget extends StatelessWidget {
void _onCreatePressed(BuildContext context) async {
final newView = await Navigator.of(context).push<SavedView?>(
MaterialPageRoute(
builder: (context) => AddSavedViewPage(currentFilter: getIt<DocumentsCubit>().state.filter),
builder: (context) => AddSavedViewPage(
currentFilter: getIt<DocumentsCubit>().state.filter),
),
);
if (newView != null) {
try {
BlocProvider.of<SavedViewCubit>(context).add(newView);
} on ErrorMessage catch (error) {
showError(context, error);
}
}
}
void _onSelected(bool isSelected, BuildContext context, SavedView view) {
if (isSelected) {
BlocProvider.of<DocumentsCubit>(context).updateFilter(filter: view.toDocumentFilter());
BlocProvider.of<DocumentsCubit>(context)
.updateFilter(filter: view.toDocumentFilter());
BlocProvider.of<SavedViewCubit>(context).selectView(view);
} else {
BlocProvider.of<DocumentsCubit>(context).updateFilter();
@@ -106,11 +105,7 @@ class SavedViewSelectionWidget extends StatelessWidget {
) ??
false;
if (delete) {
try {
BlocProvider.of<SavedViewCubit>(context).remove(view);
} on ErrorMessage catch (error) {
showError(context, error);
}
}
}
}

View File

@@ -1,9 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/logic/error_code_localization_mapper.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_state.dart';
import 'package:paperless_mobile/features/documents/model/query_parameters/sort_order.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:paperless_mobile/util.dart';
class SortDocumentsButton extends StatefulWidget {
const SortDocumentsButton({
@@ -30,16 +33,20 @@ class _SortDocumentsButtonState extends State<SortDocumentsButton> {
),
);
} else {
final bool isAscending = state.filter.sortOrder == SortOrder.ascending;
final bool isAscending =
state.filter.sortOrder == SortOrder.ascending;
child = IconButton(
icon: FaIcon(
isAscending ? FontAwesomeIcons.arrowDownAZ : FontAwesomeIcons.arrowUpZA,
isAscending
? FontAwesomeIcons.arrowDownAZ
: FontAwesomeIcons.arrowUpZA,
),
onPressed: () async {
setState(() => _isLoading = true);
BlocProvider.of<DocumentsCubit>(context)
.updateFilter(
filter: state.filter.copyWith(sortOrder: state.filter.sortOrder.toggle()))
filter: state.filter
.copyWith(sortOrder: state.filter.sortOrder.toggle()))
.whenComplete(() => setState(() => _isLoading = false));
},
);

View File

@@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart';
import 'package:paperless_mobile/core/bloc/global_error_cubit.dart';
import 'package:paperless_mobile/core/logic/error_code_localization_mapper.dart';
import 'package:paperless_mobile/core/widgets/offline_banner.dart';
import 'package:paperless_mobile/di_initializer.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:paperless_mobile/features/documents/bloc/saved_view_cubit.dart';
@@ -35,18 +38,29 @@ class _HomePageState extends State<HomePage> {
@override
Widget build(BuildContext context) {
return BlocListener<ConnectivityCubit, ConnectivityState>(
return BlocListener<GlobalErrorCubit, GlobalErrorState>(
listener: (context, state) {
if (state.hasError) {
showSnackBar(context, translateError(context, state.error!.code));
}
},
child: BlocConsumer<ConnectivityCubit, ConnectivityState>(
//Only re-initialize data if the connectivity changed from not connected to connected
listenWhen: (previous, current) =>
previous != ConnectivityState.connected && current == ConnectivityState.connected,
current == ConnectivityState.connected,
listener: (context, state) {
initializeLabelData(context);
},
child: Scaffold(
builder: (context, connectivityState) {
return Scaffold(
appBar: connectivityState == ConnectivityState.connected
? null
: const OfflineBanner(),
key: rootScaffoldKey,
bottomNavigationBar: BottomNavBar(
selectedIndex: _currentIndex,
onNavigationChanged: (index) => setState(() => _currentIndex = index),
onNavigationChanged: (index) =>
setState(() => _currentIndex = index),
),
drawer: const InfoDrawer(),
body: [
@@ -62,6 +76,8 @@ class _HomePageState extends State<HomePage> {
),
const LabelsPage(),
][_currentIndex],
);
},
),
);
}

View File

@@ -5,7 +5,10 @@ class BottomNavBar extends StatelessWidget {
final int selectedIndex;
final void Function(int) onNavigationChanged;
const BottomNavBar({Key? key, required this.selectedIndex, required this.onNavigationChanged})
const BottomNavBar(
{Key? key,
required this.selectedIndex,
required this.onNavigationChanged})
: super(key: key);
@override

View File

@@ -1,5 +1,8 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/bloc/global_error_cubit.dart';
import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart';
import 'package:paperless_mobile/di_initializer.dart';
import 'package:paperless_mobile/features/labels/correspondent/bloc/correspondents_cubit.dart';
@@ -11,12 +14,12 @@ import 'package:paperless_mobile/features/scan/bloc/document_scanner_cubit.dart'
import 'package:paperless_mobile/features/labels/tags/bloc/tags_cubit.dart';
import 'package:paperless_mobile/generated/l10n.dart';
import 'package:paperless_mobile/util.dart';
import 'package:url_launcher/link.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
class InfoDrawer extends StatelessWidget {
const InfoDrawer({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Drawer(
@@ -37,10 +40,9 @@ class InfoDrawer extends StatelessWidget {
).padded(const EdgeInsets.only(right: 8.0)),
Text(
S.of(context).appTitleText,
style: Theme.of(context)
.textTheme
.headline5!
.copyWith(color: Theme.of(context).colorScheme.onPrimaryContainer),
style: Theme.of(context).textTheme.headline5!.copyWith(
color:
Theme.of(context).colorScheme.onPrimaryContainer),
),
],
),
@@ -49,9 +51,14 @@ class InfoDrawer extends StatelessWidget {
child: BlocBuilder<AuthenticationCubit, AuthenticationState>(
builder: (context, state) {
return Text(
state.authentication?.serverUrl.replaceAll(RegExp(r'https?://'), "") ?? "",
state.authentication?.serverUrl
.replaceAll(RegExp(r'https?://'), "") ??
"",
textAlign: TextAlign.end,
style: TextStyle(color: Theme.of(context).colorScheme.onPrimaryContainer),
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onPrimaryContainer),
);
},
),
@@ -81,17 +88,39 @@ class InfoDrawer extends StatelessWidget {
leading: const Icon(Icons.bug_report),
title: Text(S.of(context).appDrawerReportBugLabel),
onTap: () {
launchUrlString("https://github.com/astubenbord/paperless-mobile/issues/new");
launchUrlString(
"https://github.com/astubenbord/paperless-mobile/issues/new");
},
),
const Divider(),
AboutListTile(
icon: const Icon(Icons.info),
applicationIcon: const ImageIcon(AssetImage("assets/logos/paperless_logo_green.png")),
applicationIcon: const ImageIcon(
AssetImage("assets/logos/paperless_logo_green.png")),
applicationName: "Paperless Mobile",
applicationVersion: kPackageInfo.version + "+" + kPackageInfo.buildNumber,
applicationVersion:
kPackageInfo.version + "+" + kPackageInfo.buildNumber,
aboutBoxChildren: [
Text('${S.of(context).aboutDialogDevelopedByText} Anton Stubenbord'),
Text(
'${S.of(context).aboutDialogDevelopedByText} Anton Stubenbord'),
Link(
uri: Uri.parse(
"https://github.com/astubenbord/paperless-mobile"),
builder: (context, followLink) => GestureDetector(
onTap: followLink,
child: Text(
"https://github.com/astubenbord/paperless-mobile",
style: TextStyle(
color: Theme.of(context).colorScheme.tertiary),
),
),
),
const SizedBox(height: 16),
Text(
"Credits",
style: Theme.of(context).textTheme.titleMedium,
),
_buildOnboardingImageCredits(),
],
child: Text(S.of(context).appDrawerAboutLabel),
),
@@ -107,6 +136,7 @@ class InfoDrawer extends StatelessWidget {
getIt<DocumentTypeCubit>().reset();
getIt<TagCubit>().reset();
getIt<DocumentScannerCubit>().reset();
getIt<GlobalErrorCubit>().reset();
},
),
const Divider(),
@@ -114,4 +144,24 @@ class InfoDrawer extends StatelessWidget {
),
);
}
Link _buildOnboardingImageCredits() {
return Link(
uri: Uri.parse(
"https://www.freepik.com/free-vector/business-team-working-cogwheel-mechanism-together_8270974.htm#query=setting&position=4&from_view=author"),
builder: (context, followLink) => Wrap(
children: [
const Text("Onboarding images by "),
GestureDetector(
onTap: followLink,
child: Text(
"pch.vector",
style: TextStyle(color: Theme.of(context).colorScheme.tertiary),
),
),
const Text(" on Freepik.")
],
),
);
}
}

View File

@@ -4,7 +4,7 @@ import 'package:injectable/injectable.dart';
@singleton
class CorrespondentCubit extends LabelCubit<Correspondent> {
CorrespondentCubit(super.metaDataService);
CorrespondentCubit(super.metaDataService, super.errorCubit);
@override
Future<void> initialize() async {
@@ -12,11 +12,14 @@ class CorrespondentCubit extends LabelCubit<Correspondent> {
}
@override
Future<Correspondent> save(Correspondent item) => labelRepository.saveCorrespondent(item);
Future<Correspondent> save(Correspondent item) =>
labelRepository.saveCorrespondent(item);
@override
Future<Correspondent> update(Correspondent item) => labelRepository.updateCorrespondent(item);
Future<Correspondent> update(Correspondent item) =>
labelRepository.updateCorrespondent(item);
@override
Future<int> delete(Correspondent item) => labelRepository.deleteCorrespondent(item);
Future<int> delete(Correspondent item) =>
labelRepository.deleteCorrespondent(item);
}

View File

@@ -1,4 +1,4 @@
import 'package:paperless_mobile/core/type/json.dart';
import 'package:paperless_mobile/core/type/types.dart';
import 'package:paperless_mobile/extensions/dart_extensions.dart';
import 'package:paperless_mobile/features/labels/document_type/model/matching_algorithm.dart';
import 'package:paperless_mobile/features/labels/model/label.model.dart';
@@ -20,7 +20,8 @@ class Correspondent extends Label {
});
Correspondent.fromJson(JSON json)
: lastCorrespondence = DateTime.tryParse(json[lastCorrespondenceKey] ?? ''),
: lastCorrespondence =
DateTime.tryParse(json[lastCorrespondenceKey] ?? ''),
super.fromJson(json);
@override

View File

@@ -23,19 +23,16 @@ class EditCorrespondentPage extends StatelessWidget {
);
}
Future<void> _onDelete(Correspondent correspondent, BuildContext context) async {
try {
Future<void> _onDelete(
Correspondent correspondent, BuildContext context) async {
await BlocProvider.of<CorrespondentCubit>(context).remove(correspondent);
final cubit = BlocProvider.of<DocumentsCubit>(context);
if (cubit.state.filter.correspondent.id == correspondent.id) {
cubit.updateFilter(
filter: cubit.state.filter.copyWith(correspondent: const CorrespondentQuery.unset()),
filter: cubit.state.filter
.copyWith(correspondent: const CorrespondentQuery.unset()),
);
}
} on ErrorMessage catch (e) {
showSnackBar(context, translateError(context, e.code));
} finally {
Navigator.pop(context);
}
}
}

View File

@@ -46,11 +46,13 @@ class CorrespondentWidget extends StatelessWidget {
final cubit = BlocProvider.of<DocumentsCubit>(context);
if (cubit.state.filter.correspondent.id == correspondentId) {
cubit.updateCurrentFilter(
(filter) => filter.copyWith(correspondent: const CorrespondentQuery.unset()),
(filter) =>
filter.copyWith(correspondent: const CorrespondentQuery.unset()),
);
} else {
cubit.updateCurrentFilter(
(filter) => filter.copyWith(correspondent: CorrespondentQuery.fromId(correspondentId)),
(filter) => filter.copyWith(
correspondent: CorrespondentQuery.fromId(correspondentId)),
);
}
afterSelected?.call();

View File

@@ -4,7 +4,7 @@ import 'package:injectable/injectable.dart';
@singleton
class DocumentTypeCubit extends LabelCubit<DocumentType> {
DocumentTypeCubit(super.metaDataService);
DocumentTypeCubit(super.metaDataService, super.errorCubit);
@override
Future<void> initialize() async {
@@ -12,11 +12,14 @@ class DocumentTypeCubit extends LabelCubit<DocumentType> {
}
@override
Future<DocumentType> save(DocumentType item) => labelRepository.saveDocumentType(item);
Future<DocumentType> save(DocumentType item) =>
labelRepository.saveDocumentType(item);
@override
Future<DocumentType> update(DocumentType item) => labelRepository.updateDocumentType(item);
Future<DocumentType> update(DocumentType item) =>
labelRepository.updateDocumentType(item);
@override
Future<int> delete(DocumentType item) => labelRepository.deleteDocumentType(item);
Future<int> delete(DocumentType item) =>
labelRepository.deleteDocumentType(item);
}

View File

@@ -1,4 +1,4 @@
import 'package:paperless_mobile/core/type/json.dart';
import 'package:paperless_mobile/core/type/types.dart';
import 'package:paperless_mobile/features/labels/document_type/model/matching_algorithm.dart';
import 'package:paperless_mobile/features/labels/model/label.model.dart';

View File

@@ -29,7 +29,8 @@ class EditDocumentTypePage extends StatelessWidget {
final cubit = BlocProvider.of<DocumentsCubit>(context);
if (cubit.state.filter.documentType.id == docType.id) {
cubit.updateFilter(
filter: cubit.state.filter.copyWith(documentType: const DocumentTypeQuery.unset()),
filter: cubit.state.filter
.copyWith(documentType: const DocumentTypeQuery.unset()),
);
}
} on ErrorMessage catch (e) {

View File

@@ -8,18 +8,18 @@ import 'package:paperless_mobile/features/labels/document_type/model/document_ty
class DocumentTypeWidget extends StatelessWidget {
final int? documentTypeId;
final void Function()? afterSelected;
final bool isSelectable;
final bool isClickable;
const DocumentTypeWidget({
Key? key,
required this.documentTypeId,
this.afterSelected,
this.isSelectable = true,
this.isClickable = true,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return AbsorbPointer(
absorbing: !isSelectable,
absorbing: !isClickable,
child: GestureDetector(
onTap: () => _addDocumentTypeToFilter(context),
child: BlocBuilder<DocumentTypeCubit, Map<int, DocumentType>>(
@@ -41,11 +41,13 @@ class DocumentTypeWidget extends StatelessWidget {
final cubit = BlocProvider.of<DocumentsCubit>(context);
if (cubit.state.filter.documentType.id == documentTypeId) {
cubit.updateCurrentFilter(
(filter) => filter.copyWith(documentType: const DocumentTypeQuery.unset()),
(filter) =>
filter.copyWith(documentType: const DocumentTypeQuery.unset()),
);
} else {
cubit.updateCurrentFilter(
(filter) => filter.copyWith(documentType: DocumentTypeQuery.fromId(documentTypeId)),
(filter) => filter.copyWith(
documentType: DocumentTypeQuery.fromId(documentTypeId)),
);
}
afterSelected?.call();

View File

@@ -1,5 +1,5 @@
import 'package:equatable/equatable.dart';
import 'package:paperless_mobile/core/type/json.dart';
import 'package:paperless_mobile/core/type/types.dart';
import 'package:paperless_mobile/extensions/dart_extensions.dart';
import 'package:paperless_mobile/features/labels/document_type/model/matching_algorithm.dart';
@@ -37,7 +37,8 @@ abstract class Label with EquatableMixin implements Comparable {
name = json[nameKey],
slug = json[slugKey],
match = json[matchKey],
matchingAlgorithm = MatchingAlgorithm.fromInt(json[matchingAlgorithmKey]),
matchingAlgorithm =
MatchingAlgorithm.fromInt(json[matchingAlgorithmKey]),
isInsensitive = json[isInsensitiveKey],
documentCount = json[documentCountKey];

View File

@@ -28,7 +28,8 @@ class LabelRepositoryImpl implements LabelRepository {
@override
Future<Tag?> getTag(int id) async {
return getSingleResult("/api/tags/$id/", Tag.fromJson, ErrorCode.tagLoadFailed);
return getSingleResult(
"/api/tags/$id/", Tag.fromJson, ErrorCode.tagLoadFailed);
}
@override
@@ -39,7 +40,9 @@ class LabelRepositoryImpl implements LabelRepository {
ErrorCode.tagLoadFailed,
minRequiredApiVersion: 2,
);
return results.where((element) => ids?.contains(element.id) ?? true).toList();
return results
.where((element) => ids?.contains(element.id) ?? true)
.toList();
}
@override
@@ -78,9 +81,11 @@ class LabelRepositoryImpl implements LabelRepository {
encoding: Encoding.getByName("utf-8"),
);
if (response.statusCode == 201) {
return Correspondent.fromJson(jsonDecode(utf8.decode(response.bodyBytes)));
return Correspondent.fromJson(
jsonDecode(utf8.decode(response.bodyBytes)));
}
throw ErrorMessage(ErrorCode.correspondentCreateFailed, httpStatusCode: response.statusCode);
throw ErrorMessage(ErrorCode.correspondentCreateFailed,
httpStatusCode: response.statusCode);
}
@override
@@ -127,7 +132,8 @@ class LabelRepositoryImpl implements LabelRepository {
@override
Future<int> deleteCorrespondent(Correspondent correspondent) async {
assert(correspondent.id != null);
final response = await httpClient.delete(Uri.parse('/api/correspondents/${correspondent.id}/'));
final response = await httpClient
.delete(Uri.parse('/api/correspondents/${correspondent.id}/'));
if (response.statusCode == 204) {
return correspondent.id!;
}
@@ -137,7 +143,8 @@ class LabelRepositoryImpl implements LabelRepository {
@override
Future<int> deleteDocumentType(DocumentType documentType) async {
assert(documentType.id != null);
final response = await httpClient.delete(Uri.parse('/api/document_types/${documentType.id}/'));
final response = await httpClient
.delete(Uri.parse('/api/document_types/${documentType.id}/'));
if (response.statusCode == 204) {
return documentType.id!;
}
@@ -164,7 +171,8 @@ class LabelRepositoryImpl implements LabelRepository {
encoding: Encoding.getByName("utf-8"),
);
if (response.statusCode == 200) {
return Correspondent.fromJson(jsonDecode(utf8.decode(response.bodyBytes)));
return Correspondent.fromJson(
jsonDecode(utf8.decode(response.bodyBytes)));
}
throw const ErrorMessage(ErrorCode.unknown);
}
@@ -205,7 +213,8 @@ class LabelRepositoryImpl implements LabelRepository {
@override
Future<int> deleteStoragePath(StoragePath path) async {
assert(path.id != null);
final response = await httpClient.delete(Uri.parse('/api/storage_paths/${path.id}/'));
final response =
await httpClient.delete(Uri.parse('/api/storage_paths/${path.id}/'));
if (response.statusCode == 204) {
return path.id!;
}
@@ -214,8 +223,8 @@ class LabelRepositoryImpl implements LabelRepository {
@override
Future<StoragePath?> getStoragePath(int id) {
return getSingleResult("/api/storage_paths/?page=1&page_size=100000", StoragePath.fromJson,
ErrorCode.storagePathLoadFailed);
return getSingleResult("/api/storage_paths/?page=1&page_size=100000",
StoragePath.fromJson, ErrorCode.storagePathLoadFailed);
}
@override
@@ -237,7 +246,8 @@ class LabelRepositoryImpl implements LabelRepository {
if (response.statusCode == 201) {
return StoragePath.fromJson(jsonDecode(utf8.decode(response.bodyBytes)));
}
throw ErrorMessage(ErrorCode.storagePathCreateFailed, httpStatusCode: response.statusCode);
throw ErrorMessage(ErrorCode.storagePathCreateFailed,
httpStatusCode: response.statusCode);
}
@override

View File

@@ -1,11 +1,10 @@
import 'package:paperless_mobile/core/bloc/label_cubit.dart';
import 'package:paperless_mobile/features/labels/correspondent/model/correspondent.model.dart';
import 'package:paperless_mobile/features/labels/storage_path/model/storage_path.model.dart';
import 'package:injectable/injectable.dart';
import 'package:paperless_mobile/core/bloc/label_cubit.dart';
import 'package:paperless_mobile/features/labels/storage_path/model/storage_path.model.dart';
@singleton
class StoragePathCubit extends LabelCubit<StoragePath> {
StoragePathCubit(super.metaDataService);
StoragePathCubit(super.metaDataService, super.errorCubit);
@override
Future<void> initialize() async {
@@ -13,11 +12,14 @@ class StoragePathCubit extends LabelCubit<StoragePath> {
}
@override
Future<StoragePath> save(StoragePath item) => labelRepository.saveStoragePath(item);
Future<StoragePath> save(StoragePath item) =>
labelRepository.saveStoragePath(item);
@override
Future<StoragePath> update(StoragePath item) => labelRepository.updateStoragePath(item);
Future<StoragePath> update(StoragePath item) =>
labelRepository.updateStoragePath(item);
@override
Future<int> delete(StoragePath item) => labelRepository.deleteStoragePath(item);
Future<int> delete(StoragePath item) =>
labelRepository.deleteStoragePath(item);
}

View File

@@ -1,4 +1,4 @@
import 'package:paperless_mobile/core/type/json.dart';
import 'package:paperless_mobile/core/type/types.dart';
import 'package:paperless_mobile/extensions/dart_extensions.dart';
import 'package:paperless_mobile/features/labels/document_type/model/matching_algorithm.dart';
import 'package:paperless_mobile/features/labels/model/label.model.dart';

View File

@@ -32,17 +32,13 @@ class EditStoragePathPage extends StatelessWidget {
}
Future<void> _onDelete(StoragePath path, BuildContext context) async {
try {
await BlocProvider.of<StoragePathCubit>(context).remove(path);
final cubit = BlocProvider.of<DocumentsCubit>(context);
if (cubit.state.filter.storagePath.id == path.id) {
cubit.updateFilter(
filter: cubit.state.filter.copyWith(storagePath: const StoragePathQuery.unset()));
filter: cubit.state.filter
.copyWith(storagePath: const StoragePathQuery.unset()));
}
} on ErrorMessage catch (e) {
showSnackBar(context, translateError(context, e.code));
} finally {
Navigator.pop(context);
}
}
}

View File

@@ -1,10 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter/src/widgets/container.dart';
import 'package:flutter/src/widgets/framework.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:paperless_mobile/features/labels/storage_path/model/storage_path.model.dart';
import 'package:paperless_mobile/generated/l10n.dart';
import 'package:form_builder_validators/form_builder_validators.dart';
import 'package:paperless_mobile/generated/l10n.dart';
class StoragePathAutofillFormBuilderField extends StatefulWidget {
final String name;
@@ -20,10 +18,10 @@ class StoragePathAutofillFormBuilderField extends StatefulWidget {
_StoragePathAutofillFormBuilderFieldState();
}
class _StoragePathAutofillFormBuilderFieldState extends State<StoragePathAutofillFormBuilderField> {
class _StoragePathAutofillFormBuilderFieldState
extends State<StoragePathAutofillFormBuilderField> {
late final TextEditingController _textEditingController;
late String _exampleOutput;
late bool _showClearIcon;
@override
void initState() {
@@ -35,7 +33,6 @@ class _StoragePathAutofillFormBuilderFieldState extends State<StoragePathAutofil
_showClearIcon = _textEditingController.text.isNotEmpty;
});
});
_exampleOutput = _buildExampleOutput(widget.initialValue ?? '');
_showClearIcon = widget.initialValue?.isNotEmpty ?? false;
}
@@ -70,7 +67,8 @@ class _StoragePathAutofillFormBuilderFieldState extends State<StoragePathAutofil
spacing: 8.0,
children: [
InputChip(
label: Text(S.of(context).documentArchiveSerialNumberPropertyLongLabel),
label: Text(
S.of(context).documentArchiveSerialNumberPropertyLongLabel),
onPressed: () => _addParameterToInput("{asn}", field),
),
InputChip(
@@ -138,22 +136,8 @@ class _StoragePathAutofillFormBuilderFieldState extends State<StoragePathAutofil
final text = (field.value ?? "") + param;
field.didChange(text);
_textEditingController.text = text;
}
String _buildExampleOutput(String input) {
return input
.replaceAll("{asn}", "1234")
.replaceAll("{correspondent}", "My Bank")
.replaceAll("{document_type}", "Invoice")
.replaceAll("{tag_list}", "TODO,University,Work")
.replaceAll("{created}", "2020-02-10")
.replaceAll("{created_year}", "2020")
.replaceAll("{created_month}", "02")
.replaceAll("{created_day}", "10")
.replaceAll("{added}", "2029-12-24")
.replaceAll("{added_year}", "2029")
.replaceAll("{added_month}", "12")
.replaceAll("{added_day}", "24");
_textEditingController.selection = TextSelection.fromPosition(
TextPosition(offset: _textEditingController.text.length));
}
void _resetfield(FormFieldState<String> field) {

View File

@@ -45,11 +45,13 @@ class StoragePathWidget extends StatelessWidget {
final cubit = BlocProvider.of<DocumentsCubit>(context);
if (cubit.state.filter.correspondent.id == pathId) {
cubit.updateCurrentFilter(
(filter) => filter.copyWith(storagePath: const StoragePathQuery.unset()),
(filter) =>
filter.copyWith(storagePath: const StoragePathQuery.unset()),
);
} else {
cubit.updateCurrentFilter(
(filter) => filter.copyWith(storagePath: StoragePathQuery.fromId(pathId)),
(filter) =>
filter.copyWith(storagePath: StoragePathQuery.fromId(pathId)),
);
}
afterSelected?.call();

View File

@@ -4,7 +4,7 @@ import 'package:injectable/injectable.dart';
@singleton
class TagCubit extends LabelCubit<Tag> {
TagCubit(super.metaDataService);
TagCubit(super.metaDataService, super.errorCubit);
@override
Future<void> initialize() async {

View File

@@ -1,7 +1,7 @@
import 'dart:developer';
import 'dart:ui';
import 'package:paperless_mobile/core/type/json.dart';
import 'package:paperless_mobile/core/type/types.dart';
import 'package:paperless_mobile/extensions/dart_extensions.dart';
import 'package:paperless_mobile/features/labels/document_type/model/matching_algorithm.dart';
import 'package:paperless_mobile/features/labels/model/label.model.dart';
@@ -85,7 +85,8 @@ String? _toHex(Color? color) {
if (color == null) {
return null;
}
String val = '#${(color.value & 0xFFFFFF).toRadixString(16).padLeft(6, '0').toLowerCase()}';
String val =
'#${(color.value & 0xFFFFFF).toRadixString(16).padLeft(6, '0').toLowerCase()}';
log("Color in Tag#_toHex is $val");
return val;
}

View File

@@ -24,6 +24,7 @@ class AddTagPage extends StatelessWidget {
label: Text(S.of(context).tagColorPropertyLabel),
),
colorPickerType: ColorPickerType.materialPicker,
initialValue: null,
),
FormBuilderCheckbox(
name: Tag.isInboxTagKey,

View File

@@ -43,7 +43,6 @@ class EditTagPage extends StatelessWidget {
}
Future<void> _onDelete(Tag tag, BuildContext context) async {
try {
await BlocProvider.of<TagCubit>(context).remove(tag);
final cubit = BlocProvider.of<DocumentsCubit>(context);
final currentFilter = cubit.state.filter;
@@ -56,10 +55,6 @@ class EditTagPage extends StatelessWidget {
);
}
cubit.updateFilter(filter: updatedFilter);
} on ErrorMessage catch (error) {
showError(context, error);
} finally {
Navigator.pop(context);
}
}
}

View File

@@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_state.dart';
import 'package:paperless_mobile/features/documents/model/query_parameters/tags_query.dart';
import 'package:paperless_mobile/features/labels/tags/model/tag.model.dart';
import 'package:paperless_mobile/util.dart';
class TagWidget extends StatelessWidget {
final Tag tag;
@@ -37,15 +39,17 @@ class TagWidget extends StatelessWidget {
void _addTagToFilter(BuildContext context) {
final cubit = BlocProvider.of<DocumentsCubit>(context);
if (cubit.state.filter.tags.ids.contains(tag.id)) {
cubit.updateFilter(
filter: cubit.state.filter.copyWith(
tags: TagsQuery.fromIds(cubit.state.filter.tags.ids.where((id) => id != tag.id).toList()),
cubit.updateCurrentFilter(
(filter) => filter.copyWith(
tags: TagsQuery.fromIds(
cubit.state.filter.tags.ids.where((id) => id != tag.id).toList()),
),
);
} else {
cubit.updateFilter(
filter: cubit.state.filter
.copyWith(tags: TagsQuery.fromIds([...cubit.state.filter.tags.ids, tag.id!])),
cubit.updateCurrentFilter(
(filter) => filter.copyWith(
tags: TagsQuery.fromIds([...cubit.state.filter.tags.ids, tag.id!]),
),
);
}
if (afterTagTapped != null) {

View File

@@ -72,7 +72,8 @@ class _TagFormFieldState extends State<TagFormField> {
),
),
selectedColor: tag.color,
selected: field.value?.ids.contains(tag.id) ?? false,
selected:
field.value?.ids.contains(tag.id) ?? false,
onSelected: (isSelected) {
List<int> ids = [...field.value?.ids ?? []];
if (isSelected) {

View File

@@ -8,12 +8,14 @@ class TagsWidget extends StatefulWidget {
final List<int> tagIds;
final bool isMultiLine;
final void Function()? afterTagTapped;
final bool isClickable;
const TagsWidget({
Key? key,
required this.tagIds,
this.afterTagTapped,
this.isMultiLine = true,
this.isClickable = true,
}) : super(key: key);
@override

View File

@@ -5,6 +5,7 @@ import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:paperless_mobile/core/bloc/label_cubit.dart';
import 'package:paperless_mobile/core/logic/error_code_localization_mapper.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:paperless_mobile/core/type/types.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/labels/document_type/model/matching_algorithm.dart';
import 'package:paperless_mobile/features/labels/model/label.model.dart';
@@ -34,7 +35,7 @@ class AddLabelPage<T extends Label> extends StatefulWidget {
class _AddLabelPageState<T extends Label> extends State<AddLabelPage<T>> {
final _formKey = GlobalKey<FormBuilderState>();
Map<String, String> _errors = {};
PaperlessValidationErrors _errors = {};
@override
Widget build(BuildContext context) {
@@ -103,11 +104,12 @@ class _AddLabelPageState<T extends Label> extends State<AddLabelPage<T>> {
void _onSubmit() async {
if (_formKey.currentState?.saveAndValidate() ?? false) {
try {
final label = await widget.cubit.add(widget.fromJson(_formKey.currentState!.value));
final label = await widget.cubit
.add(widget.fromJson(_formKey.currentState!.value));
Navigator.pop(context, label);
} on ErrorMessage catch (e) {
showSnackBar(context, translateError(context, e.code));
} on Map<String, String> catch (json) {
} on PaperlessValidationErrors catch (json) {
setState(() => _errors = json);
}
}

View File

@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:paperless_mobile/core/logic/error_code_localization_mapper.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:paperless_mobile/core/type/json.dart';
import 'package:paperless_mobile/core/type/types.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/labels/document_type/model/matching_algorithm.dart';
import 'package:paperless_mobile/features/labels/model/label.model.dart';
@@ -35,7 +35,7 @@ class EditLabelPage<T extends Label> extends StatefulWidget {
class _EditLabelPageState<T extends Label> extends State<EditLabelPage<T>> {
final _formKey = GlobalKey<FormBuilderState>();
Map<String, String> _errors = {};
PaperlessValidationErrors _errors = {};
@override
Widget build(BuildContext context) {
@@ -80,8 +80,8 @@ class _EditLabelPageState<T extends Label> extends State<EditLabelPage<T>> {
),
FormBuilderDropdown<int?>(
name: Label.matchingAlgorithmKey,
initialValue:
widget.label.matchingAlgorithm?.value ?? MatchingAlgorithm.allWords.value,
initialValue: widget.label.matchingAlgorithm?.value ??
MatchingAlgorithm.allWords.value,
decoration: InputDecoration(
labelText: S.of(context).labelMatchingAlgorithmPropertyLabel,
errorText: _errors[Label.matchingAlgorithmKey],
@@ -111,12 +111,13 @@ class _EditLabelPageState<T extends Label> extends State<EditLabelPage<T>> {
void _onSubmit() async {
if (_formKey.currentState?.saveAndValidate() ?? false) {
try {
final mergedJson = {...widget.label.toJson(), ..._formKey.currentState!.value};
final mergedJson = {
...widget.label.toJson(),
..._formKey.currentState!.value
};
await widget.onSubmit(widget.fromJson(mergedJson));
Navigator.pop(context);
} on ErrorMessage catch (e) {
showSnackBar(context, translateError(context, e.code));
} on Map<String, String> catch (errorMessages) {
} on PaperlessValidationErrors catch (errorMessages) {
setState(() => _errors = errorMessages);
}
}

View File

@@ -37,7 +37,8 @@ class LabelsPage extends StatefulWidget {
State<LabelsPage> createState() => _LabelsPageState();
}
class _LabelsPageState extends State<LabelsPage> with SingleTickerProviderStateMixin {
class _LabelsPageState extends State<LabelsPage>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
int _currentIndex = 0;
@@ -54,7 +55,9 @@ class _LabelsPageState extends State<LabelsPage> with SingleTickerProviderStateM
@override
Widget build(BuildContext context) {
return DefaultTabController(
return BlocProvider.value(
value: getIt<DocumentsCubit>(),
child: DefaultTabController(
length: 3,
child: Scaffold(
drawer: const InfoDrawer(),
@@ -120,6 +123,12 @@ class _LabelsPageState extends State<LabelsPage> with SingleTickerProviderStateM
pageSize: label.documentCount ?? 0,
),
onOpenEditPage: _openEditCorrespondentPage,
emptyStateActionButtonLabel:
S.of(context).labelsPageCorrespondentEmptyStateAddNewLabel,
emptyStateDescription: S
.of(context)
.labelsPageCorrespondentEmptyStateDescriptionText,
onOpenAddNewPage: _onAddPressed,
),
LabelTabView<DocumentType>(
cubit: BlocProvider.of<DocumentTypeCubit>(context),
@@ -128,6 +137,12 @@ class _LabelsPageState extends State<LabelsPage> with SingleTickerProviderStateM
pageSize: label.documentCount ?? 0,
),
onOpenEditPage: _openEditDocumentTypePage,
emptyStateActionButtonLabel:
S.of(context).labelsPageDocumentTypeEmptyStateAddNewLabel,
emptyStateDescription: S
.of(context)
.labelsPageDocumentTypeEmptyStateDescriptionText,
onOpenAddNewPage: _onAddPressed,
),
LabelTabView<Tag>(
cubit: BlocProvider.of<TagCubit>(context),
@@ -137,6 +152,11 @@ class _LabelsPageState extends State<LabelsPage> with SingleTickerProviderStateM
),
onOpenEditPage: _openEditTagPage,
leadingBuilder: (t) => CircleAvatar(backgroundColor: t.color),
emptyStateActionButtonLabel:
S.of(context).labelsPageTagsEmptyStateAddNewLabel,
emptyStateDescription:
S.of(context).labelsPageTagsEmptyStateDescriptionText,
onOpenAddNewPage: _onAddPressed,
),
LabelTabView<StoragePath>(
cubit: BlocProvider.of<StoragePathCubit>(context),
@@ -146,10 +166,17 @@ class _LabelsPageState extends State<LabelsPage> with SingleTickerProviderStateM
pageSize: label.documentCount ?? 0,
),
contentBuilder: (path) => Text(path.path ?? ""),
emptyStateActionButtonLabel:
S.of(context).labelsPageStoragePathEmptyStateAddNewLabel,
emptyStateDescription: S
.of(context)
.labelsPageStoragePathEmptyStateDescriptionText,
onOpenAddNewPage: _onAddPressed,
),
],
),
),
),
);
}
@@ -160,7 +187,8 @@ class _LabelsPageState extends State<LabelsPage> with SingleTickerProviderStateM
builder: (_) => MultiBlocProvider(
providers: [
BlocProvider.value(value: getIt<DocumentsCubit>()),
BlocProvider.value(value: BlocProvider.of<CorrespondentCubit>(context)),
BlocProvider.value(
value: BlocProvider.of<CorrespondentCubit>(context)),
],
child: EditCorrespondentPage(correspondent: correspondent),
),
@@ -175,7 +203,8 @@ class _LabelsPageState extends State<LabelsPage> with SingleTickerProviderStateM
builder: (_) => MultiBlocProvider(
providers: [
BlocProvider.value(value: getIt<DocumentsCubit>()),
BlocProvider.value(value: BlocProvider.of<DocumentTypeCubit>(context)),
BlocProvider.value(
value: BlocProvider.of<DocumentTypeCubit>(context)),
],
child: EditDocumentTypePage(documentType: docType),
),
@@ -205,7 +234,8 @@ class _LabelsPageState extends State<LabelsPage> with SingleTickerProviderStateM
builder: (_) => MultiBlocProvider(
providers: [
BlocProvider.value(value: getIt<DocumentsCubit>()),
BlocProvider.value(value: BlocProvider.of<StoragePathCubit>(context)),
BlocProvider.value(
value: BlocProvider.of<StoragePathCubit>(context)),
],
child: EditStoragePathPage(storagePath: path),
),

View File

@@ -12,7 +12,8 @@ import 'package:form_builder_extra_fields/form_builder_extra_fields.dart';
/// Form field allowing to select labels (i.e. correspondent, documentType)
/// [T] is the label type (e.g. [DocumentType], [Correspondent], ...), [R] is the return type (e.g. [CorrespondentQuery], ...).
///
class LabelFormField<T extends Label, R extends IdQueryParameter> extends StatefulWidget {
class LabelFormField<T extends Label, R extends IdQueryParameter>
extends StatefulWidget {
final Widget prefixIcon;
final Map<int, T> state;
final FormBuilderState? formBuilderState;
@@ -57,8 +58,8 @@ class _LabelFormFieldState<T extends Label, R extends IdQueryParameter>
void initState() {
super.initState();
_showClearSuffixIcon = widget.state.containsKey(widget.initialValue?.id);
_textEditingController =
TextEditingController(text: widget.state[widget.initialValue?.id]?.name ?? '')
_textEditingController = TextEditingController(
text: widget.state[widget.initialValue?.id]?.name ?? '')
..addListener(() {
setState(() {
_showCreationSuffixIcon = widget.state.values
@@ -67,7 +68,8 @@ class _LabelFormFieldState<T extends Label, R extends IdQueryParameter>
))
.isEmpty;
});
setState(() => _showClearSuffixIcon = _textEditingController.text.isNotEmpty);
setState(() =>
_showClearSuffixIcon = _textEditingController.text.isNotEmpty);
});
}
@@ -79,18 +81,22 @@ class _LabelFormFieldState<T extends Label, R extends IdQueryParameter>
child: Text(
S.of(context).labelFormFieldNoItemsFoundText,
textAlign: TextAlign.center,
style: TextStyle(color: Theme.of(context).disabledColor, fontSize: 18.0),
style:
TextStyle(color: Theme.of(context).disabledColor, fontSize: 18.0),
),
),
initialValue: widget.initialValue ?? widget.queryParameterIdBuilder(null),
name: widget.name,
itemBuilder: (context, suggestion) => ListTile(
title: Text(widget.state[suggestion.id]?.name ?? S.of(context).labelNotAssignedText),
title: Text(widget.state[suggestion.id]?.name ??
S.of(context).labelNotAssignedText),
),
suggestionsCallback: (pattern) {
final List<IdQueryParameter> suggestions = widget.state.keys
.where((item) =>
widget.state[item]!.name.toLowerCase().startsWith(pattern.toLowerCase()) ||
widget.state[item]!.name
.toLowerCase()
.startsWith(pattern.toLowerCase()) ||
pattern.isEmpty)
.map((id) => widget.queryParameterIdBuilder(id))
.toList();
@@ -117,8 +123,9 @@ class _LabelFormFieldState<T extends Label, R extends IdQueryParameter>
return widget.state[suggestion.id]?.name ?? "";
},
direction: AxisDirection.up,
onSuggestionSelected: (suggestion) =>
widget.formBuilderState?.fields[widget.name]?.didChange(suggestion as R),
onSuggestionSelected: (suggestion) => widget
.formBuilderState?.fields[widget.name]
?.didChange(suggestion as R),
);
}
@@ -127,8 +134,8 @@ class _LabelFormFieldState<T extends Label, R extends IdQueryParameter>
return IconButton(
onPressed: () => Navigator.of(context)
.push<T>(MaterialPageRoute(
builder: (context) =>
widget.labelCreationWidgetBuilder!(_textEditingController.text)))
builder: (context) => widget
.labelCreationWidgetBuilder!(_textEditingController.text)))
.then((value) {
if (value != null) {
// If new label has been created, set form field value and text of this form field and unfocus keyboard (we assume user is done).
@@ -155,7 +162,8 @@ class _LabelFormFieldState<T extends Label, R extends IdQueryParameter>
}
void _reset() {
widget.formBuilderState?.fields[widget.name]?.didChange(widget.queryParameterIdBuilder(null));
widget.formBuilderState?.fields[widget.name]
?.didChange(widget.queryParameterIdBuilder(null));
_textEditingController.clear();
}

View File

@@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/bloc/global_error_cubit.dart';
import 'package:paperless_mobile/core/bloc/label_bloc_provider.dart';
import 'package:paperless_mobile/core/logic/error_code_localization_mapper.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:paperless_mobile/di_initializer.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:paperless_mobile/features/documents/model/document_filter.dart';
@@ -33,11 +36,11 @@ class LabelItem<T extends Label> extends StatelessWidget {
subtitle: content,
leading: leading,
onTap: () => onOpenEditPage(label),
trailing: _buildDocumentCountWidget(context),
trailing: _buildReferencedDocumentsWidget(context),
);
}
Widget _buildDocumentCountWidget(BuildContext context) {
Widget _buildReferencedDocumentsWidget(BuildContext context) {
return TextButton.icon(
label: const Icon(Icons.link),
icon: Text(_formatDocumentCount(label.documentCount)),
@@ -50,8 +53,10 @@ class LabelItem<T extends Label> extends StatelessWidget {
MaterialPageRoute(
builder: (context) => LabelBlocProvider(
child: BlocProvider(
create: (context) =>
DocumentsCubit(getIt<DocumentRepository>())..updateFilter(filter: filter),
create: (context) => DocumentsCubit(
getIt<DocumentRepository>(),
getIt<GlobalErrorCubit>())
..updateFilter(filter: filter),
child: LinkedDocumentsPreview(filter: filter),
),
),

View File

@@ -1,73 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/bloc/label_bloc_provider.dart';
import 'package:paperless_mobile/di_initializer.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:paperless_mobile/features/documents/model/document_filter.dart';
import 'package:paperless_mobile/features/documents/repository/document_repository.dart';
import 'package:paperless_mobile/features/labels/model/label.model.dart';
import 'package:paperless_mobile/features/labels/tags/model/tag.model.dart';
import 'package:paperless_mobile/features/labels/view/widgets/linked_documents_preview.dart';
class LabelListTile<T extends Label> extends StatelessWidget {
final T label;
final DocumentFilter Function(Label) filterBuilder;
final void Function() onOpenEditPage;
const LabelListTile(
this.label, {
super.key,
required this.filterBuilder,
required this.onOpenEditPage,
});
@override
Widget build(BuildContext context) {
return ListTile(
leading: (label is Tag)
? CircleAvatar(
backgroundColor: (label as Tag).color,
)
: null,
title: Text(label.name),
onTap: onOpenEditPage,
trailing: _buildDocumentCountWidget(context),
subtitle: Text(
(label.match?.isEmpty ?? true) ? "-" : label.match!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
);
}
Widget _buildDocumentCountWidget(BuildContext context) {
return TextButton.icon(
label: const Icon(Icons.link),
icon: Text(_formatDocumentCount(label.documentCount)),
onPressed: (label.documentCount ?? 0) == 0
? null
: () {
final filter = filterBuilder(label);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => LabelBlocProvider(
child: BlocProvider(
create: (context) =>
DocumentsCubit(getIt<DocumentRepository>())..updateFilter(filter: filter),
child: LinkedDocumentsPreview(filter: filter),
),
),
),
);
},
);
}
String _formatDocumentCount(int? count) {
if ((count ?? 0) > 99) {
return "99+";
}
return (count ?? 0).toString().padLeft(3);
}
}

View File

@@ -1,14 +1,18 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart';
import 'package:paperless_mobile/core/bloc/label_cubit.dart';
import 'package:paperless_mobile/core/widgets/offline_widget.dart';
import 'package:paperless_mobile/features/documents/model/document_filter.dart';
import 'package:paperless_mobile/features/labels/model/label.model.dart';
import 'package:paperless_mobile/features/labels/view/widgets/label_item.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
class LabelTabView<T extends Label> extends StatelessWidget {
final LabelCubit<T> cubit;
final DocumentFilter Function(Label) filterBuilder;
final void Function(T) onOpenEditPage;
final void Function() onOpenAddNewPage;
/// Displayed as the subtitle of the [ListTile]
final Widget Function(T)? contentBuilder;
@@ -16,6 +20,10 @@ class LabelTabView<T extends Label> extends StatelessWidget {
/// Displayed as the leading widget of the [ListTile]
final Widget Function(T)? leadingBuilder;
/// Shown on empty State
final String emptyStateDescription;
final String emptyStateActionButtonLabel;
const LabelTabView({
super.key,
required this.cubit,
@@ -23,27 +31,55 @@ class LabelTabView<T extends Label> extends StatelessWidget {
this.contentBuilder,
this.leadingBuilder,
required this.onOpenEditPage,
required this.emptyStateDescription,
required this.onOpenAddNewPage,
required this.emptyStateActionButtonLabel,
});
@override
Widget build(BuildContext context) {
return BlocBuilder<Cubit<Map<int, T>>, Map<int, T>>(
return BlocBuilder<ConnectivityCubit, ConnectivityState>(
builder: (context, state) {
if (state == ConnectivityState.notConnected) {
return const OfflineWidget();
}
return RefreshIndicator(
onRefresh: cubit.initialize,
child: BlocBuilder<Cubit<Map<int, T>>, Map<int, T>>(
bloc: cubit,
builder: (context, state) {
final labels = state.values.toList()..sort();
return RefreshIndicator(
onRefresh: cubit.initialize,
child: ListView(
if (labels.isEmpty) {
return Center(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
emptyStateDescription,
textAlign: TextAlign.center,
),
TextButton(
onPressed: onOpenAddNewPage,
child: Text(emptyStateActionButtonLabel),
)
].padded(),
),
);
}
return ListView(
children: labels
.map((l) => LabelItem<T>(
name: l.name,
content: contentBuilder?.call(l) ?? Text(l.match ?? '-'),
content:
contentBuilder?.call(l) ?? Text(l.match ?? '-'),
onOpenEditPage: onOpenEditPage,
filterBuilder: filterBuilder,
leading: leadingBuilder?.call(l),
label: l,
))
.toList(),
);
},
),
);
},

View File

@@ -20,7 +20,8 @@ class LinkedDocumentsPreview extends StatefulWidget {
}
class _LinkedDocumentsPreviewState extends State<LinkedDocumentsPreview> {
final PagingController<int, DocumentModel> _pagingController = PagingController(firstPageKey: 1);
final _pagingController =
PagingController<int, DocumentModel>(firstPageKey: 1);
@override
void initState() {
@@ -37,9 +38,18 @@ class _LinkedDocumentsPreviewState extends State<LinkedDocumentsPreview> {
body: BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, state) {
_pagingController.itemList = state.documents;
return CustomScrollView(
return Column(
children: [
Text(
S.of(context).referencedDocumentsReadOnlyHintText,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.caption,
),
Expanded(
child: CustomScrollView(
slivers: [
DocumentListView(
isLabelClickable: false,
onTap: (doc) {
Navigator.push(
context,
@@ -47,17 +57,26 @@ class _LinkedDocumentsPreviewState extends State<LinkedDocumentsPreview> {
builder: (ctxt) => LabelBlocProvider(
child: BlocProvider.value(
value: BlocProvider.of<DocumentsCubit>(context),
child: DocumentDetailsPage(documentId: doc.id)),
child: DocumentDetailsPage(
documentId: doc.id,
allowEdit: false,
isLabelClickable: false,
),
),
),
),
);
},
pagingController: _pagingController,
state: state,
onSelected: BlocProvider.of<DocumentsCubit>(context).toggleDocumentSelection,
onSelected: BlocProvider.of<DocumentsCubit>(context)
.toggleDocumentSelection,
hasInternetConnection: true,
),
],
),
),
],
);
},
),

View File

@@ -1,6 +1,7 @@
import 'dart:io';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/bloc/global_error_cubit.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:paperless_mobile/core/store/local_vault.dart';
import 'package:paperless_mobile/di_initializer.dart';
@@ -16,10 +17,14 @@ const authenticationKey = "authentication";
@singleton
class AuthenticationCubit extends Cubit<AuthenticationState> {
final LocalVault localStore;
final GlobalErrorCubit errorCubit;
final AuthenticationService authenticationService;
AuthenticationCubit(this.localStore, this.authenticationService)
: super(AuthenticationState.initial);
AuthenticationCubit(
this.localStore,
this.authenticationService,
this.errorCubit,
) : super(AuthenticationState.initial);
Future<void> initialize() {
return restoreSessionState();
@@ -29,13 +34,11 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
required UserCredentials credentials,
required String serverUrl,
ClientCertificate? clientCertificate,
bool propagateEventOnError = true,
}) async {
assert(credentials.username != null && credentials.password != null);
try {
registerSecurityContext(clientCertificate);
} on TlsException catch (_) {
throw const ErrorMessage(ErrorCode.invalidClientCertificateConfiguration);
}
emit(
AuthenticationState(
isAuthenticated: false,
@@ -69,18 +72,36 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
wasLoginStored: false,
authentication: auth,
));
} on TlsException catch (_) {
const error =
ErrorMessage(ErrorCode.invalidClientCertificateConfiguration);
if (propagateEventOnError) {
errorCubit.add(error);
}
throw error;
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
}
rethrow;
}
}
Future<void> restoreSessionState() async {
Future<void> restoreSessionState({
bool propagateEventOnError = true,
}) async {
try {
final storedAuth = await localStore.loadAuthenticationInformation();
final appSettings =
await localStore.loadApplicationSettings() ?? ApplicationSettingsState.defaultSettings;
final appSettings = await localStore.loadApplicationSettings() ??
ApplicationSettingsState.defaultSettings;
if (storedAuth == null || !storedAuth.isValid) {
emit(AuthenticationState(isAuthenticated: false, wasLoginStored: false));
emit(
AuthenticationState(isAuthenticated: false, wasLoginStored: false));
} else {
if (!appSettings.isLocalAuthenticationEnabled ||
await authenticationService.authenticateLocalUser("Authenticate to log back in")) {
await authenticationService
.authenticateLocalUser("Authenticate to log back in")) {
registerSecurityContext(storedAuth.clientCertificate);
emit(
AuthenticationState(
@@ -90,7 +111,13 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
),
);
} else {
emit(AuthenticationState(isAuthenticated: false, wasLoginStored: true));
emit(AuthenticationState(
isAuthenticated: false, wasLoginStored: true));
}
}
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
}
}
}

View File

@@ -7,8 +7,8 @@ class LocalAuthenticationCubit extends Cubit<LocalAuthenticationState> {
LocalAuthenticationCubit() : super(LocalAuthenticationState(false));
Future<void> authorize(String localizedMessage) async {
final isAuthenticationSuccessful =
await getIt<LocalAuthentication>().authenticate(localizedReason: localizedMessage);
final isAuthenticationSuccessful = await getIt<LocalAuthentication>()
.authenticate(localizedReason: localizedMessage);
if (isAuthenticationSuccessful) {
emit(LocalAuthenticationState(true));
} else {

View File

@@ -1,4 +1,4 @@
import 'package:paperless_mobile/core/type/json.dart';
import 'package:paperless_mobile/core/type/types.dart';
import 'package:paperless_mobile/features/login/model/client_certificate.dart';
class AuthenticationInformation {
@@ -59,8 +59,8 @@ class AuthenticationInformation {
password: password ?? this.password,
token: token ?? this.token,
serverUrl: serverUrl ?? this.serverUrl,
clientCertificate:
clientCertificate ?? (removeClientCertificate ? null : this.clientCertificate),
clientCertificate: clientCertificate ??
(removeClientCertificate ? null : this.clientCertificate),
);
}
}

View File

@@ -1,7 +1,7 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:paperless_mobile/core/type/json.dart';
import 'package:paperless_mobile/core/type/types.dart';
class ClientCertificate {
static const bytesKey = 'bytes';

View File

@@ -34,7 +34,9 @@ class AuthenticationService {
final data = jsonDecode(utf8.decode(response.bodyBytes));
return data['token'];
} else if (response.statusCode == 400 &&
response.body.toLowerCase().contains("no required certificate was sent")) {
response.body
.toLowerCase()
.contains("no required certificate was sent")) {
throw const ErrorMessage(ErrorCode.invalidClientCertificateConfiguration);
} else {
throw const ErrorMessage(ErrorCode.authenticationFailed);

View File

@@ -1,14 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:paperless_mobile/di_initializer.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/login/bloc/authentication_cubit.dart';
import 'package:paperless_mobile/features/login/view/widgets/client_certificate_form_field.dart';
import 'package:paperless_mobile/features/login/view/widgets/server_address_form_field.dart';
import 'package:paperless_mobile/features/login/view/widgets/user_credentials_form_field.dart';
import 'package:paperless_mobile/generated/l10n.dart';
import 'package:paperless_mobile/util.dart';
class LoginPage extends StatefulWidget {
const LoginPage({Key? key}) : super(key: key);
@@ -73,7 +71,8 @@ class _LoginPageState extends State<LoginPage> {
Widget _buildLoginButton() {
return ElevatedButton(
style: ButtonStyle(
backgroundColor: MaterialStatePropertyAll(Theme.of(context).colorScheme.primaryContainer),
backgroundColor: MaterialStatePropertyAll(
Theme.of(context).colorScheme.primaryContainer),
elevation: const MaterialStatePropertyAll(0),
),
onPressed: _login,
@@ -83,19 +82,17 @@ class _LoginPageState extends State<LoginPage> {
);
}
void _login() async {
void _login() {
FocusScope.of(context).unfocus();
if (_formKey.currentState?.saveAndValidate() ?? false) {
setState(() => _isLoginLoading = true);
final form = _formKey.currentState?.value;
getIt<AuthenticationCubit>()
BlocProvider.of<AuthenticationCubit>(context)
.login(
credentials: form?[UserCredentialsFormField.fkCredentials],
serverUrl: form?[ServerAddressFormField.fkServerAddress],
clientCertificate: form?[ClientCertificateFormField.fkClientCertificate],
) //TODO: Move Intro slider route push here!
.onError<ErrorMessage>(
(error, _) => showError(context, error),
clientCertificate:
form?[ClientCertificateFormField.fkClientCertificate],
)
.whenComplete(() => setState(() => _isLoginLoading = false));
}

View File

@@ -13,10 +13,12 @@ class ClientCertificateFormField extends StatefulWidget {
const ClientCertificateFormField({Key? key}) : super(key: key);
@override
State<ClientCertificateFormField> createState() => _ClientCertificateFormFieldState();
State<ClientCertificateFormField> createState() =>
_ClientCertificateFormFieldState();
}
class _ClientCertificateFormFieldState extends State<ClientCertificateFormField> {
class _ClientCertificateFormFieldState
extends State<ClientCertificateFormField> {
File? _selectedFile;
@override
Widget build(BuildContext context) {
@@ -28,14 +30,17 @@ class _ClientCertificateFormFieldState extends State<ClientCertificateFormField>
}
assert(_selectedFile != null);
if (_selectedFile?.path.split(".").last != 'pfx') {
return S.of(context).loginPageClientCertificateSettingInvalidFileFormatValidationText;
return S
.of(context)
.loginPageClientCertificateSettingInvalidFileFormatValidationText;
}
return null;
},
builder: (field) {
return ExpansionTile(
title: Text(S.of(context).loginPageClientCertificateSettingLabel),
subtitle: Text(S.of(context).loginPageClientCertificateSettingDescriptionText),
subtitle: Text(
S.of(context).loginPageClientCertificateSettingDescriptionText),
children: [
InputDecorator(
decoration: InputDecoration(
@@ -69,7 +74,9 @@ class _ClientCertificateFormFieldState extends State<ClientCertificateFormField>
onChanged: (value) => field.didChange(
field.value?.copyWith(passphrase: value),
),
label: S.of(context).loginPageClientCertificatePassphraseLabel,
label: S
.of(context)
.loginPageClientCertificatePassphraseLabel,
).padded(),
] else
...[]
@@ -90,7 +97,8 @@ class _ClientCertificateFormFieldState extends State<ClientCertificateFormField>
setState(() {
_selectedFile = file;
});
final changedValue = field.value?.copyWith(bytes: file.readAsBytesSync()) ??
final changedValue =
field.value?.copyWith(bytes: file.readAsBytesSync()) ??
ClientCertificate(bytes: file.readAsBytesSync());
field.didChange(changedValue);
}

View File

@@ -15,10 +15,12 @@ class ObscuredInputTextFormField extends StatefulWidget {
});
@override
State<ObscuredInputTextFormField> createState() => _ObscuredInputTextFormFieldState();
State<ObscuredInputTextFormField> createState() =>
_ObscuredInputTextFormFieldState();
}
class _ObscuredInputTextFormFieldState extends State<ObscuredInputTextFormField> {
class _ObscuredInputTextFormFieldState
extends State<ObscuredInputTextFormField> {
bool _showPassword = false;
final FocusNode _passwordFocusNode = FocusNode();

View File

@@ -62,7 +62,8 @@ class _ServerAddressFormFieldState extends State<ServerAddressFormField> {
}
//https://stackoverflow.com/questions/49648022/check-whether-there-is-an-internet-connection-available-on-flutter-app
setState(() => _reachabilityStatus = ReachabilityStatus.testing);
final isReachable = await getIt<ConnectivityStatusService>().isServerReachable(address);
final isReachable =
await getIt<ConnectivityStatusService>().isServerReachable(address);
if (isReachable) {
setState(() => _reachabilityStatus = ReachabilityStatus.reachable);
} else {

View File

@@ -1,17 +1,18 @@
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:form_builder_validators/form_builder_validators.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/login/model/user_credentials.model.dart';
import 'package:paperless_mobile/features/login/view/widgets/password_text_field.dart';
import 'package:paperless_mobile/generated/l10n.dart';
import 'package:form_builder_validators/form_builder_validators.dart';
class UserCredentialsFormField extends StatefulWidget {
static const fkCredentials = 'credentials';
const UserCredentialsFormField({Key? key}) : super(key: key);
@override
State<UserCredentialsFormField> createState() => _UserCredentialsFormFieldState();
State<UserCredentialsFormField> createState() =>
_UserCredentialsFormFieldState();
}
class _UserCredentialsFormFieldState extends State<UserCredentialsFormField> {
@@ -28,7 +29,8 @@ class _UserCredentialsFormFieldState extends State<UserCredentialsFormField> {
// USERNAME
autocorrect: false,
onChanged: (username) => field.didChange(
field.value?.copyWith(username: username) ?? UserCredentials(username: username),
field.value?.copyWith(username: username) ??
UserCredentials(username: username),
),
validator: FormBuilderValidators.required(
errorText: S.of(context).loginPageUsernameValidatorMessageText,
@@ -41,7 +43,8 @@ class _UserCredentialsFormFieldState extends State<UserCredentialsFormField> {
ObscuredInputTextFormField(
label: S.of(context).loginPagePasswordFieldLabel,
onChanged: (password) => field.didChange(
field.value?.copyWith(password: password) ?? UserCredentials(password: password),
field.value?.copyWith(password: password) ??
UserCredentials(password: password),
),
validator: FormBuilderValidators.required(
errorText: S.of(context).loginPagePasswordValidatorMessageText,

View File

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:paperless_mobile/core/type/types.dart';
import 'package:paperless_mobile/di_initializer.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/documents/model/document.model.dart';
@@ -44,7 +45,7 @@ class _DocumentUploadPageState extends State<DocumentUploadPage> {
static final fileNameDateFormat = DateFormat("yyyy_MM_ddTHH_mm_ss");
final GlobalKey<FormBuilderState> _formKey = GlobalKey();
Map<String, String> _errors = {};
PaperlessValidationErrors _errors = {};
bool _isUploadLoading = false;
@override
@@ -61,7 +62,8 @@ class _DocumentUploadPageState extends State<DocumentUploadPage> {
title: Text(S.of(context).documentsUploadPageTitle),
bottom: _isUploadLoading
? const PreferredSize(
child: LinearProgressIndicator(), preferredSize: Size.fromHeight(4.0))
child: LinearProgressIndicator(),
preferredSize: Size.fromHeight(4.0))
: null,
),
floatingActionButton: Visibility(
@@ -86,14 +88,17 @@ class _DocumentUploadPageState extends State<DocumentUploadPage> {
suffixIcon: IconButton(
icon: const Icon(Icons.close),
onPressed: () {
_formKey.currentState?.fields[DocumentModel.titleKey]?.didChange("");
_formKey.currentState?.fields[fkFileName]?.didChange(".pdf");
_formKey.currentState?.fields[DocumentModel.titleKey]
?.didChange("");
_formKey.currentState?.fields[fkFileName]
?.didChange(".pdf");
},
),
errorText: _errors[DocumentModel.titleKey],
),
onChanged: (value) {
final String? transformedValue = value?.replaceAll(RegExp(r"[\W_]"), "_");
final String? transformedValue =
value?.replaceAll(RegExp(r"[\W_]"), "_");
_formKey.currentState?.fields[fkFileName]
?.didChange("${transformedValue ?? ''}.pdf");
},
@@ -106,7 +111,8 @@ class _DocumentUploadPageState extends State<DocumentUploadPage> {
decoration: InputDecoration(
labelText: S.of(context).documentUploadFileNameLabel,
),
initialValue: "scan_${fileNameDateFormat.format(DateTime.now())}.pdf",
initialValue:
"scan_${fileNameDateFormat.format(DateTime.now())}.pdf",
),
FormBuilderDateTimePicker(
autovalidateMode: AutovalidateMode.always,
@@ -125,7 +131,8 @@ class _DocumentUploadPageState extends State<DocumentUploadPage> {
return LabelFormField<DocumentType, DocumentTypeQuery>(
notAssignedSelectable: false,
formBuilderState: _formKey.currentState,
labelCreationWidgetBuilder: (initialValue) => BlocProvider.value(
labelCreationWidgetBuilder: (initialValue) =>
BlocProvider.value(
value: BlocProvider.of<DocumentTypeCubit>(context),
child: AddDocumentTypePage(initialName: initialValue),
),
@@ -133,7 +140,8 @@ class _DocumentUploadPageState extends State<DocumentUploadPage> {
name: DocumentModel.documentTypeKey,
state: state,
queryParameterIdBuilder: DocumentTypeQuery.fromId,
queryParameterNotAssignedBuilder: DocumentTypeQuery.notAssigned,
queryParameterNotAssignedBuilder:
DocumentTypeQuery.notAssigned,
prefixIcon: const Icon(Icons.description_outlined),
);
},
@@ -144,15 +152,18 @@ class _DocumentUploadPageState extends State<DocumentUploadPage> {
return LabelFormField<Correspondent, CorrespondentQuery>(
notAssignedSelectable: false,
formBuilderState: _formKey.currentState,
labelCreationWidgetBuilder: (initialValue) => BlocProvider.value(
labelCreationWidgetBuilder: (initialValue) =>
BlocProvider.value(
value: BlocProvider.of<CorrespondentCubit>(context),
child: AddCorrespondentPage(initalValue: initialValue),
),
label: S.of(context).documentCorrespondentPropertyLabel + " *",
label:
S.of(context).documentCorrespondentPropertyLabel + " *",
name: DocumentModel.correspondentKey,
state: state,
queryParameterIdBuilder: CorrespondentQuery.fromId,
queryParameterNotAssignedBuilder: CorrespondentQuery.notAssigned,
queryParameterNotAssignedBuilder:
CorrespondentQuery.notAssigned,
prefixIcon: const Icon(Icons.person_outline),
);
},
@@ -188,19 +199,27 @@ class _DocumentUploadPageState extends State<DocumentUploadPage> {
onPressed: () {
getIt<DocumentsCubit>().reloadDocuments();
},
label: S.of(context).documentUploadProcessingSuccessfulReloadActionText,
label: S
.of(context)
.documentUploadProcessingSuccessfulReloadActionText,
),
content: Text(S.of(context).documentUploadProcessingSuccessfulText),
content:
Text(S.of(context).documentUploadProcessingSuccessfulText),
),
);
},
title: _formKey.currentState?.value[DocumentModel.titleKey],
documentType:
(_formKey.currentState?.value[DocumentModel.documentTypeKey] as IdQueryParameter).id,
correspondent:
(_formKey.currentState?.value[DocumentModel.correspondentKey] as IdQueryParameter).id,
tags: (_formKey.currentState?.value[DocumentModel.tagsKey] as TagsQuery).ids,
createdAt: (_formKey.currentState?.value[DocumentModel.createdKey] as DateTime?),
documentType: (_formKey.currentState
?.value[DocumentModel.documentTypeKey] as IdQueryParameter)
.id,
correspondent: (_formKey.currentState
?.value[DocumentModel.correspondentKey] as IdQueryParameter)
.id,
tags:
(_formKey.currentState?.value[DocumentModel.tagsKey] as TagsQuery)
.ids,
createdAt: (_formKey.currentState?.value[DocumentModel.createdKey]
as DateTime?),
);
setState(() {
_isUploadLoading = false;
@@ -210,7 +229,7 @@ class _DocumentUploadPageState extends State<DocumentUploadPage> {
showSnackBar(context, S.of(context).documentUploadSuccessText);
} on ErrorMessage catch (error) {
showError(context, error);
} on Map<String, String> catch (errorMessages) {
} on PaperlessValidationErrors catch (errorMessages) {
setState(() => _errors = errorMessages);
} catch (other) {
showSnackBar(context, other.toString());

View File

@@ -23,13 +23,15 @@ class ScannerPage extends StatefulWidget {
State<ScannerPage> createState() => _ScannerPageState();
}
class _ScannerPageState extends State<ScannerPage> with SingleTickerProviderStateMixin {
class _ScannerPageState extends State<ScannerPage>
with SingleTickerProviderStateMixin {
late final AnimationController _fabPulsingController;
late final Animation _animation;
@override
void initState() {
super.initState();
_fabPulsingController = AnimationController(vsync: this, duration: const Duration(seconds: 1))
_fabPulsingController =
AnimationController(vsync: this, duration: const Duration(seconds: 1))
..repeat(reverse: true);
_animation = Tween(begin: 1.0, end: 1.2).animate(_fabPulsingController)
..addListener(() {
@@ -113,7 +115,8 @@ class _ScannerPageState extends State<ScannerPage> with SingleTickerProviderStat
final img = pw.MemoryImage(element.readAsBytesSync());
doc.addPage(
pw.Page(
pageFormat: PdfPageFormat(img.width!.toDouble(), img.height!.toDouble()),
pageFormat:
PdfPageFormat(img.width!.toDouble(), img.height!.toDouble()),
build: (context) => pw.Image(img),
),
);
@@ -164,7 +167,8 @@ class _ScannerPageState extends State<ScannerPage> with SingleTickerProviderStat
itemBuilder: (context, index) {
return GridImageItemWidget(
file: scans[index],
onDelete: () => BlocProvider.of<DocumentScannerCubit>(context).removeScan(index),
onDelete: () => BlocProvider.of<DocumentScannerCubit>(context)
.removeScan(index),
index: index,
totalNumberOfFiles: scans.length,
);

View File

@@ -25,7 +25,8 @@ class _ScannerWidgetState extends State<ScannerWidget> {
appBar: AppBar(title: const Text("Scan document")),
body: FutureBuilder<PermissionStatus>(
future: Permission.camera.request(),
builder: (BuildContext context, AsyncSnapshot<PermissionStatus> snapshot) {
builder:
(BuildContext context, AsyncSnapshot<PermissionStatus> snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}

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