WIP - Add system notifications for document upload progress/status

This commit is contained in:
Anton Stubenbord
2023-01-09 01:35:47 +01:00
parent 3c6c4e63d7
commit 8cf3020335
34 changed files with 615 additions and 44 deletions

View File

@@ -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;

View File

@@ -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<HomePage> {
@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<HomePage> {
}
Future<void> _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<bool>(
// 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<HomePage> {
@override
Widget build(BuildContext context) {
return BlocListener<ConnectivityCubit, ConnectivityState>(
//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<ConnectivityCubit, ConnectivityState>(
//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<TaskStatusCubit, TaskStatusState>(
listener: (context, state) {},
),
],
child: Scaffold(
key: rootScaffoldKey,
bottomNavigationBar: BottomNavBar(

View File

@@ -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<NotificationState> {
NotificationCubit() : super(NotificationInitialState());
void navigateTo(String route, dynamic args) {}
}

View File

@@ -0,0 +1,16 @@
part of 'notification_cubit.dart';
abstract class NotificationState extends Equatable {
const NotificationState();
@override
List<Object> get props => [];
}
class NotificationInitialState extends NotificationState {}
class NotificationOpenDocumentDetailsPageState extends NotificationState {
final int documentId;
const NotificationOpenDocumentDetailsPageState(this.documentId);
}

View File

@@ -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<void> 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<void> 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;
}
}
}

View File

@@ -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<String, dynamic> json) =>
_$CreateDocumentSuccessNotificationResponsePayloadFromJson(json);
Map<String, dynamic> toJson() =>
_$CreateDocumentSuccessNotificationResponsePayloadToJson(this);
}

View File

@@ -0,0 +1,20 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'open_created_document_notification_payload.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
CreateDocumentSuccessNotificationResponsePayload
_$CreateDocumentSuccessNotificationResponsePayloadFromJson(
Map<String, dynamic> json) =>
CreateDocumentSuccessNotificationResponsePayload(
json['documentId'] as int,
);
Map<String, dynamic> _$CreateDocumentSuccessNotificationResponsePayloadToJson(
CreateDocumentSuccessNotificationResponsePayload instance) =>
<String, dynamic>{
'documentId': instance.documentId,
};

View File

@@ -0,0 +1,4 @@
enum NotificationResponseAction {
openCreatedDocument,
acknowledgeCreatedDocument;
}

View File

@@ -0,0 +1,8 @@
enum NotificationChannel {
task("task_channel", "Paperless Tasks");
final String id;
final String name;
const NotificationChannel(this.id, this.name);
}

View File

@@ -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<TaskStatusState> {
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));
}
}

View File

@@ -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<Object> get props => [];
TaskStatusState copyWith({
Task? task,
bool? isListening,
bool? isAcknowledged,
}) {
return TaskStatusState(
task: task ?? this.task,
isListening: isListening ?? this.isListening,
isAcknowledged: isAcknowledged ?? this.isAcknowledged,
);
}
}

View File

@@ -0,0 +1,8 @@
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
part 'tasks_state.dart';
class TasksCubit extends Cubit<TasksState> {
TasksCubit() : super(TasksInitial());
}

View File

@@ -0,0 +1,10 @@
part of 'tasks_cubit.dart';
abstract class TasksState extends Equatable {
const TasksState();
@override
List<Object> get props => [];
}
class TasksInitial extends TasksState {}