Notification screen

This commit is contained in:
LittleSheep 2024-11-23 16:55:23 +08:00
parent 63499df99f
commit 85a1dd3053
9 changed files with 792 additions and 3 deletions

View File

@ -22,6 +22,7 @@
"screenRealm": "Realm",
"screenRealmManage": "Edit Realm",
"screenRealmNew": "New Realm",
"screenNotification": "Notification",
"dialogOkay": "Okay",
"dialogCancel": "Cancel",
"dialogConfirm": "Confirm",
@ -174,5 +175,15 @@
},
"addAttachmentFromAlbum": "Add from album",
"addAttachmentFromClipboard": "Paste file",
"attachmentPastedImage": "Pasted Image"
"attachmentPastedImage": "Pasted Image",
"notificationUnread": "未读",
"notificationRead": "已读",
"notificationMarkAllRead": "Mark all notifications as read",
"notificationMarkAllReadDescription": "Are you sure you want to mark all notifications as read? This operation is irreversible.",
"notificationMarkAllReadPrompt": {
"zero": "Marked 0 notification as read.",
"one": "Marked {} notification as read.",
"other": "Marked {} notifications as read."
},
"notificationMarkOneReadPrompt": "Marked notification {} as read."
}

View File

@ -22,6 +22,7 @@
"screenRealm": "领域",
"screenRealmManage": "编辑领域",
"screenRealmNew": "新建领域",
"screenNotification": "通知",
"dialogOkay": "好的",
"dialogCancel": "取消",
"dialogConfirm": "确认",
@ -174,5 +175,15 @@
},
"addAttachmentFromAlbum": "从相册中添加附件",
"addAttachmentFromClipboard": "粘贴附件",
"attachmentPastedImage" : "粘贴的图片"
"attachmentPastedImage" : "粘贴的图片",
"notificationUnread": "未读",
"notificationRead": "已读",
"notificationMarkAllRead": "已读所有通知",
"notificationMarkAllReadDescription": "您确定要将所有通知设置为已读吗?该操作不可撤销。",
"notificationMarkAllReadPrompt": {
"zero": "已将 0 个通知标记为已读。",
"one": "已将 {} 个通知标记为已读。",
"other": "已将 {} 个通知标记为已读。"
},
"notificationMarkOneReadPrompt": "已将通知 {} 标记为已读。"
}

View File

@ -63,6 +63,11 @@ class NavigationProvider extends ChangeNotifier {
screen: 'album',
label: 'screenAlbum',
),
AppNavDestination(
icon: Icon(Symbols.notifications, weight: 400, opticalSize: 20),
screen: 'notification',
label: 'screenNotification',
),
];
static const List<String> kDefaultPinnedDestination = [
'home',

View File

@ -14,6 +14,7 @@ import 'package:surface/screens/chat/manage.dart';
import 'package:surface/screens/chat/room.dart';
import 'package:surface/screens/explore.dart';
import 'package:surface/screens/home.dart';
import 'package:surface/screens/notification.dart';
import 'package:surface/screens/post/post_detail.dart';
import 'package:surface/screens/post/post_editor.dart';
import 'package:surface/screens/realm.dart';
@ -175,6 +176,13 @@ final _appRoutes = [
child: const AlbumScreen(),
),
),
GoRoute(
path: '/notification',
name: 'notification',
pageBuilder: (context, state) => NoTransitionPage(
child: const NotificationScreen(),
),
),
],
),
ShellRoute(

View File

@ -0,0 +1,244 @@
import 'dart:math' as math;
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:relative_time/relative_time.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/notification.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/markdown_content.dart';
import 'package:surface/widgets/post/post_item.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class NotificationScreen extends StatefulWidget {
const NotificationScreen({super.key});
@override
State<NotificationScreen> createState() => _NotificationScreenState();
}
class _NotificationScreenState extends State<NotificationScreen> {
bool _isBusy = false;
bool _isFirstLoading = true;
bool _isSubmitting = false;
final List<SnNotification> _notifications = List.empty(growable: true);
int? _totalCount;
static const Map<String, IconData> kNotificationTopicIcons = {
'passport.security.alert': Symbols.gpp_maybe,
'interactive.subscription': Symbols.subscriptions,
'interactive.feedback': Symbols.add_reaction,
'messaging.callStart': Symbols.call_received,
};
Future<void> _fetchNotifications() async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/notifications?take=10');
_totalCount = resp.data['count'];
_notifications.addAll(
resp.data['data']
?.map((e) => SnNotification.fromJson(e))
.cast<SnNotification>() ??
[],
);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
_isFirstLoading = false;
setState(() => _isBusy = false);
}
}
void _markAllAsRead() async {
if (_notifications.isEmpty) return;
final confirm = await context.showConfirmDialog(
'notificationMarkAllRead'.tr(),
'notificationMarkAllReadDescription'.tr(),
);
if (!confirm) return;
if (!mounted) return;
setState(() => _isSubmitting = true);
List<int> markList = List.empty(growable: true);
for (final element in _notifications) {
if (element.id <= 0) continue;
if (element.readAt != null) continue;
markList.add(element.id);
}
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.put('/cgi/id/notifications/read', data: {
'messages': markList,
});
_notifications.clear();
_fetchNotifications();
if (!mounted) return;
context.showSnackbar(
'notificationMarkAllReadPrompt'.plural(markList.length),
);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isSubmitting = false);
}
}
void _markOneAsRead(SnNotification notification) async {
if (notification.readAt != null) return;
setState(() => _isSubmitting = true);
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.put('/cgi/id/notifications/read/${notification.id}');
_notifications.clear();
_fetchNotifications();
if (!mounted) return;
context.showSnackbar(
'notificationMarkOneReadPrompt'.tr(args: ['#${notification.id}']),
);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isSubmitting = false);
}
}
@override
void initState() {
super.initState();
_fetchNotifications();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('screenNotification').tr(),
actions: [
IconButton(
icon: const Icon(Icons.checklist),
onPressed: _isSubmitting ? null : _markAllAsRead,
),
],
),
body: Column(
children: [
LoadingIndicator(isActive: _isFirstLoading),
Expanded(
child: RefreshIndicator(
onRefresh: () {
_notifications.clear();
return _fetchNotifications();
},
child: InfiniteList(
padding: EdgeInsets.only(
top: 16,
bottom: math.max(MediaQuery.of(context).padding.bottom, 16),
),
itemCount: _notifications.length,
onFetchData: () {
_fetchNotifications();
},
isLoading: _isBusy,
hasReachedMax: _totalCount != null &&
_notifications.length >= _totalCount!,
itemBuilder: (context, idx) {
final nty = _notifications[idx];
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(kNotificationTopicIcons[nty.topic]),
const Gap(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (nty.readAt == null)
StyledWidget(Badge(
label: Text('notificationUnread').tr(),
)).padding(bottom: 4),
Text(
nty.title,
style: Theme.of(context).textTheme.titleMedium,
),
if (nty.subtitle != null)
Text(
nty.subtitle!,
style: Theme.of(context).textTheme.titleSmall,
),
if (nty.subtitle != null) const Gap(4),
MarkdownTextContent(
content: nty.body,
isAutoWarp: true,
isSelectable: true,
),
if ([
'interactive.feedback',
'interactive.subscription'
].contains(nty.topic) &&
nty.metadata['related_post'] != null)
PostItem(
data: SnPost.fromJson(
nty.metadata['related_post']!,
),
),
const Gap(8),
Row(
children: [
Text(
DateFormat('yy/MM/dd').format(nty.createdAt),
).fontSize(12),
const Gap(4),
Text(
'·',
style: TextStyle(fontSize: 12),
),
const Gap(4),
Text(
RelativeTime(context).format(nty.createdAt),
).fontSize(12),
],
).opacity(0.75),
],
),
),
const Gap(16),
IconButton(
icon: const Icon(Symbols.check),
padding: EdgeInsets.all(0),
visualDensity:
const VisualDensity(horizontal: -4, vertical: -4),
onPressed:
_isSubmitting ? null : () => _markOneAsRead(nty),
),
],
).padding(horizontal: 16);
},
separatorBuilder: (_, __) => const Divider(),
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,26 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'notification.freezed.dart';
part 'notification.g.dart';
@freezed
class SnNotification with _$SnNotification {
const factory SnNotification({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required String topic,
required String title,
required String? subtitle,
required String body,
@Default({}) Map<String, dynamic> metadata,
required int priority,
required int? senderId,
required int accountId,
required DateTime? readAt,
}) = _SnNotification;
factory SnNotification.fromJson(Map<String, dynamic> json) =>
_$SnNotificationFromJson(json);
}

View File

@ -0,0 +1,438 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'notification.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
SnNotification _$SnNotificationFromJson(Map<String, dynamic> json) {
return _SnNotification.fromJson(json);
}
/// @nodoc
mixin _$SnNotification {
int get id => throw _privateConstructorUsedError;
DateTime get createdAt => throw _privateConstructorUsedError;
DateTime get updatedAt => throw _privateConstructorUsedError;
DateTime? get deletedAt => throw _privateConstructorUsedError;
String get topic => throw _privateConstructorUsedError;
String get title => throw _privateConstructorUsedError;
String? get subtitle => throw _privateConstructorUsedError;
String get body => throw _privateConstructorUsedError;
Map<String, dynamic> get metadata => throw _privateConstructorUsedError;
int get priority => throw _privateConstructorUsedError;
int? get senderId => throw _privateConstructorUsedError;
int get accountId => throw _privateConstructorUsedError;
DateTime? get readAt => throw _privateConstructorUsedError;
/// Serializes this SnNotification to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of SnNotification
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$SnNotificationCopyWith<SnNotification> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $SnNotificationCopyWith<$Res> {
factory $SnNotificationCopyWith(
SnNotification value, $Res Function(SnNotification) then) =
_$SnNotificationCopyWithImpl<$Res, SnNotification>;
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
String topic,
String title,
String? subtitle,
String body,
Map<String, dynamic> metadata,
int priority,
int? senderId,
int accountId,
DateTime? readAt});
}
/// @nodoc
class _$SnNotificationCopyWithImpl<$Res, $Val extends SnNotification>
implements $SnNotificationCopyWith<$Res> {
_$SnNotificationCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of SnNotification
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? topic = null,
Object? title = null,
Object? subtitle = freezed,
Object? body = null,
Object? metadata = null,
Object? priority = null,
Object? senderId = freezed,
Object? accountId = null,
Object? readAt = freezed,
}) {
return _then(_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
topic: null == topic
? _value.topic
: topic // ignore: cast_nullable_to_non_nullable
as String,
title: null == title
? _value.title
: title // ignore: cast_nullable_to_non_nullable
as String,
subtitle: freezed == subtitle
? _value.subtitle
: subtitle // ignore: cast_nullable_to_non_nullable
as String?,
body: null == body
? _value.body
: body // ignore: cast_nullable_to_non_nullable
as String,
metadata: null == metadata
? _value.metadata
: metadata // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>,
priority: null == priority
? _value.priority
: priority // ignore: cast_nullable_to_non_nullable
as int,
senderId: freezed == senderId
? _value.senderId
: senderId // ignore: cast_nullable_to_non_nullable
as int?,
accountId: null == accountId
? _value.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
readAt: freezed == readAt
? _value.readAt
: readAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
) as $Val);
}
}
/// @nodoc
abstract class _$$SnNotificationImplCopyWith<$Res>
implements $SnNotificationCopyWith<$Res> {
factory _$$SnNotificationImplCopyWith(_$SnNotificationImpl value,
$Res Function(_$SnNotificationImpl) then) =
__$$SnNotificationImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
String topic,
String title,
String? subtitle,
String body,
Map<String, dynamic> metadata,
int priority,
int? senderId,
int accountId,
DateTime? readAt});
}
/// @nodoc
class __$$SnNotificationImplCopyWithImpl<$Res>
extends _$SnNotificationCopyWithImpl<$Res, _$SnNotificationImpl>
implements _$$SnNotificationImplCopyWith<$Res> {
__$$SnNotificationImplCopyWithImpl(
_$SnNotificationImpl _value, $Res Function(_$SnNotificationImpl) _then)
: super(_value, _then);
/// Create a copy of SnNotification
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? topic = null,
Object? title = null,
Object? subtitle = freezed,
Object? body = null,
Object? metadata = null,
Object? priority = null,
Object? senderId = freezed,
Object? accountId = null,
Object? readAt = freezed,
}) {
return _then(_$SnNotificationImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
topic: null == topic
? _value.topic
: topic // ignore: cast_nullable_to_non_nullable
as String,
title: null == title
? _value.title
: title // ignore: cast_nullable_to_non_nullable
as String,
subtitle: freezed == subtitle
? _value.subtitle
: subtitle // ignore: cast_nullable_to_non_nullable
as String?,
body: null == body
? _value.body
: body // ignore: cast_nullable_to_non_nullable
as String,
metadata: null == metadata
? _value._metadata
: metadata // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>,
priority: null == priority
? _value.priority
: priority // ignore: cast_nullable_to_non_nullable
as int,
senderId: freezed == senderId
? _value.senderId
: senderId // ignore: cast_nullable_to_non_nullable
as int?,
accountId: null == accountId
? _value.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
readAt: freezed == readAt
? _value.readAt
: readAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
));
}
}
/// @nodoc
@JsonSerializable()
class _$SnNotificationImpl implements _SnNotification {
const _$SnNotificationImpl(
{required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.topic,
required this.title,
required this.subtitle,
required this.body,
final Map<String, dynamic> metadata = const {},
required this.priority,
required this.senderId,
required this.accountId,
required this.readAt})
: _metadata = metadata;
factory _$SnNotificationImpl.fromJson(Map<String, dynamic> json) =>
_$$SnNotificationImplFromJson(json);
@override
final int id;
@override
final DateTime createdAt;
@override
final DateTime updatedAt;
@override
final DateTime? deletedAt;
@override
final String topic;
@override
final String title;
@override
final String? subtitle;
@override
final String body;
final Map<String, dynamic> _metadata;
@override
@JsonKey()
Map<String, dynamic> get metadata {
if (_metadata is EqualUnmodifiableMapView) return _metadata;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_metadata);
}
@override
final int priority;
@override
final int? senderId;
@override
final int accountId;
@override
final DateTime? readAt;
@override
String toString() {
return 'SnNotification(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, topic: $topic, title: $title, subtitle: $subtitle, body: $body, metadata: $metadata, priority: $priority, senderId: $senderId, accountId: $accountId, readAt: $readAt)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$SnNotificationImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt) &&
(identical(other.updatedAt, updatedAt) ||
other.updatedAt == updatedAt) &&
(identical(other.deletedAt, deletedAt) ||
other.deletedAt == deletedAt) &&
(identical(other.topic, topic) || other.topic == topic) &&
(identical(other.title, title) || other.title == title) &&
(identical(other.subtitle, subtitle) ||
other.subtitle == subtitle) &&
(identical(other.body, body) || other.body == body) &&
const DeepCollectionEquality().equals(other._metadata, _metadata) &&
(identical(other.priority, priority) ||
other.priority == priority) &&
(identical(other.senderId, senderId) ||
other.senderId == senderId) &&
(identical(other.accountId, accountId) ||
other.accountId == accountId) &&
(identical(other.readAt, readAt) || other.readAt == readAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
createdAt,
updatedAt,
deletedAt,
topic,
title,
subtitle,
body,
const DeepCollectionEquality().hash(_metadata),
priority,
senderId,
accountId,
readAt);
/// Create a copy of SnNotification
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$SnNotificationImplCopyWith<_$SnNotificationImpl> get copyWith =>
__$$SnNotificationImplCopyWithImpl<_$SnNotificationImpl>(
this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$SnNotificationImplToJson(
this,
);
}
}
abstract class _SnNotification implements SnNotification {
const factory _SnNotification(
{required final int id,
required final DateTime createdAt,
required final DateTime updatedAt,
required final DateTime? deletedAt,
required final String topic,
required final String title,
required final String? subtitle,
required final String body,
final Map<String, dynamic> metadata,
required final int priority,
required final int? senderId,
required final int accountId,
required final DateTime? readAt}) = _$SnNotificationImpl;
factory _SnNotification.fromJson(Map<String, dynamic> json) =
_$SnNotificationImpl.fromJson;
@override
int get id;
@override
DateTime get createdAt;
@override
DateTime get updatedAt;
@override
DateTime? get deletedAt;
@override
String get topic;
@override
String get title;
@override
String? get subtitle;
@override
String get body;
@override
Map<String, dynamic> get metadata;
@override
int get priority;
@override
int? get senderId;
@override
int get accountId;
@override
DateTime? get readAt;
/// Create a copy of SnNotification
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$SnNotificationImplCopyWith<_$SnNotificationImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@ -0,0 +1,46 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'notification.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$SnNotificationImpl _$$SnNotificationImplFromJson(Map<String, dynamic> json) =>
_$SnNotificationImpl(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
topic: json['topic'] as String,
title: json['title'] as String,
subtitle: json['subtitle'] as String?,
body: json['body'] as String,
metadata: json['metadata'] as Map<String, dynamic>? ?? const {},
priority: (json['priority'] as num).toInt(),
senderId: (json['sender_id'] as num?)?.toInt(),
accountId: (json['account_id'] as num).toInt(),
readAt: json['read_at'] == null
? null
: DateTime.parse(json['read_at'] as String),
);
Map<String, dynamic> _$$SnNotificationImplToJson(
_$SnNotificationImpl instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'topic': instance.topic,
'title': instance.title,
'subtitle': instance.subtitle,
'body': instance.body,
'metadata': instance.metadata,
'priority': instance.priority,
'sender_id': instance.senderId,
'account_id': instance.accountId,
'read_at': instance.readAt?.toIso8601String(),
};

View File

@ -29,7 +29,7 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
Widget build(BuildContext context) {
final nav = context.watch<NavigationProvider>();
final backgroundColor = ResponsiveBreakpoints.of(context).largerThan(MOBILE)
final backgroundColor = ResponsiveBreakpoints.of(context).largerThan(TABLET)
? Colors.transparent
: null;