Merge pull request #346 from astubenbord/feature/notes

Feature: Notes and bugfixes
This commit is contained in:
Anton Stubenbord
2024-01-06 20:54:18 +01:00
committed by GitHub
80 changed files with 3732 additions and 726 deletions

View File

@@ -4,7 +4,7 @@
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:requestLegacyExternalStorage="true"
>
>
<activity
android:name=".MainActivity"
android:exported="true"
@@ -135,6 +135,7 @@
android:mimeType="application/vnd.openxmlformats-officedocument.presentationml.presentation" />
</intent-filter>
<!-- .xls -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
@@ -162,11 +163,22 @@
</intent-filter>
<!-- END Snippet from https://github.com/qcasey/paperless_share -->
</activity>
<!-- Don't delete the meta-data below. This is used by the Flutter tool to generate
GeneratedPluginRegistrant.java -->
<meta-data android:name="flutterEmbedding" android:value="2" />
</application>
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
</intent>
</queries>
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"

View File

@@ -0,0 +1,3 @@
- Neues Feature: Notizen
- Neue Sprache: Italienisch
- Mehere Fehlerbehebungen

View File

@@ -0,0 +1,3 @@
- New feature: Notes
- New language: Italian
- Multiple bugfixes

8
build.yaml Normal file
View File

@@ -0,0 +1,8 @@
targets:
$default:
builders:
mockito|mockBuilder:
generate_for:
- lib/**.dart
- test/**.dart
- integration_test/**.dart

View File

@@ -1,224 +1,130 @@
// import 'package:flutter/material.dart';
// import 'package:flutter_test/flutter_test.dart';
// import 'package:mockito/mockito.dart';
// import 'package:paperless_api/paperless_api.dart';
// import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart';
// import 'package:paperless_mobile/core/service/connectivity_status.service.dart';
// import 'package:paperless_mobile/core/store/local_vault.dart';
// import 'package:paperless_mobile/di_test_mocks.mocks.dart';
// import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart';
// import 'package:paperless_mobile/features/settings/model/application_settings_state.dart';
// import 'package:paperless_mobile/features/settings/model/view_type.dart';
import 'dart:io';
// import 'src/framework.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hive/hive.dart';
import 'package:integration_test/integration_test.dart';
import 'package:mockito/mockito.dart';
import 'package:paperless_mobile/core/database/hive/hive_config.dart';
import 'package:paperless_mobile/core/database/hive/hive_extensions.dart';
import 'package:paperless_mobile/core/database/hive/hive_initialization.dart';
import 'package:paperless_mobile/core/database/tables/global_settings.dart';
import 'package:paperless_mobile/core/security/session_manager.dart';
import 'package:paperless_mobile/core/service/connectivity_status_service.dart';
import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart';
import 'package:paperless_mobile/features/login/services/authentication_service.dart';
import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart';
import 'package:paperless_mobile/keys.dart';
import 'package:paperless_mobile/main.dart'
show initializeDefaultParameters, AppEntrypoint;
import 'package:path_provider/path_provider.dart';
// void main() async {
// final t = await initializeTestingFramework(languageCode: 'de');
import 'src/mocks/mock_paperless_api.dart';
// const testServerUrl = 'https://example.com';
// const testUsername = 'user';
// const testPassword = 'pass';
class MockConnectivityStatusService extends Mock
implements ConnectivityStatusService {}
// final serverAddressField = find.byKey(const ValueKey('login-server-address'));
// final usernameField = find.byKey(const ValueKey('login-username'));
// final passwordField = find.byKey(const ValueKey('login-password'));
// final loginBtn = find.byKey(const ValueKey('login-login-button'));
class MockLocalAuthService extends Mock implements LocalAuthenticationService {}
// testWidgets('Test successful login flow', (WidgetTester tester) async {
// await initAndLaunchTestApp(tester, () async {
// // Initialize dat for mocked classes
// when((getIt<ConnectivityStatusService>()).connectivityChanges())
// .thenAnswer((i) => Stream.value(true));
// when((getIt<LocalVault>() as MockLocalVault)
// .loadAuthenticationInformation())
// .thenAnswer((realInvocation) async => null);
// when((getIt<LocalVault>() as MockLocalVault).loadApplicationSettings())
// .thenAnswer((realInvocation) async => ApplicationSettingsState(
// preferredLocaleSubtag: 'en',
// preferredThemeMode: ThemeMode.light,
// isLocalAuthenticationEnabled: false,
// preferredViewType: ViewType.list,
// showInboxOnStartup: false,
// ));
// when(getIt<PaperlessAuthenticationApi>().login(
// username: testUsername,
// password: testPassword,
// )).thenAnswer((i) => Future.value("eyTestToken"));
class MockSessionManager extends Mock implements SessionManager {}
// await getIt<ConnectivityCubit>().initialize();
// await getIt<ApplicationSettingsCubit>().initialize();
// });
class MockLocalNotificationService extends Mock
implements LocalNotificationService {}
// // Mocked classes
void main() async {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
const locale = Locale("en", "US");
const testServerUrl = 'https://example.com';
const testUsername = 'user';
const testPassword = 'pass';
// await t.binding.waitUntilFirstFrameRasterized;
// await tester.pumpAndSettle();
final hiveDirectory = await getTemporaryDirectory();
// await tester.enterText(serverAddressField, testServerUrl);
// await tester.pumpAndSettle();
late ConnectivityStatusService connectivityStatusService;
late MockPaperlessApiFactory paperlessApiFactory;
late AuthenticationCubit authenticationCubit;
late LocalNotificationService localNotificationService;
late SessionManager sessionManager;
final localAuthService = MockLocalAuthService();
// await tester.enterText(usernameField, testUsername);
// await tester.pumpAndSettle();
setUp(() async {
connectivityStatusService = MockConnectivityStatusService();
paperlessApiFactory = MockPaperlessApiFactory();
sessionManager = MockSessionManager();
localNotificationService = MockLocalNotificationService();
// await tester.enterText(passwordField, testPassword);
authenticationCubit = AuthenticationCubit(
localAuthService,
paperlessApiFactory,
sessionManager,
connectivityStatusService,
localNotificationService,
);
await initHive(
hiveDirectory,
locale.toString(),
);
});
testWidgets(
'A user shall be successfully logged in when providing correct credentials.',
(tester) async {
// Reset data to initial state with given [locale].
await Hive.globalSettingsBox.setValue(
GlobalSettings(
preferredLocaleSubtag: locale.toString(),
loggedInUserId: null,
),
);
when(paperlessApiFactory.authenticationApi.login(
username: testUsername,
password: testPassword,
)).thenAnswer((_) async => "token");
// FocusManager.instance.primaryFocus?.unfocus();
// await tester.pumpAndSettle();
await initializeDefaultParameters();
// await tester.tap(loginBtn);
await tester.pumpWidget(
AppEntrypoint(
apiFactory: paperlessApiFactory,
authenticationCubit: authenticationCubit,
connectivityStatusService: connectivityStatusService,
localNotificationService: localNotificationService,
localAuthService: localAuthService,
sessionManager: sessionManager,
),
);
await tester.binding.waitUntilFirstFrameRasterized;
await tester.pumpAndSettle();
// verify(getIt<PaperlessAuthenticationApi>().login(
// username: testUsername,
// password: testPassword,
// )).called(1);
// });
await tester.enterText(
find.byKey(TestKeys.login.serverAddressFormField),
testServerUrl,
);
await tester.pumpAndSettle();
// testWidgets('Test login validation missing password',
// (WidgetTester tester) async {
// await initAndLaunchTestApp(tester, () async {
// when((getIt<ConnectivityStatusService>() as MockConnectivityStatusService)
// .connectivityChanges())
// .thenAnswer((i) => Stream.value(true));
// when((getIt<LocalVault>() as MockLocalVault)
// .loadAuthenticationInformation())
// .thenAnswer((realInvocation) async => null);
await tester.press(find.byKey(TestKeys.login.continueButton));
// when((getIt<LocalVault>() as MockLocalVault).loadApplicationSettings())
// .thenAnswer((realInvocation) async => ApplicationSettingsState(
// preferredLocaleSubtag: 'en',
// preferredThemeMode: ThemeMode.light,
// isLocalAuthenticationEnabled: false,
// preferredViewType: ViewType.list,
// showInboxOnStartup: false,
// ));
await tester.pumpAndSettle();
expect(
find.byKey(TestKeys.login.usernameFormField),
findsOneWidget,
);
// await getIt<ConnectivityCubit>().initialize();
// await getIt<ApplicationSettingsCubit>().initialize();
// });
// // Mocked classes
await tester.enterText(
find.byKey(TestKeys.login.usernameFormField),
testUsername,
);
await tester.enterText(
find.byKey(TestKeys.login.passwordFormField),
testUsername,
);
await tester.pumpAndSettle();
// // Initialize dat for mocked classes
await tester.press(find.byKey(TestKeys.login.loginButton));
await tester.pumpAndSettle();
// await t.binding.waitUntilFirstFrameRasterized;
// await tester.pumpAndSettle();
// await tester.enterText(serverAddressField, testServerUrl);
// await tester.pumpAndSettle();
// await tester.enterText(usernameField, testUsername);
// await tester.pumpAndSettle();
// FocusManager.instance.primaryFocus?.unfocus();
// await tester.pumpAndSettle();
// await tester.tap(loginBtn);
// await tester.pumpAndSettle();
// verifyNever(
// (getIt<PaperlessAuthenticationApi>() as MockPaperlessAuthenticationApi)
// .login(
// username: testUsername,
// password: testPassword,
// ));
// expect(
// find.textContaining(t.translations.passwordMustNotBeEmpty),
// findsOneWidget,
// );
// });
// testWidgets('Test login validation missing username',
// (WidgetTester tester) async {
// await initAndLaunchTestApp(tester, () async {
// when((getIt<ConnectivityStatusService>() as MockConnectivityStatusService)
// .connectivityChanges())
// .thenAnswer((i) => Stream.value(true));
// when((getIt<LocalVault>() as MockLocalVault)
// .loadAuthenticationInformation())
// .thenAnswer((realInvocation) async => null);
// when((getIt<LocalVault>() as MockLocalVault).loadApplicationSettings())
// .thenAnswer((realInvocation) async => ApplicationSettingsState(
// preferredLocaleSubtag: 'en',
// preferredThemeMode: ThemeMode.light,
// isLocalAuthenticationEnabled: false,
// preferredViewType: ViewType.list,
// showInboxOnStartup: false,
// ));
// await getIt<ConnectivityCubit>().initialize();
// await getIt<ApplicationSettingsCubit>().initialize();
// });
// await t.binding.waitUntilFirstFrameRasterized;
// await tester.pumpAndSettle();
// await tester.enterText(serverAddressField, testServerUrl);
// await tester.pumpAndSettle();
// await tester.enterText(passwordField, testPassword);
// await tester.pumpAndSettle();
// FocusManager.instance.primaryFocus?.unfocus();
// await tester.pumpAndSettle();
// await tester.tap(loginBtn);
// await tester.pumpAndSettle();
// verifyNever(
// (getIt<PaperlessAuthenticationApi>() as MockPaperlessAuthenticationApi)
// .login(
// username: testUsername,
// password: testPassword,
// ));
// expect(
// find.textContaining(t.translations.usernameMustNotBeEmpty),
// findsOneWidget,
// );
// });
// testWidgets('Test login validation missing server address',
// (WidgetTester tester) async {
// initAndLaunchTestApp(tester, () async {
// when((getIt<ConnectivityStatusService>()).connectivityChanges())
// .thenAnswer((i) => Stream.value(true));
// when((getIt<LocalVault>()).loadAuthenticationInformation())
// .thenAnswer((realInvocation) async => null);
// when((getIt<LocalVault>()).loadApplicationSettings())
// .thenAnswer((realInvocation) async => ApplicationSettingsState(
// preferredLocaleSubtag: 'en',
// preferredThemeMode: ThemeMode.light,
// isLocalAuthenticationEnabled: false,
// preferredViewType: ViewType.list,
// showInboxOnStartup: false,
// ));
// await getIt<ConnectivityCubit>().initialize();
// await getIt<ApplicationSettingsCubit>().initialize();
// });
// await t.binding.waitUntilFirstFrameRasterized;
// await tester.pumpAndSettle();
// await tester.enterText(usernameField, testUsername);
// await tester.pumpAndSettle();
// await tester.enterText(passwordField, testPassword);
// await tester.pumpAndSettle();
// FocusManager.instance.primaryFocus?.unfocus();
// await tester.pumpAndSettle();
// await tester.tap(loginBtn);
// await tester.pumpAndSettle();
// verifyNever(getIt<PaperlessAuthenticationApi>().login(
// username: testUsername,
// password: testPassword,
// ));
// expect(
// find.textContaining(
// t.translations.loginPageServerUrlValidatorMessageText),
// findsOneWidget,
// );
// });
// }
expect(
find.byKey(TestKeys.login.loggingInScreen),
findsOneWidget,
);
});
}

View File

@@ -1,7 +1,16 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:paperless_mobile/core/factory/paperless_api_factory.dart';
import 'package:paperless_mobile/core/security/session_manager.dart';
import 'package:paperless_mobile/core/service/connectivity_status_service.dart';
import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart';
import 'package:paperless_mobile/features/login/services/authentication_service.dart';
import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/main.dart'
show initializeDefaultParameters, AppEntrypoint;
import 'package:path_provider/path_provider.dart';
Future<TestingFrameworkVariables> initializeTestingFramework(
{String languageCode = 'en'}) async {
@@ -26,11 +35,3 @@ class TestingFrameworkVariables {
required this.translations,
});
}
Future<void> initAndLaunchTestApp(
WidgetTester tester,
Future<void> Function() initializationCallback,
) async {
await initializationCallback();
//runApp(const PaperlessMobileEntrypoint(authenticationCubit: ),));
}

View File

@@ -0,0 +1,65 @@
import 'package:dio/src/dio.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/factory/paperless_api_factory.dart';
import 'package:mockito/annotations.dart';
@GenerateNiceMocks([
MockSpec<PaperlessAuthenticationApi>(),
MockSpec<PaperlessDocumentsApi>(),
MockSpec<PaperlessLabelsApi>(),
MockSpec<PaperlessUserApi>(),
MockSpec<PaperlessServerStatsApi>(),
MockSpec<PaperlessSavedViewsApi>(),
MockSpec<PaperlessTasksApi>(),
])
import 'mock_paperless_api.mocks.dart';
class MockPaperlessApiFactory implements PaperlessApiFactory {
final PaperlessAuthenticationApi authenticationApi =
MockPaperlessAuthenticationApi();
final PaperlessDocumentsApi documentApi = MockPaperlessDocumentsApi();
final PaperlessLabelsApi labelsApi = MockPaperlessLabelsApi();
final PaperlessUserApi userApi = MockPaperlessUserApi();
final PaperlessSavedViewsApi savedViewsApi = MockPaperlessSavedViewsApi();
final PaperlessServerStatsApi serverStatsApi = MockPaperlessServerStatsApi();
final PaperlessTasksApi tasksApi = MockPaperlessTasksApi();
@override
PaperlessAuthenticationApi createAuthenticationApi(Dio dio) {
return authenticationApi;
}
@override
PaperlessDocumentsApi createDocumentsApi(Dio dio, {required int apiVersion}) {
return documentApi;
}
@override
PaperlessLabelsApi createLabelsApi(Dio dio, {required int apiVersion}) {
return labelsApi;
}
@override
PaperlessSavedViewsApi createSavedViewsApi(
Dio dio, {
required int apiVersion,
}) {
return savedViewsApi;
}
@override
PaperlessServerStatsApi createServerStatsApi(Dio dio,
{required int apiVersion}) {
return serverStatsApi;
}
@override
PaperlessTasksApi createTasksApi(Dio dio, {required int apiVersion}) {
return tasksApi;
}
@override
PaperlessUserApi createUserApi(Dio dio, {required int apiVersion}) {
return userApi;
}
}

View File

@@ -5,3 +5,5 @@ import 'package:package_info_plus/package_info_plus.dart';
late final PackageInfo packageInfo;
late final AndroidDeviceInfo? androidInfo;
late final IosDeviceInfo? iosInfo;
const latestSupportedApiVersion = 3;

View File

@@ -0,0 +1,25 @@
import 'package:paperless_mobile/core/bloc/loading_status.dart';
class BaseState<T> {
final Object? error;
final T? value;
final LoadingStatus status;
BaseState({
required this.error,
required this.value,
required this.status,
});
BaseState<T> copyWith({
Object? error,
T? value,
LoadingStatus? status,
}) {
return BaseState(
error: error ?? this.error,
value: value ?? this.value,
status: status ?? this.status,
);
}
}

View File

@@ -19,12 +19,14 @@ class HiveBoxes {
static const localUserAccount = 'localUserAccount';
static const localUserAppState = 'localUserAppState';
static const hosts = 'hosts';
static const hintStateBox = 'hintStateBox';
static List<String> get all => [
globalSettings,
localUserCredentials,
localUserAccount,
localUserAppState,
hintStateBox,
hosts,
];
}

View File

@@ -54,4 +54,5 @@ extension HiveBoxAccessors on HiveInterface {
box<LocalUserAppState>(HiveBoxes.localUserAppState);
Box<GlobalSettings> get globalSettingsBox =>
box<GlobalSettings>(HiveBoxes.globalSettings);
Box<bool> get hintStateBox => box<bool>(HiveBoxes.hintStateBox);
}

View File

@@ -0,0 +1,25 @@
import 'dart:io';
import 'package:hive/hive.dart';
import 'package:hive_flutter/adapters.dart';
import 'package:paperless_mobile/core/database/hive/hive_config.dart';
import 'package:paperless_mobile/core/database/tables/global_settings.dart';
import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart';
Future<void> initHive(Directory directory, String defaultLocale) async {
Hive.init(directory.path);
registerHiveAdapters();
await Hive.openBox<LocalUserAccount>(HiveBoxes.localUserAccount);
await Hive.openBox<LocalUserAppState>(HiveBoxes.localUserAppState);
await Hive.openBox<bool>(HiveBoxes.hintStateBox);
await Hive.openBox<String>(HiveBoxes.hosts);
final globalSettingsBox =
await Hive.openBox<GlobalSettings>(HiveBoxes.globalSettings);
if (!globalSettingsBox.hasValue) {
await globalSettingsBox.setValue(
GlobalSettings(preferredLocaleSubtag: defaultLocale),
);
}
}

View File

@@ -11,9 +11,18 @@ extension WidgetPadding on Widget {
Widget paddedSymmetrically({
double horizontal = 0.0,
double vertical = 0.0,
bool sliver = false,
}) {
final insets =
EdgeInsets.symmetric(horizontal: horizontal, vertical: vertical);
if (sliver) {
return SliverPadding(
padding: insets,
sliver: this,
);
}
return Padding(
padding: EdgeInsets.symmetric(horizontal: horizontal, vertical: vertical),
padding: insets,
child: this,
);
}

View File

@@ -1,16 +1,16 @@
import 'package:dio/dio.dart';
class LanguageHeaderInterceptor extends Interceptor {
String preferredLocaleSubtag;
LanguageHeaderInterceptor(this.preferredLocaleSubtag);
final String Function() preferredLocaleSubtagBuilder;
LanguageHeaderInterceptor(this.preferredLocaleSubtagBuilder);
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
late String languages;
if (preferredLocaleSubtag == "en") {
if (preferredLocaleSubtagBuilder() == "en") {
languages = "en";
} else {
languages = "$preferredLocaleSubtag,en;q=0.7,en-US;q=0.6";
languages = "${preferredLocaleSubtagBuilder()},en;q=0.7,en-US;q=0.6";
}
options.headers.addAll({"Accept-Language": languages});
handler.next(options);

View File

@@ -9,4 +9,9 @@ class InfoMessageException implements Exception {
this.message,
this.stackTrace,
});
@override
String toString() {
return 'InfoMessageException(code: $code, message: $message, stackTrace: $stackTrace)';
}
}

View File

@@ -1,93 +1,14 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import 'package:flutter/material.dart';
import 'package:paperless_mobile/core/interceptor/dio_http_error_interceptor.dart';
import 'package:paperless_mobile/core/interceptor/dio_offline_interceptor.dart';
import 'package:paperless_mobile/core/interceptor/dio_unauthorized_interceptor.dart';
import 'package:paperless_mobile/core/interceptor/retry_on_connection_change_interceptor.dart';
import 'package:paperless_mobile/features/login/model/client_certificate.dart';
import 'package:pretty_dio_logger/pretty_dio_logger.dart';
/// Manages the security context, authentication and base request URL for
/// an underlying [Dio] client which is injected into all services
/// requiring authenticated access to the Paperless REST API.
class SessionManager extends ValueNotifier<Dio> {
Dio get client => value;
SessionManager([List<Interceptor> interceptors = const []])
: super(_initDio(interceptors));
static Dio _initDio(List<Interceptor> interceptors) {
//en- and decoded by utf8 by default
final Dio dio = Dio(
BaseOptions(
contentType: Headers.jsonContentType,
followRedirects: true,
maxRedirects: 10,
),
);
dio.options
..receiveTimeout = const Duration(seconds: 30)
..sendTimeout = const Duration(seconds: 60)
..responseType = ResponseType.json;
(dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient =
() => HttpClient()..badCertificateCallback = (cert, host, port) => true;
dio.interceptors.addAll([
...interceptors,
DioUnauthorizedInterceptor(),
DioHttpErrorInterceptor(),
DioOfflineInterceptor(),
RetryOnConnectionChangeInterceptor(dio: dio)
]);
return dio;
}
abstract interface class SessionManager implements ChangeNotifier {
Dio get client;
void updateSettings({
String? baseUrl,
String? authToken,
ClientCertificate? clientCertificate,
}) {
if (clientCertificate != null) {
final context = SecurityContext()
..usePrivateKeyBytes(
clientCertificate.bytes,
password: clientCertificate.passphrase,
)
..useCertificateChainBytes(
clientCertificate.bytes,
password: clientCertificate.passphrase,
)
..setTrustedCertificatesBytes(
clientCertificate.bytes,
password: clientCertificate.passphrase,
);
final adapter = IOHttpClientAdapter()
..createHttpClient = () => HttpClient(context: context)
..badCertificateCallback =
(X509Certificate cert, String host, int port) => true;
client.httpClientAdapter = adapter;
}
if (baseUrl != null) {
client.options.baseUrl = baseUrl;
}
if (authToken != null) {
client.options.headers.addAll({
HttpHeaders.authorizationHeader: 'Token $authToken',
});
}
notifyListeners();
}
void resetSettings() {
client.httpClientAdapter = IOHttpClientAdapter();
client.options.baseUrl = '';
client.options.headers.remove(HttpHeaders.authorizationHeader);
notifyListeners();
}
});
void resetSettings();
}

View File

@@ -0,0 +1,96 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import 'package:flutter/material.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/interceptor/dio_offline_interceptor.dart';
import 'package:paperless_mobile/core/interceptor/dio_unauthorized_interceptor.dart';
import 'package:paperless_mobile/core/interceptor/retry_on_connection_change_interceptor.dart';
import 'package:paperless_mobile/core/security/session_manager.dart';
import 'package:paperless_mobile/features/login/model/client_certificate.dart';
/// Manages the security context, authentication and base request URL for
/// an underlying [Dio] client which is injected into all services
/// requiring authenticated access to the Paperless REST API.
class SessionManagerImpl extends ValueNotifier<Dio> implements SessionManager {
@override
Dio get client => value;
SessionManagerImpl([List<Interceptor> interceptors = const []])
: super(_initDio(interceptors));
static Dio _initDio(List<Interceptor> interceptors) {
//en- and decoded by utf8 by default
final Dio dio = Dio(
BaseOptions(
contentType: Headers.jsonContentType,
followRedirects: true,
maxRedirects: 10,
),
);
dio.options
..receiveTimeout = const Duration(seconds: 30)
..sendTimeout = const Duration(seconds: 60)
..responseType = ResponseType.json;
(dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient =
() => HttpClient()..badCertificateCallback = (cert, host, port) => true;
dio.interceptors.addAll([
...interceptors,
DioUnauthorizedInterceptor(),
DioHttpErrorInterceptor(),
DioOfflineInterceptor(),
RetryOnConnectionChangeInterceptor(dio: dio)
]);
return dio;
}
@override
void updateSettings({
String? baseUrl,
String? authToken,
ClientCertificate? clientCertificate,
}) {
if (clientCertificate != null) {
final context = SecurityContext()
..usePrivateKeyBytes(
clientCertificate.bytes,
password: clientCertificate.passphrase,
)
..useCertificateChainBytes(
clientCertificate.bytes,
password: clientCertificate.passphrase,
)
..setTrustedCertificatesBytes(
clientCertificate.bytes,
password: clientCertificate.passphrase,
);
final adapter = IOHttpClientAdapter()
..createHttpClient = () => HttpClient(context: context)
..badCertificateCallback =
(X509Certificate cert, String host, int port) => true;
client.httpClientAdapter = adapter;
}
if (baseUrl != null) {
client.options.baseUrl = baseUrl;
}
if (authToken != null) {
client.options.headers.addAll({
HttpHeaders.authorizationHeader: 'Token $authToken',
});
}
notifyListeners();
}
@override
void resetSettings() {
client.httpClientAdapter = IOHttpClientAdapter();
client.options.baseUrl = '';
client.options.headers.remove(HttpHeaders.authorizationHeader);
notifyListeners();
}
}

View File

@@ -5,6 +5,7 @@ import 'package:dio/dio.dart';
import 'package:paperless_mobile/core/global/os_error_codes.dart';
import 'package:paperless_mobile/core/interceptor/server_reachability_error_interceptor.dart';
import 'package:paperless_mobile/core/security/session_manager.dart';
import 'package:paperless_mobile/core/security/session_manager_impl.dart';
import 'package:paperless_mobile/features/login/model/client_certificate.dart';
import 'package:paperless_mobile/features/login/model/reachability_status.dart';
import 'package:rxdart/subjects.dart';
@@ -79,7 +80,7 @@ class ConnectivityStatusServiceImpl implements ConnectivityStatusService {
}
try {
SessionManager manager =
SessionManager([ServerReachabilityErrorInterceptor()])
SessionManagerImpl([ServerReachabilityErrorInterceptor()])
..updateSettings(clientCertificate: clientCertificate)
..client.options.connectTimeout = const Duration(seconds: 5)
..client.options.receiveTimeout = const Duration(seconds: 5);

View File

@@ -82,5 +82,7 @@ String translateError(BuildContext context, ErrorCode code) {
'Could not load custom field.', //TODO: INTL
ErrorCode.customFieldDeleteFailed =>
'Could not delete custom field, please try again.', //TODO: INTL
ErrorCode.deleteNoteFailed => 'Could not delete note, please try again.',
ErrorCode.addNoteFailed => 'Could not create note, please try again.',
};
}

View File

@@ -3,9 +3,11 @@
import 'dart:collection';
import 'package:collection/collection.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:intl/intl.dart';
import 'package:paperless_mobile/core/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/landing/view/widgets/mime_types_pie_chart.dart';
@@ -83,7 +85,6 @@ class _FormBuilderLocalizedDatePickerState
final _textFieldControls =
LinkedList<_NeighbourAwareDateInputSegmentControls>();
String? _error;
bool _temporarilyDisableListeners = false;
@override
void initState() {
@@ -184,10 +185,7 @@ class _FormBuilderLocalizedDatePickerState
// Imitate the functionality of the validator function in "normal" form fields.
// The error is shown on the outer decorator as if this was a regular text input.
// Errors are cleared after the next user interaction.
final error = _validateDate(value);
setState(() {
_error = error;
});
// final error = _validateDate(value);
},
autovalidateMode: AutovalidateMode.onUserInteraction,
initialValue: widget.initialValue != null
@@ -201,7 +199,7 @@ class _FormBuilderLocalizedDatePickerState
child: InputDecorator(
textAlignVertical: TextAlignVertical.bottom,
decoration: InputDecoration(
errorText: _error,
errorText: field.errorText,
labelText: widget.labelText,
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
@@ -271,16 +269,10 @@ class _FormBuilderLocalizedDatePickerState
if (d.day != date.day && d.month != date.month && d.year != date.year) {
return "Invalid date.";
}
if (d.isBefore(widget.firstDate)) {
final formattedDateHint =
DateFormat.yMd(widget.locale.toString()).format(widget.firstDate);
return "Date must be after $formattedDateHint.";
}
if (d.isAfter(widget.lastDate)) {
final formattedDateHint =
DateFormat.yMd(widget.locale.toString()).format(widget.lastDate);
return "Date must be before $formattedDateHint.";
if (d.isBefore(widget.firstDate) || d.isAfter(widget.lastDate)) {
return S.of(context)!.dateOutOfRange(widget.firstDate, widget.lastDate);
}
return null;
}
@@ -332,6 +324,7 @@ class _FormBuilderLocalizedDatePickerState
_DateInputSegment.year => fieldValue.copyWith(year: number),
};
field.setValue(newValue);
field.validate();
}
},
inputFormatters: [

View File

@@ -61,7 +61,7 @@ class HintCard extends StatelessWidget {
const Padding(padding: EdgeInsets.only(bottom: 24)),
],
).padded(),
).padded(),
),
);
}
}

View File

@@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
import 'package:hive_flutter/adapters.dart';
import 'package:paperless_mobile/core/database/hive/hive_extensions.dart';
class HintStateBuilder extends StatelessWidget {
final String? listenKey;
final Widget Function(BuildContext context, Box<bool> box) builder;
const HintStateBuilder({
super.key,
required this.builder,
this.listenKey,
});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<Box<bool>>(
valueListenable: Hive.hintStateBox
.listenable(keys: listenKey != null ? [listenKey] : null),
builder: (context, box, child) {
return builder(context, box);
},
);
}
}

View File

@@ -63,6 +63,7 @@ class ChangelogDialog extends StatelessWidget {
}
const _versionNumbers = {
"4043": "3.2.0",
"4033": "3.1.8",
"4023": "3.1.7",
"4013": "3.1.6",

View File

@@ -87,6 +87,47 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
}
}
Future<void> updateNote(NoteModel note) async {
assert(state.status == LoadingStatus.loaded);
final document = state.document!;
final updatedNotes = document.notes.map((e) => e.id == note.id ? note : e);
try {
final updatedDocument = await _api.update(
state.document!.copyWith(
notes: updatedNotes,
),
);
_notifier.notifyUpdated(updatedDocument);
} on PaperlessApiException catch (e) {
addError(
TransientPaperlessApiError(
code: e.code,
details: e.details,
),
);
}
}
Future<void> deleteNote(NoteModel note) async {
assert(state.status == LoadingStatus.loaded,
"Document data has to be loaded before calling this method.");
assert(note.id != null, "Note id cannot be null.");
try {
final updatedDocument = await _api.deleteNote(
state.document!,
note.id!,
);
_notifier.notifyUpdated(updatedDocument);
} on PaperlessApiException catch (e) {
addError(
TransientPaperlessApiError(
code: e.code,
details: e.details,
),
);
}
}
Future<void> assignAsn(
DocumentModel document, {
int? asn,
@@ -270,4 +311,17 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
_notifier.removeListener(this);
await super.close();
}
Future<void> addNote(String text) async {
assert(state.status == LoadingStatus.loaded);
try {
final updatedDocument = await _api.addNote(
document: state.document!,
text: text,
);
_notifier.notifyUpdated(updatedDocument);
} on PaperlessApiException catch (err) {
addError(TransientPaperlessApiError(code: err.code));
}
}
}

View File

@@ -15,6 +15,7 @@ import 'package:paperless_mobile/features/document_details/cubit/document_detail
import 'package:paperless_mobile/features/document_details/view/widgets/document_content_widget.dart';
import 'package:paperless_mobile/features/document_details/view/widgets/document_download_button.dart';
import 'package:paperless_mobile/features/document_details/view/widgets/document_meta_data_widget.dart';
import 'package:paperless_mobile/features/document_details/view/widgets/document_notes_widget.dart';
import 'package:paperless_mobile/features/document_details/view/widgets/document_overview_widget.dart';
import 'package:paperless_mobile/features/document_details/view/widgets/document_permissions_widget.dart';
import 'package:paperless_mobile/features/document_details/view/widgets/document_share_button.dart';
@@ -67,7 +68,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
debugPrint(disableAnimations.toString());
final hasMultiUserSupport =
context.watch<LocalUserAccount>().hasMultiUserSupport;
final tabLength = 4 + (hasMultiUserSupport ? 1 : 0);
final tabLength = 5 + (hasMultiUserSupport ? 1 : 0);
return AnnotatedRegion(
value: buildOverlayStyle(
Theme.of(context),
@@ -160,6 +161,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
bottom: ColoredTabBar(
tabBar: TabBar(
isScrollable: true,
tabAlignment: TabAlignment.start,
tabs: [
Tab(
child: Text(
@@ -201,10 +203,34 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
),
),
),
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
S.of(context)!.notes(0),
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
),
),
if ((state.document?.notes.length ?? 0) >
0)
Card(
child: Text(state
.document!.notes.length
.toString())
.paddedSymmetrically(
horizontal: 8, vertical: 2),
),
],
),
),
if (hasMultiUserSupport)
Tab(
child: Text(
"Permissions",
S.of(context)!.permissions,
style: TextStyle(
color: Theme.of(context)
.colorScheme
@@ -229,67 +255,103 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
context.read(),
documentId: widget.id,
),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 16,
horizontal: 16,
),
child: TabBarView(
children: [
CustomScrollView(
slivers: [
SliverOverlapInjector(
handle: NestedScrollView
.sliverOverlapAbsorberHandleFor(context),
),
switch (state.status) {
LoadingStatus.loaded =>
DocumentOverviewWidget(
document: state.document!,
itemSpacing: _itemSpacing,
queryString:
widget.titleAndContentQueryString,
),
LoadingStatus.error => _buildErrorState(),
_ => _buildLoadingState(),
},
],
),
CustomScrollView(
slivers: [
SliverOverlapInjector(
handle: NestedScrollView
.sliverOverlapAbsorberHandleFor(context),
),
switch (state.status) {
LoadingStatus.loaded => DocumentContentWidget(
document: state.document!,
queryString:
widget.titleAndContentQueryString,
),
LoadingStatus.error => _buildErrorState(),
_ => _buildLoadingState(),
}
],
),
CustomScrollView(
slivers: [
SliverOverlapInjector(
handle: NestedScrollView
.sliverOverlapAbsorberHandleFor(context),
),
switch (state.status) {
LoadingStatus.loaded =>
DocumentMetaDataWidget(
document: state.document!,
itemSpacing: _itemSpacing,
metaData: state.metaData!,
),
LoadingStatus.error => _buildErrorState(),
_ => _buildLoadingState(),
},
],
),
child: TabBarView(
children: [
CustomScrollView(
slivers: [
SliverOverlapInjector(
handle: NestedScrollView
.sliverOverlapAbsorberHandleFor(context),
),
switch (state.status) {
LoadingStatus.loaded => DocumentOverviewWidget(
document: state.document!,
itemSpacing: _itemSpacing,
queryString:
widget.titleAndContentQueryString,
).paddedSymmetrically(
vertical: 16,
sliver: true,
),
LoadingStatus.error => _buildErrorState(),
_ => _buildLoadingState(),
},
],
),
CustomScrollView(
slivers: [
SliverOverlapInjector(
handle: NestedScrollView
.sliverOverlapAbsorberHandleFor(context),
),
switch (state.status) {
LoadingStatus.loaded => DocumentContentWidget(
document: state.document!,
queryString:
widget.titleAndContentQueryString,
).paddedSymmetrically(
vertical: 16,
sliver: true,
),
LoadingStatus.error => _buildErrorState(),
_ => _buildLoadingState(),
}
],
),
CustomScrollView(
slivers: [
SliverOverlapInjector(
handle: NestedScrollView
.sliverOverlapAbsorberHandleFor(context),
),
switch (state.status) {
LoadingStatus.loaded => DocumentMetaDataWidget(
document: state.document!,
itemSpacing: _itemSpacing,
metaData: state.metaData!,
).paddedSymmetrically(
vertical: 16,
sliver: true,
),
LoadingStatus.error => _buildErrorState(),
_ => _buildLoadingState(),
},
],
),
CustomScrollView(
controller: _pagingScrollController,
slivers: [
SliverOverlapInjector(
handle: NestedScrollView
.sliverOverlapAbsorberHandleFor(context),
),
SimilarDocumentsView(
pagingScrollController: _pagingScrollController,
).paddedSymmetrically(
vertical: 16,
sliver: true,
),
],
),
CustomScrollView(
slivers: [
SliverOverlapInjector(
handle: NestedScrollView
.sliverOverlapAbsorberHandleFor(context),
),
switch (state.status) {
LoadingStatus.loaded => DocumentNotesWidget(
document: state.document!,
).paddedSymmetrically(
vertical: 16,
sliver: true,
),
LoadingStatus.error => _buildErrorState(),
_ => _buildLoadingState(),
},
],
),
if (hasMultiUserSupport)
CustomScrollView(
controller: _pagingScrollController,
slivers: [
@@ -297,33 +359,27 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
handle: NestedScrollView
.sliverOverlapAbsorberHandleFor(context),
),
SimilarDocumentsView(
pagingScrollController:
_pagingScrollController,
),
switch (state.status) {
LoadingStatus.loaded =>
DocumentPermissionsWidget(
document: state.document!,
).paddedSymmetrically(
vertical: 16,
sliver: true,
),
LoadingStatus.error => _buildErrorState(),
_ => _buildLoadingState(),
}
],
),
if (hasMultiUserSupport)
CustomScrollView(
controller: _pagingScrollController,
slivers: [
SliverOverlapInjector(
handle: NestedScrollView
.sliverOverlapAbsorberHandleFor(
context),
),
switch (state.status) {
LoadingStatus.loaded =>
DocumentPermissionsWidget(
document: state.document!,
),
LoadingStatus.error => _buildErrorState(),
_ => _buildLoadingState(),
}
],
]
.map(
(child) => Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: child,
),
],
),
)
.toList(),
),
);
},

View File

@@ -25,54 +25,51 @@ class DocumentMetaDataWidget extends StatelessWidget {
Widget build(BuildContext context) {
final currentUser = context.watch<LocalUserAccount>().paperlessUser;
return SliverList(
delegate: SliverChildListDelegate(
[
if (currentUser.canEditDocuments)
ArchiveSerialNumberField(
document: document,
).paddedOnly(bottom: itemSpacing),
DetailsItem.text(
DateFormat.yMMMMd(Localizations.localeOf(context).toString())
.format(document.modified),
context: context,
label: S.of(context)!.modifiedAt,
return SliverList.list(
children: [
if (currentUser.canEditDocuments)
ArchiveSerialNumberField(
document: document,
).paddedOnly(bottom: itemSpacing),
DetailsItem.text(
DateFormat.yMMMMd(Localizations.localeOf(context).toString())
.format(document.modified),
context: context,
label: S.of(context)!.modifiedAt,
).paddedOnly(bottom: itemSpacing),
DetailsItem.text(
DateFormat.yMMMMd(Localizations.localeOf(context).toString())
.format(document.added),
context: context,
label: S.of(context)!.addedAt,
).paddedOnly(bottom: itemSpacing),
DetailsItem.text(
metaData.mediaFilename,
context: context,
label: S.of(context)!.mediaFilename,
).paddedOnly(bottom: itemSpacing),
if (document.originalFileName != null)
DetailsItem.text(
DateFormat.yMMMMd(Localizations.localeOf(context).toString())
.format(document.added),
context: context,
label: S.of(context)!.addedAt,
).paddedOnly(bottom: itemSpacing),
DetailsItem.text(
metaData.mediaFilename,
context: context,
label: S.of(context)!.mediaFilename,
).paddedOnly(bottom: itemSpacing),
if (document.originalFileName != null)
DetailsItem.text(
document.originalFileName!,
context: context,
label: S.of(context)!.originalMD5Checksum,
).paddedOnly(bottom: itemSpacing),
DetailsItem.text(
metaData.originalChecksum,
document.originalFileName!,
context: context,
label: S.of(context)!.originalMD5Checksum,
).paddedOnly(bottom: itemSpacing),
DetailsItem.text(
formatBytes(metaData.originalSize, 2),
context: context,
label: S.of(context)!.originalFileSize,
).paddedOnly(bottom: itemSpacing),
DetailsItem.text(
metaData.originalMimeType,
context: context,
label: S.of(context)!.originalMIMEType,
).paddedOnly(bottom: itemSpacing),
],
),
DetailsItem.text(
metaData.originalChecksum,
context: context,
label: S.of(context)!.originalMD5Checksum,
).paddedOnly(bottom: itemSpacing),
DetailsItem.text(
formatBytes(metaData.originalSize, 2),
context: context,
label: S.of(context)!.originalFileSize,
).paddedOnly(bottom: itemSpacing),
DetailsItem.text(
metaData.originalMimeType,
context: context,
label: S.of(context)!.originalMIMEType,
).paddedOnly(bottom: itemSpacing),
],
);
}
}

View File

@@ -0,0 +1,179 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:intl/intl.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/database/hive/hive_config.dart';
import 'package:paperless_mobile/core/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/core/widgets/hint_card.dart';
import 'package:paperless_mobile/core/widgets/hint_state_builder.dart';
import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.dart';
import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart';
import 'package:markdown/markdown.dart' show markdownToHtml;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:url_launcher/url_launcher_string.dart';
class DocumentNotesWidget extends StatefulWidget {
final DocumentModel document;
const DocumentNotesWidget({super.key, required this.document});
@override
State<DocumentNotesWidget> createState() => _DocumentNotesWidgetState();
}
class _DocumentNotesWidgetState extends State<DocumentNotesWidget> {
final _noteContentController = TextEditingController();
final _formKey = GlobalKey<FormState>();
bool _isNoteSubmitting = false;
@override
Widget build(BuildContext context) {
const hintKey = "hideMarkdownSyntaxHint";
return SliverMainAxisGroup(
slivers: [
SliverPadding(
padding: const EdgeInsets.only(bottom: 16),
sliver: SliverToBoxAdapter(
child: HintStateBuilder(
listenKey: hintKey,
builder: (context, box) {
return HintCard(
hintText: S.of(context)!.notesMarkdownSyntaxSupportHint,
show: !box.get(hintKey, defaultValue: false)!,
onHintAcknowledged: () {
box.put(hintKey, true);
},
);
},
),
),
),
SliverToBoxAdapter(
child: Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _noteContentController,
maxLines: null,
validator: (value) {
if (value?.trim().isEmpty ?? true) {
return S.of(context)!.thisFieldIsRequired;
}
return null;
},
textInputAction: TextInputAction.newline,
decoration: InputDecoration(
labelText: S.of(context)!.newNote,
suffixIcon: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_noteContentController.clear();
},
),
),
).paddedOnly(bottom: 8),
Align(
alignment: Alignment.centerRight,
child: ElevatedButton.icon(
icon: _isNoteSubmitting
? const SizedBox.square(
dimension: 20,
child: Center(
child: CircularProgressIndicator(
strokeWidth: 3,
),
),
)
: const Icon(Icons.note_add_outlined),
label: Text(S.of(context)!.addNote),
onPressed: () async {
_formKey.currentState?.save();
FocusScope.of(context).unfocus();
if (_formKey.currentState?.validate() ?? false) {
setState(() {
_isNoteSubmitting = true;
});
try {
await context
.read<DocumentDetailsCubit>()
.addNote(_noteContentController.text.trim());
_noteContentController.clear();
} catch (error) {
showGenericError(context, error);
} finally {
setState(() {
_isNoteSubmitting = false;
});
}
}
},
),
),
],
),
),
),
const SliverToBoxAdapter(
child: SizedBox(height: 16),
),
SliverList.separated(
separatorBuilder: (context, index) => const SizedBox(height: 16),
itemBuilder: (context, index) {
final note = widget.document.notes.elementAt(index);
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Html(
data: markdownToHtml(note.note!),
onLinkTap: (url, attributes, element) async {
if (url?.isEmpty ?? true) {
return;
}
if (await canLaunchUrlString(url!)) {
launchUrlString(url);
}
},
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (note.created != null)
Text(
DateFormat.yMMMd(
Localizations.localeOf(context).toString())
.addPattern('\u2014')
.add_jm()
.format(note.created!),
style:
Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(.5),
),
),
IconButton(
tooltip: S.of(context)!.delete,
icon: const Icon(Icons.delete),
onPressed: () {
context.read<DocumentDetailsCubit>().deleteNote(note);
},
),
],
),
],
).padded(16),
);
},
itemCount: widget.document.notes.length,
),
],
);
}
}

View File

@@ -424,7 +424,7 @@ class _DocumentEditPageState extends State<DocumentEditPage>
initialValue: initialCreatedAtDate,
labelText: S.of(context)!.createdAt,
firstDate: DateTime(1970, 1, 1),
lastDate: DateTime.now(),
lastDate: DateTime(2100, 1, 1),
locale: Localizations.localeOf(context),
prefixIcon: Icon(Icons.calendar_today),
),

View File

@@ -54,7 +54,9 @@ class DocumentScannerCubit extends Cubit<DocumentScannerState> {
Future<void> removeScan(File file) async {
try {
await file.delete();
if (await file.exists()) {
await file.delete();
}
} catch (error, stackTrace) {
throw InfoMessageException(
code: ErrorCode.scanRemoveFailed,

View File

@@ -14,6 +14,7 @@ import 'package:paperless_mobile/core/bloc/loading_status.dart';
import 'package:paperless_mobile/core/database/hive/hive_config.dart';
import 'package:paperless_mobile/core/database/tables/global_settings.dart';
import 'package:paperless_mobile/core/global/constants.dart';
import 'package:paperless_mobile/core/model/info_message_exception.dart';
import 'package:paperless_mobile/core/service/file_service.dart';
import 'package:paperless_mobile/features/app_drawer/view/app_drawer.dart';
import 'package:paperless_mobile/features/document_scan/cubit/document_scanner_cubit.dart';
@@ -326,6 +327,8 @@ class _ScannerPageState extends State<ScannerPage>
.removeScan(scans[index]);
} on PaperlessApiException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace);
} on InfoMessageException catch (error, stackTrace) {
showInfoMessage(context, error, stackTrace);
}
},
index: index,

View File

@@ -222,7 +222,7 @@ class _DocumentUploadPreparationPageState
FormBuilderLocalizedDatePicker(
name: DocumentModel.createdKey,
firstDate: DateTime(1970, 1, 1),
lastDate: DateTime.now(),
lastDate: DateTime(2100, 1, 1),
locale: Localizations.localeOf(context),
labelText: S.of(context)!.createdAt + " *",
allowUnset: true,

View File

@@ -21,6 +21,7 @@ import 'package:paperless_mobile/features/documents/view/widgets/selection/docum
import 'package:paperless_mobile/features/documents/view/widgets/selection/view_type_selection_widget.dart';
import 'package:paperless_mobile/features/documents/view/widgets/sort_documents_button.dart';
import 'package:paperless_mobile/features/labels/cubit/label_cubit.dart';
import 'package:paperless_mobile/features/logging/data/logger.dart';
import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart';
import 'package:paperless_mobile/features/tasks/model/pending_tasks_notifier.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
@@ -308,8 +309,18 @@ class _DocumentsPageState extends State<DocumentsPage> {
// Listen for scroll notifications to load new data.
// Scroll controller does not work here due to nestedscrollview limitations.
final offset = notification.metrics.pixels;
if (offset > 128 && _savedViewsExpansionController.isExpanded) {
_savedViewsExpansionController.collapse();
try {
if (offset > 128 && _savedViewsExpansionController.isExpanded) {
_savedViewsExpansionController.collapse();
}
// Workaround for https://github.com/astubenbord/paperless-mobile/issues/341 probably caused by https://github.com/flutter/flutter/issues/138153
} on TypeError catch (error) {
logger.fw(
"An exception was thrown, but this message can probably be ignored. See issue #341 for more details.",
error: error,
className: runtimeType.toString(),
methodName: "_buildDocumentsTab",
);
}
final max = notification.metrics.maxScrollExtent;

View File

@@ -4,6 +4,8 @@ import 'package:flutter/widgets.dart';
import 'package:hive_flutter/adapters.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/constants.dart';
import 'package:paperless_mobile/core/bloc/transient_error.dart';
import 'package:paperless_mobile/core/database/hive/hive_config.dart';
import 'package:paperless_mobile/core/database/hive/hive_extensions.dart';
import 'package:paperless_mobile/core/database/tables/global_settings.dart';
@@ -13,6 +15,7 @@ import 'package:paperless_mobile/core/database/tables/local_user_settings.dart';
import 'package:paperless_mobile/core/database/tables/user_credentials.dart';
import 'package:paperless_mobile/core/factory/paperless_api_factory.dart';
import 'package:paperless_mobile/core/interceptor/language_header.interceptor.dart';
import 'package:paperless_mobile/core/security/session_manager_impl.dart';
import 'package:paperless_mobile/features/logging/data/logger.dart';
import 'package:paperless_mobile/features/logging/utils/redaction_utils.dart';
import 'package:paperless_mobile/core/model/info_message_exception.dart';
@@ -83,7 +86,7 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
AuthenticatingStage.persistingLocalUserData));
},
);
} catch (e) {
} on PaperlessApiException catch (exception, stackTrace) {
emit(
AuthenticationErrorState(
serverUrl: serverUrl,
@@ -207,8 +210,8 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
methodName: 'switchAccount',
);
final sessionManager = SessionManager([
LanguageHeaderInterceptor(locale),
final SessionManager sessionManager = SessionManagerImpl([
LanguageHeaderInterceptor(() => locale),
]);
await _addUser(
localUserId,
@@ -462,14 +465,12 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
final authApi = _apiFactory.createAuthenticationApi(sessionManager.client);
await onPerformLogin?.call();
logger.fd(
"Fetching bearer token from the server...",
className: runtimeType.toString(),
methodName: '_addUser',
);
await onPerformLogin?.call();
final token = await authApi.login(
username: credentials.username!,
password: credentials.password!,
@@ -486,7 +487,6 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
clientCertificate: clientCert,
authToken: token,
);
final userAccountBox =
Hive.box<LocalUserAccount>(HiveBoxes.localUserAccount);
final userStateBox =
@@ -586,12 +586,14 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
clientCertificate: clientCert,
),
);
logger.fd(
"User credentials successfully saved.",
className: runtimeType.toString(),
methodName: '_addUser',
);
});
final hostsBox = Hive.box<String>(HiveBoxes.hosts);
if (!hostsBox.values.contains(serverUrl)) {
await hostsBox.add(serverUrl);
@@ -618,12 +620,19 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
try {
final response = await dio.get(
"/api/",
options: Options(
sendTimeout: timeout,
),
options: Options(sendTimeout: timeout),
);
final apiVersion =
int apiVersion =
int.parse(response.headers.value('x-api-version') ?? "3");
if (apiVersion > latestSupportedApiVersion) {
logger.fw(
"The server is running a newer API version ($apiVersion) than the app supports (v$latestSupportedApiVersion), falling back to latest supported version (v$latestSupportedApiVersion). "
"Warning: This might lead to unexpected behavior!",
className: runtimeType.toString(),
methodName: '_getApiVersion',
);
apiVersion = latestSupportedApiVersion;
}
logger.fd(
"Successfully retrieved API version ($apiVersion).",
className: runtimeType.toString(),

View File

@@ -1,6 +1,5 @@
import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
@@ -17,7 +16,6 @@ import 'package:paperless_mobile/features/login/model/client_certificate_form_mo
import 'package:paperless_mobile/features/login/model/login_form_credentials.dart';
import 'package:paperless_mobile/features/login/model/reachability_status.dart';
import 'package:paperless_mobile/features/login/view/widgets/form_fields/client_certificate_form_field.dart';
import 'package:paperless_mobile/features/login/view/widgets/form_fields/login_settings_page.dart';
import 'package:paperless_mobile/features/login/view/widgets/form_fields/server_address_form_field.dart';
import 'package:paperless_mobile/features/login/view/widgets/form_fields/user_credentials_form_field.dart';
import 'package:paperless_mobile/generated/assets.gen.dart';
@@ -44,6 +42,7 @@ class AddAccountPage extends StatefulWidget {
final bool showLocalAccounts;
final Widget? bottomLeftButton;
const AddAccountPage({
Key? key,
required this.onSubmit,

View File

@@ -5,6 +5,7 @@ import 'package:hive_flutter/adapters.dart';
import 'package:paperless_mobile/core/database/hive/hive_config.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/keys.dart';
class ServerAddressFormField extends StatefulWidget {
static const String fkServerAddress = "serverAddress";
@@ -59,7 +60,7 @@ class _ServerAddressFormFieldState extends State<ServerAddressFormField>
maxWidth: MediaQuery.sizeOf(context).width - 40,
);
},
key: const ValueKey('login-server-address'),
key: TestKeys.login.serverAddressFormField,
optionsBuilder: (textEditingValue) {
return Hive.box<String>(HiveBoxes.hosts)
.values

View File

@@ -1,3 +1,5 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/features/settings/view/widgets/app_logs_tile.dart';
@@ -15,6 +17,7 @@ import 'package:paperless_mobile/features/settings/view/widgets/theme_mode_setti
import 'package:paperless_mobile/features/settings/view/widgets/user_settings_builder.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher_string.dart';
class SettingsPage extends StatelessWidget {
const SettingsPage({super.key});
@@ -80,15 +83,49 @@ class SettingsPage extends StatelessWidget {
);
}
final serverData = snapshot.data!;
return Text(
S.of(context)!.paperlessServerVersion +
' ' +
serverData.version.toString() +
' (API v${serverData.apiVersion})',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.secondary,
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
S.of(context)!.paperlessServerVersion +
' ' +
serverData.version.toString() +
' (API v${serverData.apiVersion})',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.secondary,
),
textAlign: TextAlign.center,
),
if (serverData.isUpdateAvailable) ...[
SizedBox(height: 8),
Text.rich(
TextSpan(
style: Theme.of(context).textTheme.labelSmall!,
text: '${S.of(context)!.newerVersionAvailable} ',
children: [
TextSpan(
text: serverData.latestVersion,
style: Theme.of(context)
.textTheme
.labelSmall!
.copyWith(
decoration: TextDecoration.underline,
color: CupertinoColors.link,
decorationColor: CupertinoColors.link,
),
recognizer: TapGestureRecognizer()
..onTap = () {
launchUrlString(
"https://github.com/paperless-ngx/paperless-ngx/releases/tag/${serverData.latestVersion}",
);
},
),
],
),
textAlign: TextAlign.center,
),
textAlign: TextAlign.center,
]
],
);
},
),

View File

@@ -23,6 +23,7 @@ class _LanguageSelectionSettingState extends State<LanguageSelectionSetting> {
'pl': LanguageOption('Polska', true),
'ca': LanguageOption('Català', true),
'ru': LanguageOption('Русский', true),
'it': LanguageOption('Italiano', true),
};
@override

19
lib/keys.dart Normal file
View File

@@ -0,0 +1,19 @@
import 'package:flutter/widgets.dart';
class TestKeys {
TestKeys._();
static final login = _LoginTestKeys();
}
class _LoginTestKeys {
final serverAddressFormField = const Key('login-server-address');
final continueButton = const Key('login-continue-button');
final usernameFormField = const Key('login-username');
final passwordFormField = const Key('login-password');
final loginButton = const Key('login-login-button');
final clientCertificateFormField = const Key('login-client-certificate');
final clientCertificatePassphraseFormField =
const Key('login-client-certificate-passphrase');
final loggingInScreen = const Key('login-logging-in-screen');
}

View File

@@ -1024,5 +1024,25 @@
"@testingConnection": {
"description": "Text shown while the app tries to establish a connection to the specified host."
},
"version": "Versió {versionCode}"
"version": "Versió {versionCode}",
"notes": "{count, plural, zero{Notes} one{Nota} other{Notes}}",
"addNote": "Afegir Nota",
"newerVersionAvailable": "Newer version available:",
"dateOutOfRange": "Date must be between {firstDate} and {lastDate}.",
"@dateOutOfRange": {
"description": "Error message shown when the user tries to select a date outside of the allowed range.",
"placeholders": {
"firstDate": {
"type": "DateTime",
"format": "yMd"
},
"lastDate": {
"type": "DateTime",
"format": "yMd"
}
}
},
"permissions": "Permissions",
"newNote": "New note",
"notesMarkdownSyntaxSupportHint": "Paperless Mobile can render notes using basic markdown syntax. Try it out!"
}

View File

@@ -1024,5 +1024,25 @@
"@testingConnection": {
"description": "Text shown while the app tries to establish a connection to the specified host."
},
"version": "Version {versionCode}"
"version": "Version {versionCode}",
"notes": "{count, plural, zero{Notes} one{Note} other{Notes}}",
"addNote": "Add note",
"newerVersionAvailable": "Newer version available:",
"dateOutOfRange": "Date must be between {firstDate} and {lastDate}.",
"@dateOutOfRange": {
"description": "Error message shown when the user tries to select a date outside of the allowed range.",
"placeholders": {
"firstDate": {
"type": "DateTime",
"format": "yMd"
},
"lastDate": {
"type": "DateTime",
"format": "yMd"
}
}
},
"permissions": "Permissions",
"newNote": "New note",
"notesMarkdownSyntaxSupportHint": "Paperless Mobile can render notes using basic markdown syntax. Try it out!"
}

View File

@@ -1024,5 +1024,25 @@
"@testingConnection": {
"description": "Text shown while the app tries to establish a connection to the specified host."
},
"version": "Version {versionCode}"
"version": "Version {versionCode}",
"notes": "{count, plural, zero{Notizen} one{Notiz} other{Notizen}}",
"addNote": "Notiz hinzufügen",
"newerVersionAvailable": "Neuere Version verfügbar:",
"dateOutOfRange": "Das Datum muss zwischen {firstDate} und {lastDate} liegen.",
"@dateOutOfRange": {
"description": "Error message shown when the user tries to select a date outside of the allowed range.",
"placeholders": {
"firstDate": {
"type": "DateTime",
"format": "yMd"
},
"lastDate": {
"type": "DateTime",
"format": "yMd"
}
}
},
"permissions": "Berechtigungen",
"newNote": "Neue Notiz",
"notesMarkdownSyntaxSupportHint": "Paperless Mobile unterstützt Markdown-Syntax zur Darstellung und Formatierung von Notizen. Probiere es aus!"
}

View File

@@ -1024,5 +1024,25 @@
"@testingConnection": {
"description": "Text shown while the app tries to establish a connection to the specified host."
},
"version": "Version {versionCode}"
"version": "Version {versionCode}",
"notes": "{count, plural, zero{Notes} one{Note} other{Notes}}",
"addNote": "Add note",
"newerVersionAvailable": "Newer version available:",
"dateOutOfRange": "Date must be between {firstDate} and {lastDate}.",
"@dateOutOfRange": {
"description": "Error message shown when the user tries to select a date outside of the allowed range.",
"placeholders": {
"firstDate": {
"type": "DateTime",
"format": "yMd"
},
"lastDate": {
"type": "DateTime",
"format": "yMd"
}
}
},
"permissions": "Permissions",
"newNote": "New note",
"notesMarkdownSyntaxSupportHint": "Paperless Mobile can render notes using basic markdown syntax. Try it out!"
}

View File

@@ -1020,9 +1020,29 @@
},
"misc": "Otros",
"loggingOut": "Cerrando sesión...",
"testingConnection": "Testing connection...",
"testingConnection": "Probando conexión...",
"@testingConnection": {
"description": "Text shown while the app tries to establish a connection to the specified host."
},
"version": "Version {versionCode}"
"version": "Version {versionCode}",
"notes": "{count, plural, zero{Notas} one{Nota} other{Notas}}",
"addNote": "Añadir nota",
"newerVersionAvailable": "Newer version available:",
"dateOutOfRange": "Date must be between {firstDate} and {lastDate}.",
"@dateOutOfRange": {
"description": "Error message shown when the user tries to select a date outside of the allowed range.",
"placeholders": {
"firstDate": {
"type": "DateTime",
"format": "yMd"
},
"lastDate": {
"type": "DateTime",
"format": "yMd"
}
}
},
"permissions": "Permissions",
"newNote": "New note",
"notesMarkdownSyntaxSupportHint": "Paperless Mobile can render notes using basic markdown syntax. Try it out!"
}

View File

@@ -703,7 +703,7 @@
"@confirmAction": {
"description": "Typically used as a title to confirm a previously selected action"
},
"areYouSureYouWantToContinue": "Etes-vous sûr(e) de vouloir continuer?",
"areYouSureYouWantToContinue": "Êtes-vous sûr(e) de vouloir continuer ?",
"bulkEditTagsAddMessage": "{count, plural, one{Cette opération va ajouter les balises {tags} au document sélectionné} other{Cette opération va ajouter les balises {tags} à {count} documents sélectionnés!}}",
"@bulkEditTagsAddMessage": {
"description": "Message of the confirmation dialog when bulk adding tags."
@@ -717,7 +717,7 @@
"description": "Message of the confirmation dialog when both adding and removing tags."
},
"bulkEditCorrespondentAssignMessage": "{count, plural, one{Cette opération assignera le correspondant {correspondent} au document sélectionné} other{Cette opération va assigner le correspondant {correspondent} à {count} documents sélectionnés!}}",
"bulkEditDocumentTypeAssignMessage": "{count, plural, one{Cette opération assignera le type de document {docType} au document sélectionné.} other{Cette opération va assigner le documentType {docType} à {count} documents sélectionnés.}}",
"bulkEditDocumentTypeAssignMessage": "{count, plural, one{Cette opération assignera le type de document {docType} au document sélectionné.} other{Cette opération va assigner le type de document {docType} à {count} documents sélectionnés.}}",
"bulkEditStoragePathAssignMessage": "{count, plural, one{Cette opération assignera le chemin de stockage {path} au document sélectionné.} other{Cette opération va assigner le chemin de stockage {path} à {count} documents sélectionnés.}}",
"bulkEditCorrespondentRemoveMessage": "{count, plural, one{Cette opération va supprimer le correspondant du document sélectionné.} other{Cette opération va supprimer le correspondant de {count} documents sélectionnés.}}",
"bulkEditDocumentTypeRemoveMessage": "{count, plural, one{Cette opération va supprimer le type de document du document sélectionné.} other{Cette opération va supprimer le type de document de {count} documents sélectionnés.}}",
@@ -772,7 +772,7 @@
"@defaultDownloadFileType": {
"description": "Label indicating the default filetype to download (one of archived, original and always ask)"
},
"defaultShareFileType": "Type de fichier par défaut de partage",
"defaultShareFileType": "Type de fichier par défaut pour le partage",
"@defaultShareFileType": {
"description": "Label indicating the default filetype to share (one of archived, original and always ask)"
},
@@ -861,7 +861,7 @@
"@loginRequiredPermissionsHint": {
"description": "Hint shown on the login page informing the user of the required permissions to use the app."
},
"missingPermissions": "You do not have the necessary permissions to perform this action.",
"missingPermissions": "Vous n'avez pas les permissions nécessaires pour faire cette action.",
"@missingPermissions": {
"description": "Message shown in a snackbar when a user without the reequired permissions performs an action."
},
@@ -873,156 +873,176 @@
"@donate": {
"description": "Label of the in-app donate button"
},
"donationDialogContent": "Thank you for considering to support this app! Due to both Google's and Apple's Payment Policies, no links leading to donations may be displayed in-app. Not even linking to the project's repository page appears to be allowed in this context. Therefore, maybe have a look at the 'Donations' section in the project's README. Your support is much appreciated and keeps the development of this app alive. Thanks!",
"donationDialogContent": "Merci d'avoir envisagé de soutenir cette application ! En raison des politiques de paiement de Google et d'Apple, aucun lien menant aux dons ne peut être affiché dans l'application. Même un lien vers la page du dépôt du projet ne semble pas autorisé dans ce contexte. Par conséquent, jetez peut-être un coup d'oeil à la section « Donations » dans le README du projet. Votre soutien est très apprécié et maintient en vie le développement de cette application. Merci !",
"@donationDialogContent": {
"description": "Text displayed in the donation dialog"
},
"noDocumentsFound": "No documents found.",
"noDocumentsFound": "Aucun document trouvé.",
"@noDocumentsFound": {
"description": "Message shown when no documents were found."
},
"couldNotDeleteCorrespondent": "Could not delete correspondent, please try again.",
"couldNotDeleteCorrespondent": "Impossible de supprimer le correspondant, veuillez réessayer.",
"@couldNotDeleteCorrespondent": {
"description": "Message shown in snackbar when a correspondent could not be deleted."
},
"couldNotDeleteDocumentType": "Could not delete document type, please try again.",
"couldNotDeleteDocumentType": "Impossible de supprimer ce type de document, veuillez réessayer.",
"@couldNotDeleteDocumentType": {
"description": "Message shown when a document type could not be deleted"
},
"couldNotDeleteTag": "Could not delete tag, please try again.",
"couldNotDeleteTag": "Impossible de supprimer l'étiquette, veuillez réessayer.",
"@couldNotDeleteTag": {
"description": "Message shown when a tag could not be deleted"
},
"couldNotDeleteStoragePath": "Could not delete storage path, please try again.",
"couldNotDeleteStoragePath": "Impossible de supprimer le chemin de stockage, veuillez réessayer.",
"@couldNotDeleteStoragePath": {
"description": "Message shown when a storage path could not be deleted"
},
"couldNotUpdateCorrespondent": "Could not update correspondent, please try again.",
"couldNotUpdateCorrespondent": "Impossible de mettre à jour le correspondant, veuillez réessayer.",
"@couldNotUpdateCorrespondent": {
"description": "Message shown when a correspondent could not be updated"
},
"couldNotUpdateDocumentType": "Could not update document type, please try again.",
"couldNotUpdateDocumentType": "Impossible de mettre à jour le type de document, veuillez réessayer.",
"@couldNotUpdateDocumentType": {
"description": "Message shown when a document type could not be updated"
},
"couldNotUpdateTag": "Could not update tag, please try again.",
"couldNotUpdateTag": "Impossible de mettre à jour l'étiquette, veuillez réessayer.",
"@couldNotUpdateTag": {
"description": "Message shown when a tag could not be updated"
},
"couldNotLoadServerInformation": "Could not load server information.",
"couldNotLoadServerInformation": "Impossible de charger les informations du serveur.",
"@couldNotLoadServerInformation": {
"description": "Message shown when the server information could not be loaded"
},
"couldNotLoadStatistics": "Could not load server statistics.",
"couldNotLoadStatistics": "Impossible de charger les statistiques du serveur.",
"@couldNotLoadStatistics": {
"description": "Message shown when the server statistics could not be loaded"
},
"couldNotLoadUISettings": "Could not load UI settings.",
"couldNotLoadUISettings": "Impossible de charger les paramètres de l'interface.",
"@couldNotLoadUISettings": {
"description": "Message shown when the UI settings could not be loaded"
},
"couldNotLoadTasks": "Could not load tasks.",
"couldNotLoadTasks": "Impossible de charger les tâches.",
"@couldNotLoadTasks": {
"description": "Message shown when the tasks (e.g. document consumed) could not be loaded"
},
"userNotFound": "User could not be found.",
"userNotFound": "L'utilisateur ne peut pas être trouvé.",
"@userNotFound": {
"description": "Message shown when the specified user (e.g. by id) could not be found"
},
"couldNotUpdateSavedView": "Could not update saved view, please try again.",
"couldNotUpdateSavedView": "Impossible de mettre à jour la vue, veuillez réessayer.",
"@couldNotUpdateSavedView": {
"description": "Message shown when a saved view could not be updated"
},
"couldNotUpdateStoragePath": "Could not update storage path, please try again.",
"savedViewSuccessfullyUpdated": "Saved view successfully updated.",
"couldNotUpdateStoragePath": "Impossible de mettre à jour le chemin de stockage, veuillez réessayer.",
"savedViewSuccessfullyUpdated": "Vue enregistrée mise à jour avec succès.",
"@savedViewSuccessfullyUpdated": {
"description": "Message shown when a saved view was successfully updated."
},
"discardChanges": "Discard changes?",
"discardChanges": "Annuler les modifications ?",
"@discardChanges": {
"description": "Title of the alert dialog shown when a user tries to close a view with unsaved changes."
},
"savedViewChangedDialogContent": "The filter conditions of the active view have changed. By resetting the filter, these changes will be lost. Do you still wish to continue?",
"savedViewChangedDialogContent": "Les conditions de filtre de la vue active ont changé. En réinitialisant le filtre, ces modifications seront perdues. Voulez-vous continuer ?",
"@savedViewChangedDialogContent": {
"description": "Content of the alert dialog shown when all of the following applies:\r\n* User has saved view selected\r\n* User has performed changes to the current document filter\r\n* User now tries to reset this filter without having saved the changes to the view."
},
"createFromCurrentFilter": "Create from current filter",
"createFromCurrentFilter": "Créer à partir du filtre actuel",
"@createFromCurrentFilter": {
"description": "Tooltip of the \"New saved view\" button"
},
"home": "Home",
"home": "Accueil",
"@home": {
"description": "Label of the \"Home\" route"
},
"welcomeUser": "Welcome, {name}!",
"welcomeUser": "Bienvenue {name} !",
"@welcomeUser": {
"description": "Top message shown on the home page"
},
"statistics": "Statistics",
"documentsInInbox": "Documents in inbox",
"totalDocuments": "Total documents",
"totalCharacters": "Total characters",
"showAll": "Show all",
"statistics": "Statistiques",
"documentsInInbox": "Documents dans la boîte de réception",
"totalDocuments": "Nombre total de documents",
"totalCharacters": "Nombre total de caractères",
"showAll": "Tout afficher",
"@showAll": {
"description": "Button label shown on a saved view preview to open this view in the documents page"
},
"userAlreadyExists": "This user already exists.",
"userAlreadyExists": "Cet utilisateur existe déjà.",
"@userAlreadyExists": {
"description": "Error message shown when the user tries to add an already existing account."
},
"youDidNotSaveAnyViewsYet": "You did not save any views yet, create one and it will be shown here.",
"youDidNotSaveAnyViewsYet": "Vous n'avez pas encore enregistré de vues, créez en une et elle sera affichée ici.",
"@youDidNotSaveAnyViewsYet": {
"description": "Message shown when there are no saved views yet."
},
"tryAgain": "Try again",
"discardFile": "Discard file?",
"discard": "Discard",
"backToLogin": "Back to login",
"skipEditingReceivedFiles": "Skip editing received files",
"uploadWithoutPromptingUploadForm": "Always upload without prompting the upload form when sharing files with the app.",
"authenticatingDots": "Authenticating...",
"tryAgain": "Veuillez réessayer",
"discardFile": "Abandonner le fichier ?",
"discard": "Abandonner",
"backToLogin": "Retour à la page de connexion",
"skipEditingReceivedFiles": "Passer l'édition des fichiers reçus",
"uploadWithoutPromptingUploadForm": "Toujours mettre en ligne sans montrer le formulaire de mise en ligne lors du partage de fichiers avec l'application.",
"authenticatingDots": "Authentification en cours...",
"@authenticatingDots": {
"description": "Message shown when the app is authenticating the user"
},
"persistingUserInformation": "Persisting user information...",
"fetchingUserInformation": "Fetching user information...",
"persistingUserInformation": "Sauvegarde des informations utilisateur...",
"fetchingUserInformation": "Récupération des informations utilisateur...",
"@fetchingUserInformation": {
"description": "Message shown when the app loads user data from the server"
},
"restoringSession": "Restoring session...",
"restoringSession": "Restauration de la session...",
"@restoringSession": {
"description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in"
},
"documentsAssigned": "{count, plural, zero{No documents} one{1 document} other{{count} documents}}",
"documentsAssigned": "{count, plural, zero{Pas de document} one{1 document} other{{count} documents}}",
"@documentsAssigned": {
"description": "Text shown with a correspondent, document type etc. to indicate the number of documents this filter will maximally yield."
},
"discardChangesWarning": "You have unsaved changes. By continuing, all changes will be lost. Do you want to discard these changes?",
"discardChangesWarning": "Vous avez des modifications non enregistrées. En continuant, toutes les modifications seront perdues. Voulez-vous abandonner ces modifications ?",
"@discardChangesWarning": {
"description": "Warning message shown when the user tries to close a route without saving the changes."
},
"changelog": "Changelog",
"noLogsFoundOn": "No logs found on {date}.",
"logfileBottomReached": "You have reached the bottom of this logfile.",
"appLogs": "App logs {date}",
"saveLogsToFile": "Save logs to file",
"copyToClipboard": "Copy to clipboard",
"couldNotLoadLogfileFrom": "Could not load logfile from {date}.",
"loadingLogsFrom": "Loading logs from {date}...",
"clearLogs": "Clear logs from {date}",
"showPdf": "Show PDF",
"changelog": "Notes de version",
"noLogsFoundOn": "Aucun journal trouvé sur {date}.",
"logfileBottomReached": "Vous avez atteint le bas de ce fichier journal.",
"appLogs": "Journaux d'application {date}",
"saveLogsToFile": "Enregistrer le fichier journal",
"copyToClipboard": "Copier dans le presse-papier",
"couldNotLoadLogfileFrom": "Impossible de charger le fichier journal depuis {date}.",
"loadingLogsFrom": "Chargement des journaux depuis {date}...",
"clearLogs": "Effacer les journaux de {date}",
"showPdf": "Afficher le PDF",
"@showPdf": {
"description": "Tooltip shown on the \"show pdf\" button on the document edit page"
},
"hidePdf": "Hide PDF",
"hidePdf": "Masquer le PDF",
"@hidePdf": {
"description": "Tooltip shown on the \"show pdf\" icon button on the document edit page"
},
"misc": "Sonstige",
"loggingOut": "Logging out...",
"testingConnection": "Testing connection...",
"loggingOut": "Déconnexion...",
"testingConnection": "Vérifier la connexion...",
"@testingConnection": {
"description": "Text shown while the app tries to establish a connection to the specified host."
},
"version": "Version {versionCode}"
"version": "Version {versionCode}",
"notes": "{count, plural, zero{Notes} one{Note} other{Notes}}",
"addNote": "Ajouter une note",
"newerVersionAvailable": "Newer version available:",
"dateOutOfRange": "Date must be between {firstDate} and {lastDate}.",
"@dateOutOfRange": {
"description": "Error message shown when the user tries to select a date outside of the allowed range.",
"placeholders": {
"firstDate": {
"type": "DateTime",
"format": "yMd"
},
"lastDate": {
"type": "DateTime",
"format": "yMd"
}
}
},
"permissions": "Permissions",
"newNote": "New note",
"notesMarkdownSyntaxSupportHint": "Paperless Mobile can render notes using basic markdown syntax. Try it out!"
}

1048
lib/l10n/intl_it.arb Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1024,5 +1024,25 @@
"@testingConnection": {
"description": "Text shown while the app tries to establish a connection to the specified host."
},
"version": "Version {versionCode}"
"version": "Version {versionCode}",
"notes": "{count, plural, zero{Notes} one{Note} other{Notes}}",
"addNote": "Add note",
"newerVersionAvailable": "Newer version available:",
"dateOutOfRange": "Date must be between {firstDate} and {lastDate}.",
"@dateOutOfRange": {
"description": "Error message shown when the user tries to select a date outside of the allowed range.",
"placeholders": {
"firstDate": {
"type": "DateTime",
"format": "yMd"
},
"lastDate": {
"type": "DateTime",
"format": "yMd"
}
}
},
"permissions": "Permissions",
"newNote": "New note",
"notesMarkdownSyntaxSupportHint": "Paperless Mobile can render notes using basic markdown syntax. Try it out!"
}

View File

@@ -1024,5 +1024,25 @@
"@testingConnection": {
"description": "Text shown while the app tries to establish a connection to the specified host."
},
"version": "Version {versionCode}"
"version": "Version {versionCode}",
"notes": "{count, plural, zero{Notes} one{Note} other{Notes}}",
"addNote": "Add note",
"newerVersionAvailable": "Newer version available:",
"dateOutOfRange": "Date must be between {firstDate} and {lastDate}.",
"@dateOutOfRange": {
"description": "Error message shown when the user tries to select a date outside of the allowed range.",
"placeholders": {
"firstDate": {
"type": "DateTime",
"format": "yMd"
},
"lastDate": {
"type": "DateTime",
"format": "yMd"
}
}
},
"permissions": "Permissions",
"newNote": "New note",
"notesMarkdownSyntaxSupportHint": "Paperless Mobile can render notes using basic markdown syntax. Try it out!"
}

1048
lib/l10n/intl_ro.arb Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1024,5 +1024,25 @@
"@testingConnection": {
"description": "Text shown while the app tries to establish a connection to the specified host."
},
"version": "Version {versionCode}"
"version": "Version {versionCode}",
"notes": "{count, plural, zero{Notes} one{Note} other{Notes}}",
"addNote": "Add note",
"newerVersionAvailable": "Newer version available:",
"dateOutOfRange": "Date must be between {firstDate} and {lastDate}.",
"@dateOutOfRange": {
"description": "Error message shown when the user tries to select a date outside of the allowed range.",
"placeholders": {
"firstDate": {
"type": "DateTime",
"format": "yMd"
},
"lastDate": {
"type": "DateTime",
"format": "yMd"
}
}
},
"permissions": "Permissions",
"newNote": "New note",
"notesMarkdownSyntaxSupportHint": "Paperless Mobile can render notes using basic markdown syntax. Try it out!"
}

View File

@@ -1024,5 +1024,25 @@
"@testingConnection": {
"description": "Text shown while the app tries to establish a connection to the specified host."
},
"version": "Version {versionCode}"
"version": "Version {versionCode}",
"notes": "{count, plural, zero{Notes} one{Note} other{Notes}}",
"addNote": "Add note",
"newerVersionAvailable": "Newer version available:",
"dateOutOfRange": "Date must be between {firstDate} and {lastDate}.",
"@dateOutOfRange": {
"description": "Error message shown when the user tries to select a date outside of the allowed range.",
"placeholders": {
"firstDate": {
"type": "DateTime",
"format": "yMd"
},
"lastDate": {
"type": "DateTime",
"format": "yMd"
}
}
},
"permissions": "Permissions",
"newNote": "New note",
"notesMarkdownSyntaxSupportHint": "Paperless Mobile can render notes using basic markdown syntax. Try it out!"
}

View File

@@ -24,6 +24,8 @@ import 'package:paperless_mobile/constants.dart';
import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart';
import 'package:paperless_mobile/core/bloc/my_bloc_observer.dart';
import 'package:paperless_mobile/core/database/hive/hive_config.dart';
import 'package:paperless_mobile/core/database/hive/hive_extensions.dart';
import 'package:paperless_mobile/core/database/hive/hive_initialization.dart';
import 'package:paperless_mobile/core/database/tables/global_settings.dart';
import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart';
@@ -31,7 +33,7 @@ import 'package:paperless_mobile/core/exception/server_message_exception.dart';
import 'package:paperless_mobile/core/factory/paperless_api_factory.dart';
import 'package:paperless_mobile/core/factory/paperless_api_factory_impl.dart';
import 'package:paperless_mobile/core/interceptor/language_header.interceptor.dart';
import 'package:paperless_mobile/core/notifier/go_router_refresh_stream.dart';
import 'package:paperless_mobile/core/security/session_manager_impl.dart';
import 'package:paperless_mobile/features/logging/data/formatted_printer.dart';
import 'package:paperless_mobile/features/logging/data/logger.dart';
import 'package:paperless_mobile/features/logging/data/mirrored_file_output.dart';
@@ -105,64 +107,36 @@ Future<void> performMigrations() async {
}
}
Future<void> _initHive() async {
await Hive.initFlutter();
await performMigrations();
registerHiveAdapters();
await Hive.openBox<LocalUserAccount>(HiveBoxes.localUserAccount);
await Hive.openBox<LocalUserAppState>(HiveBoxes.localUserAppState);
await Hive.openBox<String>(HiveBoxes.hosts);
final globalSettingsBox =
await Hive.openBox<GlobalSettings>(HiveBoxes.globalSettings);
Future<void> initializeDefaultParameters() async {
Bloc.observer = MyBlocObserver();
await FileService.instance.initialize();
logger = l.Logger(
output: MirroredFileOutput(),
printer: FormattedPrinter(),
level: l.Level.trace,
filter: l.ProductionFilter(),
);
if (!globalSettingsBox.hasValue) {
await globalSettingsBox.setValue(
GlobalSettings(preferredLocaleSubtag: defaultPreferredLocale.toString()),
);
packageInfo = await PackageInfo.fromPlatform();
if (Platform.isAndroid) {
androidInfo = await DeviceInfoPlugin().androidInfo;
}
if (Platform.isIOS) {
iosInfo = await DeviceInfoPlugin().iosInfo;
}
await findSystemLocale();
}
void main() async {
runZonedGuarded(() async {
Bloc.observer = MyBlocObserver();
WidgetsFlutterBinding.ensureInitialized();
await FileService.instance.initialize();
logger = l.Logger(
output: MirroredFileOutput(),
printer: FormattedPrinter(),
level: l.Level.trace,
filter: l.ProductionFilter(),
);
Paint.enableDithering = true;
// if (kDebugMode) {
// // URL: http://localhost:3131
// // Login: admin:test
// await LocalMockApiServer(
// // RandomDelayGenerator(
// // const Duration(milliseconds: 100),
// // const Duration(milliseconds: 800),
// // ),
// )
// .start();
// }
packageInfo = await PackageInfo.fromPlatform();
if (Platform.isAndroid) {
androidInfo = await DeviceInfoPlugin().androidInfo;
}
if (Platform.isIOS) {
iosInfo = await DeviceInfoPlugin().iosInfo;
}
await _initHive();
final widgetsBinding = WidgetsFlutterBinding.ensureInitialized();
final globalSettingsBox =
Hive.box<GlobalSettings>(HiveBoxes.globalSettings);
final globalSettings = globalSettingsBox.getValue()!;
await findSystemLocale();
final hiveDirectory = await getApplicationDocumentsDirectory();
final defaultLocale = defaultPreferredLocale.languageCode;
await initializeDefaultParameters();
await initHive(hiveDirectory, defaultLocale);
await performMigrations();
final connectivityStatusService = ConnectivityStatusServiceImpl(
Connectivity(),
@@ -178,10 +152,10 @@ void main() async {
FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);
final languageHeaderInterceptor = LanguageHeaderInterceptor(
globalSettings.preferredLocaleSubtag,
() => Hive.globalSettingsBox.getValue()!.preferredLocaleSubtag,
);
// Manages security context, required for self signed client certificates
final sessionManager = SessionManager([
final SessionManager sessionManager = SessionManagerImpl([
PrettyDioLogger(
compact: true,
responseBody: false,
@@ -194,21 +168,9 @@ void main() async {
languageHeaderInterceptor,
]);
// Initialize Blocs/Cubits
final connectivityCubit = ConnectivityCubit(connectivityStatusService);
// Load application settings and stored authentication data
await connectivityCubit.initialize();
final localNotificationService = LocalNotificationService();
await localNotificationService.initialize();
//Update language header in interceptor on language change.
globalSettingsBox.listenable().addListener(() {
languageHeaderInterceptor.preferredLocaleSubtag =
globalSettings.preferredLocaleSubtag;
});
final apiFactory = PaperlessApiFactoryImpl(sessionManager);
final authenticationCubit = AuthenticationCubit(
localAuthService,
@@ -218,33 +180,19 @@ void main() async {
localNotificationService,
);
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: sessionManager),
Provider<LocalAuthenticationService>.value(value: localAuthService),
Provider<ConnectivityStatusService>.value(
value: connectivityStatusService),
Provider<LocalNotificationService>.value(
value: localNotificationService),
Provider.value(value: DocumentChangedNotifier()),
],
child: MultiProvider(
providers: [
Provider<ConnectivityCubit>.value(value: connectivityCubit),
Provider.value(value: authenticationCubit),
],
child: GoRouterShell(
apiFactory: apiFactory,
),
),
AppEntrypoint(
sessionManager: sessionManager,
apiFactory: apiFactory,
authenticationCubit: authenticationCubit,
connectivityStatusService: connectivityStatusService,
localNotificationService: localNotificationService,
localAuthService: localAuthService,
),
);
}, (error, stackTrace) {
if (error is StateError &&
error.message.contains("Cannot emit new states")) {
{
return;
}
return;
}
// Catches all unexpected/uncaught errors and prints them to the console.
final message = switch (error) {
@@ -261,9 +209,52 @@ void main() async {
});
}
class AppEntrypoint extends StatelessWidget {
final PaperlessApiFactory apiFactory;
final AuthenticationCubit authenticationCubit;
final ConnectivityStatusService connectivityStatusService;
final LocalNotificationService localNotificationService;
final LocalAuthenticationService localAuthService;
final SessionManager sessionManager;
const AppEntrypoint({
super.key,
required this.apiFactory,
required this.authenticationCubit,
required this.connectivityStatusService,
required this.localNotificationService,
required this.localAuthService,
required this.sessionManager,
});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
Provider.value(value: DocumentChangedNotifier()),
Provider.value(value: authenticationCubit),
Provider.value(
value: ConnectivityCubit(connectivityStatusService)..initialize(),
),
ChangeNotifierProvider.value(value: sessionManager),
Provider.value(value: connectivityStatusService),
Provider.value(value: localNotificationService),
Provider.value(value: localAuthService),
],
child: GoRouterShell(
apiFactory: apiFactory,
),
);
}
}
class GoRouterShell extends StatefulWidget {
final PaperlessApiFactory apiFactory;
const GoRouterShell({super.key, required this.apiFactory});
const GoRouterShell({
super.key,
required this.apiFactory,
});
@override
State<GoRouterShell> createState() => _GoRouterShellState();
@@ -396,7 +387,7 @@ class _GoRouterShellState extends State<GoRouterShell> {
dynamicScheme: darkDynamic,
preferredColorScheme: settings.preferredColorSchemeOption,
),
themeMode: settings.preferredThemeMode,
themeMode: settings.preferredThemeMode,
supportedLocales: const [
Locale('en'),
Locale('de'),
@@ -408,6 +399,7 @@ class _GoRouterShellState extends State<GoRouterShell> {
Locale('pl'),
Locale('ru'),
Locale('tr'),
Locale('it'),
],
localeResolutionCallback: (locale, supportedLocales) {
if (locale == null) {

View File

@@ -26,4 +26,5 @@ class R {
static const loggingOut = "loggingOut";
static const restoringSession = "restoringSession";
static const addAccount = 'addAccount';
static const addNote = 'addNote';
}

View File

@@ -12,6 +12,7 @@ import 'package:paperless_mobile/features/login/view/login_to_existing_account_p
import 'package:paperless_mobile/features/login/view/verify_identity_page.dart';
import 'package:paperless_mobile/features/login/view/widgets/login_transition_page.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/keys.dart';
import 'package:paperless_mobile/routing/navigation_keys.dart';
import 'package:paperless_mobile/routing/routes.dart';
part 'login_route.g.dart';
@@ -108,6 +109,7 @@ class AuthenticatingRoute extends GoRouteData {
};
return NoTransitionPage(
child: LoginTransitionPage(
key: TestKeys.login.loggingInScreen,
text: text,
),
);

View File

@@ -71,6 +71,7 @@ part 'authenticated_route.g.dart';
TypedGoRoute<DocumentDetailsRoute>(
path: "details/:id",
name: R.documentDetails,
routes: [],
),
TypedGoRoute<EditDocumentRoute>(
path: "edit",

View File

@@ -4,3 +4,4 @@ export 'src/models/models.dart';
export 'src/modules/modules.dart';
export 'src/converters/converters.dart';
export 'config/hive/hive_type_ids.dart';
export 'src/interceptor/dio_http_error_interceptor.dart';

View File

@@ -125,8 +125,8 @@ class DocumentFilter extends Equatable {
return queryParams;
}
@override
String toString() => toQueryParameters().toString();
// @override
// String toString() => toQueryParameters().toString();
DocumentFilter copyWith({
int? pageSize,
@@ -249,9 +249,4 @@ class DocumentFilter extends Equatable {
moreLike,
selectedView,
];
// factory DocumentFilter.fromJson(Map<String, dynamic> json) =>
// _$DocumentFilterFromJson(json);
// Map<String, dynamic> toJson() => _$DocumentFilterToJson(this);
}

View File

@@ -1,10 +1,10 @@
// ignore_for_file: non_constant_identifier_names
import 'package:equatable/equatable.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_api/src/converters/local_date_time_json_converter.dart';
import 'package:paperless_api/src/models/custom_field_model.dart';
import 'package:paperless_api/src/models/note_model.dart';
import 'package:paperless_api/src/models/search_hit.dart';
part 'document_model.g.dart';
@@ -48,8 +48,9 @@ class DocumentModel extends Equatable {
final int? owner;
final bool? userCanChange;
final Iterable<NoteModel> notes;
// Only present if full_perms=true
/// Only present if full_perms=true
final Permissions? permissions;
final Iterable<CustomFieldModel> customFields;
@@ -72,6 +73,7 @@ class DocumentModel extends Equatable {
this.userCanChange,
this.permissions,
this.customFields = const [],
this.notes = const [],
});
factory DocumentModel.fromJson(Map<String, dynamic> json) =>
@@ -94,6 +96,9 @@ class DocumentModel extends Equatable {
String? archivedFileName,
int? Function()? owner,
bool? userCanChange,
Iterable<NoteModel>? notes,
Permissions? permissions,
Iterable<CustomFieldModel>? customFields,
}) {
return DocumentModel(
id: id,
@@ -114,6 +119,9 @@ class DocumentModel extends Equatable {
archivedFileName: archivedFileName ?? this.archivedFileName,
owner: owner != null ? owner() : this.owner,
userCanChange: userCanChange ?? this.userCanChange,
customFields: customFields ?? this.customFields,
notes: notes ?? this.notes,
permissions: permissions ?? this.permissions,
);
}
@@ -134,5 +142,8 @@ class DocumentModel extends Equatable {
archivedFileName,
owner,
userCanChange,
customFields,
notes,
permissions,
];
}

View File

@@ -82,7 +82,6 @@ class FilterRule with EquatableMixin {
assert(filter.tags is IdsTagsQuery);
return filter.copyWith(
tags: switch (filter.tags) {
// TODO: Handle this case.
IdsTagsQuery(include: var i, exclude: var e) => IdsTagsQuery(
include: [...i, int.parse(value!)],
exclude: e,

View File

@@ -28,3 +28,4 @@ export 'task/task.dart';
export 'task/task_status.dart';
export 'user_model.dart';
export 'exception/exceptions.dart';
export 'note_model.dart' show NoteModel;

View File

@@ -0,0 +1,29 @@
// ignore_for_file: invalid_annotation_target
import 'package:freezed_annotation/freezed_annotation.dart';
part 'note_model.freezed.dart';
part 'note_model.g.dart';
@freezed
class NoteModel with _$NoteModel {
const factory NoteModel({
required int? id,
required String? note,
required DateTime? created,
required int? document,
@JsonKey(fromJson: parseNoteUserFromJson) required int? user,
}) = _NoteModel;
factory NoteModel.fromJson(Map<String, dynamic> json) =>
_$NoteModelFromJson(json);
}
int? parseNoteUserFromJson(dynamic json) {
if (json == null) return null;
if (json is Map) {
return json['id'];
} else if (json is int) {
return json;
}
return null;
}

View File

@@ -11,7 +11,16 @@ class PaperlessApiException implements Exception {
this.httpStatusCode,
});
const PaperlessApiException.unknown() : this(ErrorCode.unknown);
const PaperlessApiException.unknown({
String? details,
StackTrace? stackTrace,
int? httpStatusCode,
}) : this(
ErrorCode.unknown,
details: details,
stackTrace: stackTrace,
httpStatusCode: httpStatusCode,
);
@override
String toString() {
@@ -71,5 +80,7 @@ enum ErrorCode {
updateSavedViewError,
customFieldCreateFailed,
customFieldLoadFailed,
customFieldDeleteFailed;
customFieldDeleteFailed,
deleteNoteFailed,
addNoteFailed;
}

View File

@@ -4,6 +4,7 @@ class PaperlessServerInformationModel {
static const String versionHeader = 'x-version';
static const String apiVersionHeader = 'x-api-version';
final String version;
final String latestVersion;
final int apiVersion;
final bool isUpdateAvailable;
@@ -11,9 +12,11 @@ class PaperlessServerInformationModel {
required this.version,
required this.apiVersion,
required this.isUpdateAvailable,
required this.latestVersion,
});
int compareToOtherVersion(String other) {
return getExtendedVersionNumber(version).compareTo(getExtendedVersionNumber(other));
return getExtendedVersionNumber(version)
.compareTo(getExtendedVersionNumber(other));
}
}

View File

@@ -11,7 +11,7 @@ import 'date_range_unit.dart';
part 'date_range_query.g.dart';
sealed class DateRangeQuery {
sealed class DateRangeQuery with EquatableMixin {
const DateRangeQuery();
Map<String, String> toQueryParameter(DateRangeQueryField field);
@@ -28,10 +28,13 @@ class UnsetDateRangeQuery extends DateRangeQuery {
@override
bool matches(DateTime dt) => true;
@override
List<Object?> get props => [];
}
@HiveType(typeId: PaperlessApiHiveTypeIds.relativeDateRangeQuery)
class RelativeDateRangeQuery extends DateRangeQuery with EquatableMixin {
class RelativeDateRangeQuery extends DateRangeQuery {
@HiveField(0)
final int offset;
@HiveField(1)
@@ -84,7 +87,7 @@ class RelativeDateRangeQuery extends DateRangeQuery with EquatableMixin {
@JsonSerializable()
@HiveType(typeId: PaperlessApiHiveTypeIds.absoluteDateRangeQuery)
class AbsoluteDateRangeQuery extends DateRangeQuery with EquatableMixin {
class AbsoluteDateRangeQuery extends DateRangeQuery {
@LocalDateTimeJsonConverter()
@HiveField(0)
final DateTime? after;

View File

@@ -4,7 +4,7 @@ import 'package:paperless_api/config/hive/hive_type_ids.dart';
part 'id_query_parameter.g.dart';
sealed class IdQueryParameter {
sealed class IdQueryParameter with EquatableMixin {
const IdQueryParameter();
Map<String, String> toQueryParameter(String field);
bool matches(int? id);
@@ -23,6 +23,9 @@ class UnsetIdQueryParameter extends IdQueryParameter {
@override
bool matches(int? id) => true;
@override
List<Object?> get props => [];
}
// @HiveType(typeId: PaperlessApiHiveTypeIds.notAssignedIdQueryParameter)
@@ -36,6 +39,8 @@ class NotAssignedIdQueryParameter extends IdQueryParameter {
@override
bool matches(int? id) => id == null;
@override
List<Object?> get props => [];
}
// @HiveType(typeId: PaperlessApiHiveTypeIds.anyAssignedIdQueryParameter)
@@ -48,6 +53,8 @@ class AnyAssignedIdQueryParameter extends IdQueryParameter {
@override
bool matches(int? id) => id != null;
@override
List<Object?> get props => [];
}
@HiveType(typeId: PaperlessApiHiveTypeIds.setIdQueryParameter)

View File

@@ -4,7 +4,7 @@ import 'package:paperless_api/config/hive/hive_type_ids.dart';
part 'tags_query.g.dart';
sealed class TagsQuery {
sealed class TagsQuery with EquatableMixin {
const TagsQuery();
Map<String, String> toQueryParameter();
bool matches(Iterable<int> ids);
@@ -20,10 +20,13 @@ class NotAssignedTagsQuery extends TagsQuery {
@override
bool matches(Iterable<int> ids) => ids.isEmpty;
@override
List<Object?> get props => [];
}
@HiveType(typeId: PaperlessApiHiveTypeIds.anyAssignedTagsQuery)
class AnyAssignedTagsQuery extends TagsQuery with EquatableMixin {
class AnyAssignedTagsQuery extends TagsQuery {
@HiveField(0)
final List<int> tagIds;
const AnyAssignedTagsQuery({
@@ -54,7 +57,7 @@ class AnyAssignedTagsQuery extends TagsQuery with EquatableMixin {
}
@HiveType(typeId: PaperlessApiHiveTypeIds.idsTagsQuery)
class IdsTagsQuery extends TagsQuery with EquatableMixin {
class IdsTagsQuery extends TagsQuery {
@HiveField(0)
final List<int> include;
@HiveField(1)

View File

@@ -1,3 +1,4 @@
import 'package:equatable/equatable.dart';
import 'package:hive/hive.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:paperless_api/config/hive/hive_type_ids.dart';
@@ -91,6 +92,11 @@ class TextQuery {
return other.queryText == queryText && other.queryType == queryType;
}
@override
String toString() {
return "TextQuery($queryText, $queryType)";
}
@override
int get hashCode => Object.hash(queryText, queryType);
}

View File

@@ -1,9 +1,4 @@
import 'package:paperless_api/src/models/exception/exceptions.dart';
abstract class PaperlessAuthenticationApi {
///
/// @throws [PaperlessUnauthorizedException]
///
Future<String> login({
required String username,
required String password,

View File

@@ -37,6 +37,11 @@ class PaperlessAuthenticationApiImpl implements PaperlessAuthenticationApi {
// return AuthenticationTemporaryRedirect(redirectUrl!);
} on DioException catch (exception) {
throw exception.unravel();
} catch (error, stackTrace) {
throw PaperlessApiException.unknown(
details: error.toString(),
stackTrace: stackTrace,
);
}
}
}

View File

@@ -22,6 +22,7 @@ abstract class PaperlessDocumentsApi {
Future<DocumentModel> find(int id);
Future<int> delete(DocumentModel doc);
Future<DocumentMetaData> getMetaData(int id);
Future<DocumentModel> deleteNote(DocumentModel document, int noteId);
Future<Iterable<int>> bulkAction(BulkAction action);
Future<Uint8List> getPreview(int docId);
String getThumbnailUrl(int docId);
@@ -35,4 +36,7 @@ abstract class PaperlessDocumentsApi {
Future<FieldSuggestions> findSuggestions(DocumentModel document);
Future<List<String>> autocomplete(String query, [int limit = 10]);
Future<DocumentModel> addNote(
{required DocumentModel document, required String text});
}

View File

@@ -323,4 +323,45 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi {
);
}
}
@override
Future<DocumentModel> deleteNote(DocumentModel document, int noteId) async {
try {
final response = await client.delete(
"/api/documents/${document.id}/notes/?id=$noteId",
options: Options(validateStatus: (status) => status == 200),
);
final notes =
(response.data as List).map((e) => NoteModel.fromJson(e)).toList();
return document.copyWith(notes: notes);
} on DioException catch (exception) {
throw exception.unravel(
orElse: const PaperlessApiException(ErrorCode.deleteNoteFailed),
);
}
}
@override
Future<DocumentModel> addNote({
required DocumentModel document,
required String text,
}) async {
try {
final response = await client.post(
"/api/documents/${document.id}/notes/",
options: Options(validateStatus: (status) => status == 200),
data: {'note': text},
);
final notes =
(response.data as List).map((e) => NoteModel.fromJson(e)).toList();
return document.copyWith(notes: notes);
} on DioException catch (exception) {
throw exception.unravel(
orElse: const PaperlessApiException(ErrorCode.addNoteFailed),
);
}
}
}

View File

@@ -24,13 +24,15 @@ class PaperlessServerStatsApiImpl implements PaperlessServerStatsApi {
"/api/remote_version/",
options: Options(validateStatus: (status) => status == 200),
);
var version = response.data["version"] as String;
if (version == _fallbackVersion) {
version = response.headers.value('x-version') ?? _fallbackVersion;
}
final latestVersion = response.data["version"] as String;
final version = response.headers
.value(PaperlessServerInformationModel.versionHeader) ??
_fallbackVersion;
final updateAvailable = response.data["update_available"] as bool;
return PaperlessServerInformationModel(
apiVersion: int.parse(response.headers.value('x-api-version')!),
apiVersion: int.parse(response.headers
.value(PaperlessServerInformationModel.apiVersionHeader)!),
latestVersion: latestVersion,
version: version,
isUpdateAvailable: updateAvailable,
);

View File

@@ -22,6 +22,8 @@ dependencies:
jiffy: ^5.0.0
freezed_annotation: ^2.4.1
hive: ^2.2.3
mockito: ^5.4.4
http_mock_adapter: ^0.6.1
dev_dependencies:
flutter_test:

View File

@@ -0,0 +1,90 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http_mock_adapter/http_mock_adapter.dart';
import 'package:mockito/mockito.dart';
import 'package:paperless_api/paperless_api.dart';
void main() {
group('AuthenticationApi with DioHttpErrorIncerceptor', () {
late PaperlessAuthenticationApi authenticationApi;
late DioAdapter mockAdapter;
const token = "abcde";
const invalidCredentialsServerMessage =
"Unable to log in with provided credentials.";
setUp(() {
final dio = Dio()..interceptors.add(DioHttpErrorInterceptor());
authenticationApi = PaperlessAuthenticationApiImpl(dio);
mockAdapter = DioAdapter(dio: dio);
// Valid credentials
mockAdapter.onPost(
"/api/token/",
data: {
"username": "username",
"password": "password",
},
(server) => server.reply(200, {"token": token}),
);
// Invalid credentials
mockAdapter.onPost(
"/api/token/",
data: {
"username": "wrongUsername",
"password": "wrongPassword",
},
(server) => server.reply(400, {
"non_field_errors": [invalidCredentialsServerMessage]
}),
);
});
// tearDown(() {});
test(
'should return a valid token when logging in with valid credentials',
() {
expect(
authenticationApi.login(
username: "username",
password: "password",
),
completion(token),
);
},
);
test(
'should throw a PaperlessFormValidationException containing a reason '
'when logging in with invalid credentials',
() {
expect(
authenticationApi.login(
username: "wrongUsername",
password: "wrongPassword",
),
throwsA(isA<PaperlessFormValidationException>().having(
(e) => e.unspecificErrorMessage(),
"non-field specific error message",
equals(invalidCredentialsServerMessage),
)),
);
},
);
test(
'should return an error when logging in with invalid credentials',
() {
expect(
authenticationApi.login(
username: "wrongUsername",
password: "wrongPassword",
),
throwsA(isA<PaperlessFormValidationException>().having(
(e) => e.unspecificErrorMessage(),
"non-field specific error message",
equals(invalidCredentialsServerMessage),
)),
);
},
);
});
}

View File

@@ -2,7 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:paperless_api/paperless_api.dart';
void main() {
group('Validate parsing logic from [SavedView] to [DocumentFilter]:', () {
group('Parsing [SavedView] to [DocumentFilter]:', () {
test('Values are correctly parsed if set.', () {
expect(
SavedView.fromJson({
@@ -64,7 +64,7 @@ void main() {
]
}).toDocumentFilter(),
equals(
DocumentFilter.initial.copyWith(
DocumentFilter(
correspondent: const SetIdQueryParameter(id: 42),
documentType: const SetIdQueryParameter(id: 69),
storagePath: const SetIdQueryParameter(id: 14),
@@ -83,6 +83,7 @@ void main() {
sortField: SortField.created,
sortOrder: SortOrder.descending,
query: const TextQuery.extended("Never gonna give you up"),
selectedView: 1,
),
),
);
@@ -99,7 +100,11 @@ void main() {
"sort_reverse": true,
"filter_rules": [],
}).toDocumentFilter(),
equals(DocumentFilter.initial),
equals(
const DocumentFilter(
selectedView: 1,
),
),
);
});
@@ -130,11 +135,12 @@ void main() {
},
],
}).toDocumentFilter();
final expected = DocumentFilter.initial.copyWith(
correspondent: const NotAssignedIdQueryParameter(),
documentType: const NotAssignedIdQueryParameter(),
storagePath: const NotAssignedIdQueryParameter(),
tags: const NotAssignedTagsQuery(),
const expected = DocumentFilter(
correspondent: NotAssignedIdQueryParameter(),
documentType: NotAssignedIdQueryParameter(),
storagePath: NotAssignedIdQueryParameter(),
tags: NotAssignedTagsQuery(),
selectedView: 1,
);
expect(
actual,
@@ -148,6 +154,7 @@ void main() {
expect(
SavedView.fromDocumentFilter(
DocumentFilter(
selectedView: 1,
correspondent: const SetIdQueryParameter(id: 1),
documentType: const SetIdQueryParameter(id: 2),
storagePath: const SetIdQueryParameter(id: 3),
@@ -173,6 +180,7 @@ void main() {
),
equals(
SavedView(
id: 1,
name: "test_name",
showOnDashboard: false,
showInSidebar: false,

View File

@@ -101,10 +101,10 @@ packages:
dependency: transitive
description:
name: collection
sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687
sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
url: "https://pub.dev"
source: hosted
version: "1.17.2"
version: "1.18.0"
colorfilter_generator:
dependency: transitive
description:
@@ -244,10 +244,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3"
sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e
url: "https://pub.dev"
source: hosted
version: "1.9.1"
version: "1.10.0"
paperless_document_scanner:
dependency: "direct main"
description:
@@ -376,18 +376,18 @@ packages:
dependency: transitive
description:
name: stack_trace
sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5
sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
url: "https://pub.dev"
source: hosted
version: "1.11.0"
version: "1.11.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8"
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
url: "https://pub.dev"
source: hosted
version: "2.1.1"
version: "2.1.2"
stream_transform:
dependency: transitive
description:
@@ -416,10 +416,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "75760ffd7786fffdfb9597c35c5b27eaeec82be8edfb6d71d32651128ed7aab8"
sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
version: "0.6.1"
typed_data:
dependency: transitive
description:
@@ -440,10 +440,10 @@ packages:
dependency: transitive
description:
name: web
sha256: dc8ccd225a2005c1be616fe02951e2e342092edf968cf0844220383757ef8f10
sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152
url: "https://pub.dev"
source: hosted
version: "0.1.4-beta"
version: "0.3.0"
win32:
dependency: transitive
description:
@@ -469,5 +469,5 @@ packages:
source: hosted
version: "6.3.0"
sdks:
dart: ">=3.1.0 <4.0.0"
dart: ">=3.2.0-194.0.dev <4.0.0"
flutter: ">=3.13.0"

View File

@@ -221,10 +221,10 @@ packages:
dependency: "direct main"
description:
name: collection
sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687
sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
url: "https://pub.dev"
source: hosted
version: "1.17.2"
version: "1.18.0"
color:
dependency: transitive
description:
@@ -848,6 +848,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
http_mock_adapter:
dependency: transitive
description:
name: http_mock_adapter
sha256: "46399c78bd4a0af071978edd8c502d7aeeed73b5fb9860bca86b5ed647a63c1b"
url: "https://pub.dev"
source: hosted
version: "0.6.1"
http_multi_server:
dependency: transitive
description:
@@ -1014,7 +1022,7 @@ packages:
source: hosted
version: "1.2.0"
markdown:
dependency: transitive
dependency: "direct main"
description:
name: markdown
sha256: acf35edccc0463a9d7384e437c015a3535772e09714cf60e07eeef3a15870dcd
@@ -1041,10 +1049,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3"
sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e
url: "https://pub.dev"
source: hosted
version: "1.9.1"
version: "1.10.0"
mime:
dependency: transitive
description:
@@ -1060,6 +1068,14 @@ packages:
relative: true
source: path
version: "0.0.1"
mockito:
dependency: transitive
description:
name: mockito
sha256: "6841eed20a7befac0ce07df8116c8b8233ed1f4486a7647c7fc5a02ae6163917"
url: "https://pub.dev"
source: hosted
version: "5.4.4"
mocktail:
dependency: transitive
description:
@@ -1223,8 +1239,8 @@ packages:
dependency: "direct main"
description:
path: "packages/pdfx"
ref: HEAD
resolved-ref: "11f7dee82b58ca4f483c753f06bbdc91b34a0793"
ref: "4be9de9ffed5398fd7d5f44bbb07dcd3d3f1711b"
resolved-ref: "4be9de9ffed5398fd7d5f44bbb07dcd3d3f1711b"
url: "https://github.com/ScerIO/packages.flutter"
source: git
version: "2.5.0"
@@ -1288,10 +1304,10 @@ packages:
dependency: transitive
description:
name: platform
sha256: "4a451831508d7d6ca779f7ac6e212b4023dd5a7d08a27a63da33756410e32b76"
sha256: ae68c7bfcd7383af3629daafb32fb4e8681c7154428da4febcff06200585f102
url: "https://pub.dev"
source: hosted
version: "3.1.0"
version: "3.1.2"
plugin_platform_interface:
dependency: transitive
description:
@@ -1637,18 +1653,18 @@ packages:
dependency: transitive
description:
name: stack_trace
sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5
sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
url: "https://pub.dev"
source: hosted
version: "1.11.0"
version: "1.11.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8"
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
url: "https://pub.dev"
source: hosted
version: "2.1.1"
version: "2.1.2"
stream_transform:
dependency: transitive
description:
@@ -1693,26 +1709,26 @@ packages:
dependency: transitive
description:
name: test
sha256: "13b41f318e2a5751c3169137103b60c584297353d4b1761b66029bae6411fe46"
sha256: a1f7595805820fcc05e5c52e3a231aedd0b72972cb333e8c738a8b1239448b6f
url: "https://pub.dev"
source: hosted
version: "1.24.3"
version: "1.24.9"
test_api:
dependency: transitive
description:
name: test_api
sha256: "75760ffd7786fffdfb9597c35c5b27eaeec82be8edfb6d71d32651128ed7aab8"
sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
version: "0.6.1"
test_core:
dependency: transitive
description:
name: test_core
sha256: "99806e9e6d95c7b059b7a0fc08f07fc53fabe54a829497f0d9676299f1e8637e"
sha256: a757b14fc47507060a162cc2530d9a4a2f92f5100a952c7443b5cad5ef5b106a
url: "https://pub.dev"
source: hosted
version: "0.5.3"
version: "0.5.9"
time:
dependency: transitive
description:
@@ -1877,10 +1893,10 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: c620a6f783fa22436da68e42db7ebbf18b8c44b9a46ab911f666ff09ffd9153f
sha256: c538be99af830f478718b51630ec1b6bee5e74e52c8a802d328d9e71d35d2583
url: "https://pub.dev"
source: hosted
version: "11.7.1"
version: "11.10.0"
watcher:
dependency: transitive
description:
@@ -1893,10 +1909,10 @@ packages:
dependency: transitive
description:
name: web
sha256: dc8ccd225a2005c1be616fe02951e2e342092edf968cf0844220383757ef8f10
sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152
url: "https://pub.dev"
source: hosted
version: "0.1.4-beta"
version: "0.3.0"
web_socket_channel:
dependency: "direct main"
description:
@@ -1994,5 +2010,5 @@ packages:
source: hosted
version: "3.1.2"
sdks:
dart: ">=3.1.0 <4.0.0"
dart: ">=3.2.0-194.0.dev <4.0.0"
flutter: ">=3.13.0"

View File

@@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 3.1.8+403
version: 3.2.0+404
environment:
sdk: ">=3.1.0 <4.0.0"
@@ -103,8 +103,10 @@ dependencies:
# camerawesome: ^2.0.0-dev.1
pdfx:
git:
url: "https://github.com/ScerIO/packages.flutter"
url: 'https://github.com/ScerIO/packages.flutter'
ref: '4be9de9ffed5398fd7d5f44bbb07dcd3d3f1711b'
path: packages/pdfx
markdown: ^7.1.1
dependency_overrides:
intl: ^0.18.1

View File

@@ -1,23 +1,20 @@
#!/usr/bin/env bash
set -Eeuo pipefail
set -Euo pipefail
__script_dir=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )
readonly __script_dir
pushd "$__script_dir/../"
pushd packages/paperless_api
flutter packages pub get
dart run build_runner build --delete-conflicting-outputs
popd
pushd packages/mock_server
flutter packages pub get
popd
for dir in packages/*/ # list directories in the form "/tmp/dirname/"
do
pushd $dir
echo "Installing dependencies for $dir"
flutter packages pub get
dart run build_runner build --delete-conflicting-outputs
popd
done
flutter packages pub get
flutter gen-l10n
dart run build_runner build --delete-conflicting-outputs
popd
dart run build_runner build --delete-conflicting-outputs

View File

@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -Eeuo pipefail
__script_dir=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )
readonly __script_dir
cd "$__script_dir/../"
echo "Uploading source translation file..."
crowdin upload sources --identity=crowdin_credentials.yml --preserve-hierarchy
flutter packages pub get