diff --git a/android/app/build.gradle b/android/app/build.gradle index d5ceef6..39e075e 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -35,6 +35,8 @@ android { compileSdkVersion 33 compileOptions { + // Required for flutter_local_notifications + coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } @@ -54,6 +56,8 @@ android { versionCode flutterVersionCode.toInteger() versionName flutterVersionName testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + // Required for flutter_local_notifications + multiDexEnabled true } signingConfigs { @@ -82,4 +86,6 @@ dependencies { testImplementation 'junit:junit:4.12' androidTestImplementation 'androidx.test:runner:1.2.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' + // Required for flutter_local_notifications + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' } diff --git a/android/app/src/main/res/drawable-hdpi/ic_stat_paperless_logo_green.png b/android/app/src/main/res/drawable-hdpi/ic_stat_paperless_logo_green.png new file mode 100644 index 0000000..8de4fd1 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_stat_paperless_logo_green.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_stat_paperless_logo_green.png b/android/app/src/main/res/drawable-mdpi/ic_stat_paperless_logo_green.png new file mode 100644 index 0000000..6d6923b Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_stat_paperless_logo_green.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_stat_paperless_logo_green.png b/android/app/src/main/res/drawable-xhdpi/ic_stat_paperless_logo_green.png new file mode 100644 index 0000000..714758e Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_stat_paperless_logo_green.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_stat_paperless_logo_green.png b/android/app/src/main/res/drawable-xxhdpi/ic_stat_paperless_logo_green.png new file mode 100644 index 0000000..c175ba2 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_stat_paperless_logo_green.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_stat_paperless_logo_green.png b/android/app/src/main/res/drawable-xxxhdpi/ic_stat_paperless_logo_green.png new file mode 100644 index 0000000..54cd223 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_stat_paperless_logo_green.png differ diff --git a/android/build.gradle b/android/build.gradle index 48a5fe9..7e7824c 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -8,6 +8,8 @@ buildscript { dependencies { classpath 'com.android.tools.build:gradle:7.2.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + // implementation 'androidx.window:window:1.0.0' + // implementation 'androidx.window:window-java:1.0.0' } } diff --git a/lib/core/service/status.service.dart b/lib/core/service/status_service.dart similarity index 97% rename from lib/core/service/status.service.dart rename to lib/core/service/status_service.dart index 0a5bc96..76b2428 100644 --- a/lib/core/service/status.service.dart +++ b/lib/core/service/status_service.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; +import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart'; import 'package:paperless_api/paperless_api.dart'; @@ -50,10 +51,8 @@ class WebSocketStatusService implements StatusService { } class LongPollingStatusService implements StatusService { - static const maxRetries = 60; - - final BaseClient httpClient; - LongPollingStatusService(this.httpClient); + final Dio client; + const LongPollingStatusService(this.client); @override Future startListeningBeforeDocumentUpload( diff --git a/lib/features/documents/view/pages/documents_page.dart b/lib/features/documents/view/pages/documents_page.dart index 2bd9cb1..6a0b69c 100644 --- a/lib/features/documents/view/pages/documents_page.dart +++ b/lib/features/documents/view/pages/documents_page.dart @@ -1,11 +1,9 @@ -import 'dart:math'; - +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; -import 'package:paperless_mobile/core/repository/impl/correspondent_repository_impl.dart'; import 'package:paperless_mobile/core/repository/provider/label_repositories_provider.dart'; import 'package:paperless_mobile/features/document_details/bloc/document_details_cubit.dart'; import 'package:paperless_mobile/features/document_details/view/pages/document_details_page.dart'; @@ -21,12 +19,10 @@ import 'package:paperless_mobile/features/home/view/widget/info_drawer.dart'; import 'package:paperless_mobile/features/labels/bloc/providers/labels_bloc_provider.dart'; import 'package:paperless_mobile/features/login/bloc/authentication_cubit.dart'; import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; -import 'package:paperless_mobile/features/saved_view/cubit/saved_view_state.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 'package:paperless_mobile/util.dart'; -import 'package:collection/collection.dart'; class DocumentFilterIntent { final DocumentFilter? filter; diff --git a/lib/features/home/view/home_page.dart b/lib/features/home/view/home_page.dart index b0353e5..ee67956 100644 --- a/lib/features/home/view/home_page.dart +++ b/lib/features/home/view/home_page.dart @@ -22,10 +22,12 @@ import 'package:paperless_mobile/features/documents/view/pages/documents_page.da import 'package:paperless_mobile/features/home/view/widget/bottom_navigation_bar.dart'; import 'package:paperless_mobile/features/home/view/widget/info_drawer.dart'; import 'package:paperless_mobile/features/labels/view/pages/labels_page.dart'; +import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; import 'package:paperless_mobile/features/scan/bloc/document_scanner_cubit.dart'; import 'package:paperless_mobile/features/scan/view/scanner_page.dart'; import 'package:paperless_mobile/features/sharing/share_intent_queue.dart'; +import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.dart'; import 'package:paperless_mobile/generated/l10n.dart'; import 'package:paperless_mobile/util.dart'; import 'package:receive_sharing_intent/receive_sharing_intent.dart'; @@ -45,6 +47,46 @@ class _HomePageState extends State { @override void initState() { super.initState(); + LocalNotificationService.instance.notifyTaskChanged( + Task( + id: 100, + dateCreated: DateTime.now(), + dateDone: DateTime.now(), + taskFileName: "test_file.pdf", + status: TaskStatus.started, + taskId: "abc-def-123-456", + type: "file", + ), + ); + Future.delayed(const Duration(seconds: 5), () { + LocalNotificationService.instance.notifyTaskChanged( + Task( + id: 100, + dateCreated: DateTime.now(), + dateDone: DateTime.now(), + taskFileName: "test_file.pdf", + status: TaskStatus.pending, + taskId: "abc-def-123-456", + type: "file", + ), + ); + }); + Future.delayed(const Duration(seconds: 10), () { + LocalNotificationService.instance.notifyTaskChanged( + Task( + id: 100, + acknowledged: false, + dateCreated: DateTime.now(), + dateDone: DateTime.now(), + relatedDocumentId: 180, + result: "New document successfully created.", + status: TaskStatus.success, + taskFileName: "test_file.pdf", + taskId: "abc-def-123-456", + type: "file", + ), + ); + }); _initializeData(context); WidgetsBinding.instance.addPostFrameCallback((timeStamp) { _listenForReceivedFiles(); @@ -71,29 +113,6 @@ class _HomePageState extends State { } Future _handleReceivedFile(SharedMediaFile file) async { - // final isGranted = await askForPermission(Permission.storage); - - // if (!isGranted) { - // return; - // } - // showDialog( - // context: context, - // builder: (context) => AlertDialog( - // title: Text("Received File."), - // content: Column( - // children: [ - // Text("Path: ${file.path}"), - // Text("Type: ${file.type.name}"), - // Text("Exists: ${File(file.path).existsSync()}"), - // FutureBuilder( - // future: Permission.storage.isGranted, - // builder: (context, snapshot) => - // Text("Has storage permission: ${snapshot.data}"), - // ) - // ], - // ), - // ), - // ); SharedMediaFile mediaFile; if (Platform.isIOS) { // Workaround for file not found on iOS: https://stackoverflow.com/a/72813212 @@ -165,12 +184,20 @@ class _HomePageState extends State { @override Widget build(BuildContext context) { - return BlocListener( - //Only re-initialize data if the connectivity changed from not connected to connected - listenWhen: (previous, current) => current == ConnectivityState.connected, - listener: (context, state) { - _initializeData(context); - }, + return MultiBlocListener( + listeners: [ + BlocListener( + //Only re-initialize data if the connectivity changed from not connected to connected + listenWhen: (previous, current) => + current == ConnectivityState.connected, + listener: (context, state) { + _initializeData(context); + }, + ), + BlocListener( + listener: (context, state) {}, + ), + ], child: Scaffold( key: rootScaffoldKey, bottomNavigationBar: BottomNavBar( diff --git a/lib/features/notifications/cubit/notification_cubit.dart b/lib/features/notifications/cubit/notification_cubit.dart new file mode 100644 index 0000000..458db3d --- /dev/null +++ b/lib/features/notifications/cubit/notification_cubit.dart @@ -0,0 +1,11 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; + +part 'notification_state.dart'; + +class NotificationCubit extends Cubit { + NotificationCubit() : super(NotificationInitialState()); + + void navigateTo(String route, dynamic args) {} +} diff --git a/lib/features/notifications/cubit/notification_state.dart b/lib/features/notifications/cubit/notification_state.dart new file mode 100644 index 0000000..a3d64c9 --- /dev/null +++ b/lib/features/notifications/cubit/notification_state.dart @@ -0,0 +1,16 @@ +part of 'notification_cubit.dart'; + +abstract class NotificationState extends Equatable { + const NotificationState(); + + @override + List get props => []; +} + +class NotificationInitialState extends NotificationState {} + +class NotificationOpenDocumentDetailsPageState extends NotificationState { + final int documentId; + + const NotificationOpenDocumentDetailsPageState(this.documentId); +} diff --git a/lib/features/notifications/services/local_notification_service.dart b/lib/features/notifications/services/local_notification_service.dart new file mode 100644 index 0000000..ab52749 --- /dev/null +++ b/lib/features/notifications/services/local_notification_service.dart @@ -0,0 +1,152 @@ +import 'dart:convert'; +import 'dart:developer'; + +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/features/notifications/services/models/notification_payloads/open_created_document_notification_payload.dart'; +import 'package:paperless_mobile/features/notifications/services/notification_actions.dart'; +import 'package:paperless_mobile/features/notifications/services/notification_channels.dart'; + +class LocalNotificationService { + final FlutterLocalNotificationsPlugin _plugin = + FlutterLocalNotificationsPlugin(); + + LocalNotificationService._(); + + static final LocalNotificationService instance = LocalNotificationService._(); + + Future initialize() async { + const AndroidInitializationSettings initializationSettingsAndroid = + AndroidInitializationSettings('ic_stat_paperless_logo_green'); + final DarwinInitializationSettings initializationSettingsDarwin = + DarwinInitializationSettings( + requestSoundPermission: false, + requestBadgePermission: false, + requestAlertPermission: false, + onDidReceiveLocalNotification: onDidReceiveLocalNotification, + ); + final InitializationSettings initializationSettings = + InitializationSettings( + android: initializationSettingsAndroid, + iOS: initializationSettingsDarwin, + ); + await _plugin.initialize( + initializationSettings, + onDidReceiveNotificationResponse: onDidReceiveNotificationResponse, + ); + } + + //TODO: INTL + Future notifyTaskChanged(Task task) { + log("[LocalNotificationService] notifyTaskChanged: ${task.toString()}"); + int id = task.id; + final status = task.status; + late String title; + late String? body; + late int timestampMillis; + bool showProgress = + status == TaskStatus.started || status == TaskStatus.pending; + int progress = 0; + dynamic payload; + switch (status) { + case TaskStatus.started: + title = "Document received"; + body = task.taskFileName; + timestampMillis = task.dateCreated.millisecondsSinceEpoch; + progress = 10; + break; + case TaskStatus.pending: + title = "Processing document..."; + body = task.taskFileName; + timestampMillis = task.dateCreated.millisecondsSinceEpoch; + progress = 70; + break; + case TaskStatus.failure: + title = "Failed to process document"; + body = "Document ${task.taskFileName} was rejected by the server."; + timestampMillis = task.dateCreated.millisecondsSinceEpoch; + break; + case TaskStatus.success: + title = "Document successfully created"; + body = task.taskFileName; + timestampMillis = task.dateDone!.millisecondsSinceEpoch; + payload = CreateDocumentSuccessNotificationResponsePayload( + task.relatedDocumentId!, + ); + break; + default: + break; + } + return _plugin.show( + id, + title, + body, + NotificationDetails( + android: AndroidNotificationDetails( + '${NotificationChannel.task.id}_${task.id}', + NotificationChannel.task.name, + category: AndroidNotificationCategory.status, + ongoing: showProgress, + showProgress: showProgress, + maxProgress: 100, + when: timestampMillis, + progress: progress, + actions: status == TaskStatus.success + ? [ + AndroidNotificationAction( + NotificationResponseAction.openCreatedDocument.name, + "Open", + showsUserInterface: true, + ), + AndroidNotificationAction( + NotificationResponseAction.acknowledgeCreatedDocument.name, + "Acknowledge", + ), + ] + : [], + ), + ), + payload: jsonEncode(payload), + ); + } + + void onDidReceiveLocalNotification( + int id, + String? title, + String? body, + String? payload, + ) {} + + void onDidReceiveNotificationResponse(NotificationResponse response) { + log("Received Notification: ${response.payload}"); + if (response.notificationResponseType == + NotificationResponseType.selectedNotificationAction) { + final action = + NotificationResponseAction.values.byName(response.actionId!); + _handleResponseAction(action, response); + } + // Non-actionable notification pressed, ignoring... + } + + void _handleResponseAction( + NotificationResponseAction action, + NotificationResponse response, + ) { + switch (action) { + case NotificationResponseAction.openCreatedDocument: + final payload = + CreateDocumentSuccessNotificationResponsePayload.fromJson( + jsonDecode(response.payload!), + ); + log("Navigate to document ${payload.documentId}"); + break; + case NotificationResponseAction.acknowledgeCreatedDocument: + final payload = + CreateDocumentSuccessNotificationResponsePayload.fromJson( + jsonDecode(response.payload!), + ); + log("Acknowledge document ${payload.documentId}"); + break; + } + } +} diff --git a/lib/features/notifications/services/models/notification_payloads/open_created_document_notification_payload.dart b/lib/features/notifications/services/models/notification_payloads/open_created_document_notification_payload.dart new file mode 100644 index 0000000..3c7999a --- /dev/null +++ b/lib/features/notifications/services/models/notification_payloads/open_created_document_notification_payload.dart @@ -0,0 +1,17 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'open_created_document_notification_payload.g.dart'; + +@JsonSerializable() +class CreateDocumentSuccessNotificationResponsePayload { + final int documentId; + + CreateDocumentSuccessNotificationResponsePayload(this.documentId); + + factory CreateDocumentSuccessNotificationResponsePayload.fromJson( + Map json) => + _$CreateDocumentSuccessNotificationResponsePayloadFromJson(json); + + Map toJson() => + _$CreateDocumentSuccessNotificationResponsePayloadToJson(this); +} diff --git a/lib/features/notifications/services/models/notification_payloads/open_created_document_notification_payload.g.dart b/lib/features/notifications/services/models/notification_payloads/open_created_document_notification_payload.g.dart new file mode 100644 index 0000000..deda61f --- /dev/null +++ b/lib/features/notifications/services/models/notification_payloads/open_created_document_notification_payload.g.dart @@ -0,0 +1,20 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'open_created_document_notification_payload.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +CreateDocumentSuccessNotificationResponsePayload + _$CreateDocumentSuccessNotificationResponsePayloadFromJson( + Map json) => + CreateDocumentSuccessNotificationResponsePayload( + json['documentId'] as int, + ); + +Map _$CreateDocumentSuccessNotificationResponsePayloadToJson( + CreateDocumentSuccessNotificationResponsePayload instance) => + { + 'documentId': instance.documentId, + }; diff --git a/lib/features/notifications/services/notification_actions.dart b/lib/features/notifications/services/notification_actions.dart new file mode 100644 index 0000000..91717c0 --- /dev/null +++ b/lib/features/notifications/services/notification_actions.dart @@ -0,0 +1,4 @@ +enum NotificationResponseAction { + openCreatedDocument, + acknowledgeCreatedDocument; +} diff --git a/lib/features/notifications/services/notification_channels.dart b/lib/features/notifications/services/notification_channels.dart new file mode 100644 index 0000000..dea0214 --- /dev/null +++ b/lib/features/notifications/services/notification_channels.dart @@ -0,0 +1,8 @@ +enum NotificationChannel { + task("task_channel", "Paperless Tasks"); + + final String id; + final String name; + + const NotificationChannel(this.id, this.name); +} diff --git a/lib/features/tasks/cubit/task_status_cubit.dart b/lib/features/tasks/cubit/task_status_cubit.dart new file mode 100644 index 0000000..0bad389 --- /dev/null +++ b/lib/features/tasks/cubit/task_status_cubit.dart @@ -0,0 +1,26 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:paperless_api/paperless_api.dart'; +part 'task_status_state.dart'; + +class TaskStatusCubit extends Cubit { + final PaperlessTasksApi _api; + TaskStatusCubit(this._api) : super(const TaskStatusState()); + + void startListeningToTask(String taskId) { + _api + .listenForTaskChanges(taskId) + .forEach( + (element) => TaskStatusState( + isListening: true, + isAcknowledged: false, + task: element, + ), + ) + .whenComplete(() => emit(state.copyWith(isListening: false))); + } + + void acknowledgeCurrentTask() { + emit(state.copyWith(isListening: false, isAcknowledged: true)); + } +} diff --git a/lib/features/tasks/cubit/task_status_state.dart b/lib/features/tasks/cubit/task_status_state.dart new file mode 100644 index 0000000..6e69901 --- /dev/null +++ b/lib/features/tasks/cubit/task_status_state.dart @@ -0,0 +1,34 @@ +part of 'task_status_cubit.dart'; + +class TaskStatusState extends Equatable { + final Task? task; + final bool isListening; + final bool isAcknowledged; + + const TaskStatusState({ + this.task, + this.isListening = false, + this.isAcknowledged = false, + }); + + bool get isActive => isListening && !isAcknowledged; + + bool get isSuccess => task?.status == TaskStatus.success; + + String? get taskId => task?.taskId; + + @override + List get props => []; + + TaskStatusState copyWith({ + Task? task, + bool? isListening, + bool? isAcknowledged, + }) { + return TaskStatusState( + task: task ?? this.task, + isListening: isListening ?? this.isListening, + isAcknowledged: isAcknowledged ?? this.isAcknowledged, + ); + } +} diff --git a/lib/features/tasks/cubit/tasks_cubit.dart b/lib/features/tasks/cubit/tasks_cubit.dart new file mode 100644 index 0000000..a5056f3 --- /dev/null +++ b/lib/features/tasks/cubit/tasks_cubit.dart @@ -0,0 +1,8 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; + +part 'tasks_state.dart'; + +class TasksCubit extends Cubit { + TasksCubit() : super(TasksInitial()); +} diff --git a/lib/features/tasks/cubit/tasks_state.dart b/lib/features/tasks/cubit/tasks_state.dart new file mode 100644 index 0000000..d4ae6d8 --- /dev/null +++ b/lib/features/tasks/cubit/tasks_state.dart @@ -0,0 +1,10 @@ +part of 'tasks_cubit.dart'; + +abstract class TasksState extends Equatable { + const TasksState(); + + @override + List get props => []; +} + +class TasksInitial extends TasksState {} diff --git a/lib/main.dart b/lib/main.dart index 15dcc3f..64ddbeb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -39,9 +39,11 @@ import 'package:paperless_mobile/features/login/bloc/authentication_cubit.dart'; import 'package:paperless_mobile/features/login/bloc/authentication_state.dart'; import 'package:paperless_mobile/features/login/services/authentication_service.dart'; import 'package:paperless_mobile/features/login/view/login_page.dart'; +import 'package:paperless_mobile/features/notifications/services/local_notification_service.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/sharing/share_intent_queue.dart'; +import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.dart'; import 'package:paperless_mobile/generated/l10n.dart'; import 'package:path_provider/path_provider.dart'; import 'package:pretty_dio_logger/pretty_dio_logger.dart'; @@ -53,6 +55,7 @@ void main() async { final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); await findSystemLocale(); + await LocalNotificationService.instance.initialize(); // Initialize External dependencies final connectivity = Connectivity(); @@ -95,6 +98,7 @@ void main() async { final labelsApi = PaperlessLabelApiImpl(dioWrapper.client); final statsApi = PaperlessServerStatsApiImpl(dioWrapper.client); final savedViewsApi = PaperlessSavedViewsApiImpl(dioWrapper.client); + final tasksApi = PaperlessTasksApiImpl(dioWrapper.client); // Initialize Blocs/Cubits final connectivityCubit = ConnectivityCubit(connectivityStatusService); @@ -140,6 +144,7 @@ void main() async { Provider.value(value: labelsApi), Provider.value(value: statsApi), Provider.value(value: savedViewsApi), + Provider.value(value: tasksApi), Provider( create: (context) => cm.CacheManager( cm.Config( @@ -328,7 +333,11 @@ class _AuthenticationWrapperState extends State { builder: (context, authentication) { if (authentication.isAuthenticated && (authentication.wasLocalAuthenticationSuccessful ?? true)) { - return const HomePage(); + return BlocProvider( + create: (context) => + TaskStatusCubit(context.read()), + child: const HomePage(), + ); } else { if (authentication.wasLoginStored && !(authentication.wasLocalAuthenticationSuccessful ?? false)) { diff --git a/packages/paperless_api/lib/src/models/models.dart b/packages/paperless_api/lib/src/models/models.dart index 5d39de9..a270fd0 100644 --- a/packages/paperless_api/lib/src/models/models.dart +++ b/packages/paperless_api/lib/src/models/models.dart @@ -22,3 +22,5 @@ export 'paperless_server_information_model.dart'; export 'paperless_server_statistics_model.dart'; export 'saved_view_model.dart'; export 'similar_document_model.dart'; +export 'task/task.dart'; +export 'task/task_status.dart'; diff --git a/packages/paperless_api/lib/src/models/task/task.dart b/packages/paperless_api/lib/src/models/task/task.dart new file mode 100644 index 0000000..28c6e93 --- /dev/null +++ b/packages/paperless_api/lib/src/models/task/task.dart @@ -0,0 +1,52 @@ +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:paperless_api/src/request_utils.dart'; +import 'task_status.dart'; + +part 'task.g.dart'; + +@JsonSerializable(fieldRename: FieldRename.snake) +class Task extends Equatable { + final int id; + final String? taskId; + final String? taskFileName; + final DateTime dateCreated; + final DateTime? dateDone; + final String? type; + final TaskStatus? status; + final String? result; + final bool acknowledged; + @JsonKey(fromJson: tryParseNullable) + final int? relatedDocumentId; + + const Task({ + required this.id, + this.taskId, + this.taskFileName, + required this.dateCreated, + this.dateDone, + this.type, + this.status, + this.acknowledged = false, + this.relatedDocumentId, + this.result, + }); + + factory Task.fromJson(Map json) => _$TaskFromJson(json); + + Map toJson() => _$TaskToJson(this); + + @override + List get props => [ + id, + taskId, + taskFileName, + dateCreated, + dateDone, + type, + status, + result, + acknowledged, + relatedDocumentId, + ]; +} diff --git a/packages/paperless_api/lib/src/models/task/task.g.dart b/packages/paperless_api/lib/src/models/task/task.g.dart new file mode 100644 index 0000000..fb1b969 --- /dev/null +++ b/packages/paperless_api/lib/src/models/task/task.g.dart @@ -0,0 +1,43 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'task.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Task _$TaskFromJson(Map json) => Task( + id: json['id'] as int, + taskId: json['task_id'] as String?, + taskFileName: json['task_file_name'] as String?, + dateCreated: DateTime.parse(json['date_created'] as String), + dateDone: json['date_done'] == null + ? null + : DateTime.parse(json['date_done'] as String), + type: json['type'] as String?, + status: $enumDecodeNullable(_$TaskStatusEnumMap, json['status']), + acknowledged: json['acknowledged'] as bool? ?? false, + relatedDocumentId: + tryParseNullable(json['related_document_id'] as String?), + result: json['result'] as String?, + ); + +Map _$TaskToJson(Task instance) => { + 'id': instance.id, + 'task_id': instance.taskId, + 'task_file_name': instance.taskFileName, + 'date_created': instance.dateCreated.toIso8601String(), + 'date_done': instance.dateDone?.toIso8601String(), + 'type': instance.type, + 'status': _$TaskStatusEnumMap[instance.status], + 'result': instance.result, + 'acknowledged': instance.acknowledged, + 'related_document_id': instance.relatedDocumentId, + }; + +const _$TaskStatusEnumMap = { + TaskStatus.started: 'STARTED', + TaskStatus.pending: 'PENDING', + TaskStatus.failure: 'FAILURE', + TaskStatus.success: 'SUCCESS', +}; diff --git a/packages/paperless_api/lib/src/models/task/task_status.dart b/packages/paperless_api/lib/src/models/task/task_status.dart new file mode 100644 index 0000000..8a8a875 --- /dev/null +++ b/packages/paperless_api/lib/src/models/task/task_status.dart @@ -0,0 +1,13 @@ +import 'package:json_annotation/json_annotation.dart'; + +@JsonEnum(valueField: 'value') +enum TaskStatus { + started("STARTED"), + pending("PENDING"), + failure("FAILURE"), + success("SUCCESS"); + + final String value; + + const TaskStatus(this.value); +} diff --git a/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api.dart b/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api.dart index 829e58b..b5936de 100644 --- a/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api.dart +++ b/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api.dart @@ -8,7 +8,9 @@ import 'package:paperless_api/src/models/paged_search_result.dart'; import 'package:paperless_api/src/models/similar_document_model.dart'; abstract class PaperlessDocumentsApi { - Future create( + /// Uploads a document using a form data request and from server version 1.11.3 + /// returns the celery task id which can be used to track the status of the document. + Future create( Uint8List documentBytes, { required String filename, required String title, @@ -27,7 +29,9 @@ abstract class PaperlessDocumentsApi { Future getPreview(int docId); String getThumbnailUrl(int docId); Future waitForConsumptionFinished( - String filename, String title); + String filename, + String title, + ); Future download(DocumentModel document); Future> autocomplete(String query, [int limit = 10]); diff --git a/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api_impl.dart b/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api_impl.dart index e8cc60f..6068007 100644 --- a/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api_impl.dart +++ b/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api_impl.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; @@ -6,6 +7,7 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/src/constants.dart'; import 'package:paperless_api/src/converters/document_model_json_converter.dart'; import 'package:paperless_api/src/converters/similar_document_model_json_converter.dart'; +import 'package:paperless_api/src/request_utils.dart'; class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi { final Dio client; @@ -13,7 +15,7 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi { PaperlessDocumentsApiImpl(this.client); @override - Future create( + Future create( Uint8List documentBytes, { required String filename, required String title, @@ -46,8 +48,12 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi { try { final response = await client.post('/api/documents/post_document/', data: formData); - - if (response.statusCode != 200) { + if (response.statusCode == 200) { + if (response.data is String && response.data != "OK") { + return response.data; + } + return null; + } else { throw PaperlessServerException( ErrorCode.documentUploadFailed, httpStatusCode: response.statusCode, diff --git a/packages/paperless_api/lib/src/modules/modules.dart b/packages/paperless_api/lib/src/modules/modules.dart index 1343d89..8f98dd6 100644 --- a/packages/paperless_api/lib/src/modules/modules.dart +++ b/packages/paperless_api/lib/src/modules/modules.dart @@ -9,3 +9,5 @@ export 'saved_views_api/paperless_saved_views_api.dart'; export 'saved_views_api/paperless_saved_views_api_impl.dart'; export 'server_stats_api/paperless_server_stats_api.dart'; export 'server_stats_api/paperless_server_stats_api_impl.dart'; +export 'tasks_api/paperless_tasks_api.dart'; +export 'tasks_api/paperless_tasks_api_impl.dart'; diff --git a/packages/paperless_api/lib/src/modules/tasks_api/paperless_tasks_api.dart b/packages/paperless_api/lib/src/modules/tasks_api/paperless_tasks_api.dart new file mode 100644 index 0000000..cdb20a6 --- /dev/null +++ b/packages/paperless_api/lib/src/modules/tasks_api/paperless_tasks_api.dart @@ -0,0 +1,7 @@ +import 'package:paperless_api/src/models/task/task.dart'; + +abstract class PaperlessTasksApi { + Future find({int? id, String? taskId}); + Future> findAll([Iterable? ids]); + Stream listenForTaskChanges(String taskId); +} diff --git a/packages/paperless_api/lib/src/modules/tasks_api/paperless_tasks_api_impl.dart b/packages/paperless_api/lib/src/modules/tasks_api/paperless_tasks_api_impl.dart new file mode 100644 index 0000000..2a700b8 --- /dev/null +++ b/packages/paperless_api/lib/src/modules/tasks_api/paperless_tasks_api_impl.dart @@ -0,0 +1,53 @@ +import 'package:dio/dio.dart'; +import 'package:paperless_api/src/models/task/task.dart'; +import 'package:paperless_api/src/models/task/task_status.dart'; + +import 'paperless_tasks_api.dart'; + +class PaperlessTasksApiImpl implements PaperlessTasksApi { + final Dio client; + + const PaperlessTasksApiImpl(this.client); + + @override + Future find({int? id, String? taskId}) async { + assert(id != null || taskId != null); + String url = "/api/tasks/"; + if (taskId != null) { + url += "?task_id=$taskId"; + } else { + url += "$id/"; + } + + final response = await client.get(url); + if (response.statusCode == 200) { + return Task.fromJson(response.data); + } + return null; + } + + @override + Future> findAll([Iterable? ids]) async { + final response = await client.get("/api/tasks/"); + if (response.statusCode == 200) { + return (response.data as List).map((e) => Task.fromJson(e)); + } + return []; + } + + @override + Stream listenForTaskChanges(String taskId) async* { + bool isSuccess = false; + while (!isSuccess) { + final task = await find(taskId: taskId); + if (task == null) { + throw Exception("Task with taskId $taskId does not exist."); + } + yield task; + if (task.status == TaskStatus.success) { + isSuccess = true; + } + await Future.delayed(const Duration(seconds: 1)); + } + } +} diff --git a/packages/paperless_api/lib/src/request_utils.dart b/packages/paperless_api/lib/src/request_utils.dart index 5ff0578..12add18 100644 --- a/packages/paperless_api/lib/src/request_utils.dart +++ b/packages/paperless_api/lib/src/request_utils.dart @@ -81,3 +81,14 @@ class _CollectionFromJsonSerializationParams { _CollectionFromJsonSerializationParams(this.fromJson, this.list); } + +int getExtendedVersionNumber(String version) { + List versionCells = version.split('.'); + versionCells = versionCells.map((i) => int.parse(i)).toList(); + return versionCells[0] * 100000 + versionCells[1] * 1000 + versionCells[2]; +} + +int? tryParseNullable(String? source, {int? radix}) { + if (source == null) return null; + return int.tryParse(source, radix: radix); +} diff --git a/pubspec.lock b/pubspec.lock index 3550036..7d7de2b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -588,6 +588,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "293995f94e120c8afce768981bd1fa9c5d6de67c547568e3b42ae2defdcbb4a0" + url: "https://pub.dev" + source: hosted + version: "13.0.0" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: "8f6c1611e0c4a88a382691a97bb3c3feb24cc0c0b54152b8b5fb7ffb837f7fbf" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "5ec1feac5f7f7d9266759488bc5f76416152baba9aa1b26fe572246caa00d1ab" + url: "https://pub.dev" + source: hosted + version: "6.0.0" flutter_localizations: dependency: "direct main" description: flutter @@ -1577,6 +1601,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.21" + timezone: + dependency: transitive + description: + name: timezone + sha256: "24c8fcdd49a805d95777a39064862133ff816ebfffe0ceff110fb5960e557964" + url: "https://pub.dev" + source: hosted + version: "0.9.1" timing: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index df9e709..079512b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -84,6 +84,7 @@ dependencies: pretty_dio_logger: ^1.2.0-beta-1 collection: ^1.17.0 device_info_plus: ^4.1.3 + flutter_local_notifications: ^13.0.0 dev_dependencies: integration_test: