Compare commits

..

3 Commits

Author SHA1 Message Date
88587c10da Notification embed post 2024-10-16 22:38:01 +08:00
9012566dbf 💄 Optimized notification list 2024-10-16 22:32:44 +08:00
6e00a99803 Better attachment fullscreen (support exif meta) 2024-10-16 22:16:03 +08:00
10 changed files with 193 additions and 41 deletions

View File

@ -486,5 +486,7 @@
"shareImage": "Share as image", "shareImage": "Share as image",
"shareImageFooter": "Only on the Solar Network", "shareImageFooter": "Only on the Solar Network",
"fileSavedAt": "File saved at @path", "fileSavedAt": "File saved at @path",
"showIp": "Show IP Address" "showIp": "Show IP Address",
"shotOn": "Shot on @device",
"unread": "Unread"
} }

View File

@ -482,5 +482,7 @@
"shareImage": "分享图片", "shareImage": "分享图片",
"shareImageFooter": "上 Solar Network 看更多有趣帖子", "shareImageFooter": "上 Solar Network 看更多有趣帖子",
"fileSavedAt": "文件保存于 @path", "fileSavedAt": "文件保存于 @path",
"showIp": "显示 IP 地址" "showIp": "显示 IP 地址",
"shotOn": "由 @device 拍摄",
"unread": "未读"
} }

View File

@ -199,6 +199,8 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
try { try {
await Future.wait([ await Future.wait([
if (auth.isAuthorized.isTrue)
Get.find<NotificationProvider>().fetchNotification(),
if (auth.isAuthorized.isTrue) if (auth.isAuthorized.isTrue)
Get.find<RelationshipProvider>().refreshRelativeList(), Get.find<RelationshipProvider>().refreshRelativeList(),
if (auth.isAuthorized.isTrue) if (auth.isAuthorized.isTrue)

View File

@ -1,7 +1,15 @@
import 'package:flutter/material.dart';
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
part 'notification.g.dart'; part 'notification.g.dart';
const Map<String, IconData> NotificationTopicIcons = {
'passport.security.alert': Icons.gpp_maybe,
'interactive.subscription': Icons.subscriptions,
'interactive.feedback': Icons.add_reaction,
'messaging.callStart': Icons.call_received,
};
@JsonSerializable() @JsonSerializable()
class Notification { class Notification {
int id; int id;
@ -9,11 +17,13 @@ class Notification {
DateTime updatedAt; DateTime updatedAt;
DateTime? deletedAt; DateTime? deletedAt;
DateTime? readAt; DateTime? readAt;
String topic;
String title; String title;
String? subtitle; String? subtitle;
String body; String body;
String? avatar; String? avatar;
String? picture; String? picture;
Map<String, dynamic>? metadata;
int? senderId; int? senderId;
int accountId; int accountId;
@ -23,11 +33,13 @@ class Notification {
required this.updatedAt, required this.updatedAt,
required this.deletedAt, required this.deletedAt,
required this.readAt, required this.readAt,
required this.topic,
required this.title, required this.title,
required this.subtitle, required this.subtitle,
required this.body, required this.body,
required this.avatar, required this.avatar,
required this.picture, required this.picture,
required this.metadata,
required this.senderId, required this.senderId,
required this.accountId, required this.accountId,
}); });

View File

@ -16,11 +16,13 @@ Notification _$NotificationFromJson(Map<String, dynamic> json) => Notification(
readAt: json['read_at'] == null readAt: json['read_at'] == null
? null ? null
: DateTime.parse(json['read_at'] as String), : DateTime.parse(json['read_at'] as String),
topic: json['topic'] as String,
title: json['title'] as String, title: json['title'] as String,
subtitle: json['subtitle'] as String?, subtitle: json['subtitle'] as String?,
body: json['body'] as String, body: json['body'] as String,
avatar: json['avatar'] as String?, avatar: json['avatar'] as String?,
picture: json['picture'] as String?, picture: json['picture'] as String?,
metadata: json['metadata'] as Map<String, dynamic>?,
senderId: (json['sender_id'] as num?)?.toInt(), senderId: (json['sender_id'] as num?)?.toInt(),
accountId: (json['account_id'] as num).toInt(), accountId: (json['account_id'] as num).toInt(),
); );
@ -32,11 +34,13 @@ Map<String, dynamic> _$NotificationToJson(Notification instance) =>
'updated_at': instance.updatedAt.toIso8601String(), 'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(), 'deleted_at': instance.deletedAt?.toIso8601String(),
'read_at': instance.readAt?.toIso8601String(), 'read_at': instance.readAt?.toIso8601String(),
'topic': instance.topic,
'title': instance.title, 'title': instance.title,
'subtitle': instance.subtitle, 'subtitle': instance.subtitle,
'body': instance.body, 'body': instance.body,
'avatar': instance.avatar, 'avatar': instance.avatar,
'picture': instance.picture, 'picture': instance.picture,
'metadata': instance.metadata,
'sender_id': instance.senderId, 'sender_id': instance.senderId,
'account_id': instance.accountId, 'account_id': instance.accountId,
}; };

View File

@ -18,12 +18,6 @@ class NotificationProvider extends GetxController {
RxList<Notification> notifications = RxList<Notification> notifications =
List<Notification>.empty(growable: true).obs; List<Notification>.empty(growable: true).obs;
@override
void onInit() {
super.onInit();
fetchNotification();
}
Future<void> fetchNotification() async { Future<void> fetchNotification() async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return; if (auth.isAuthorized.isFalse) return;
@ -35,7 +29,6 @@ class NotificationProvider extends GetxController {
final result = PaginationResult.fromJson(resp.body); final result = PaginationResult.fromJson(resp.body);
final data = result.data?.map((x) => Notification.fromJson(x)).toList(); final data = result.data?.map((x) => Notification.fromJson(x)).toList();
if (data != null) { if (data != null) {
print(data.map((x) => x.toJson()));
notifications.addAll(data); notifications.addAll(data);
notificationUnread.value = data.where((x) => x.readAt == null).length; notificationUnread.value = data.where((x) => x.readAt == null).length;
} }
@ -62,7 +55,11 @@ class NotificationProvider extends GetxController {
await client.put('/notifications/read', {'messages': markList}); await client.put('/notifications/read', {'messages': markList});
} }
nty.notifications.clear(); nty.notifications.value = nty.notifications.map((x) {
x.readAt = DateTime.now();
return x;
}).toList();
nty.notifications.refresh();
isBusy.value = false; isBusy.value = false;
} }
@ -86,7 +83,8 @@ class NotificationProvider extends GetxController {
await client.put('/notifications/read/${element.id}', {}); await client.put('/notifications/read/${element.id}', {});
nty.notifications.removeAt(index); nty.notifications[0].readAt = DateTime.now();
nty.notifications.refresh();
isBusy.value = false; isBusy.value = false;
} }

View File

@ -1,7 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/models/notification.dart';
import 'package:solian/models/post.dart';
import 'package:solian/providers/notifications.dart'; import 'package:solian/providers/notifications.dart';
import 'package:solian/router.dart';
import 'package:solian/widgets/loading_indicator.dart'; import 'package:solian/widgets/loading_indicator.dart';
import 'package:solian/widgets/markdown_text_content.dart';
import 'package:solian/widgets/posts/post_item.dart';
import 'package:solian/widgets/relative_date.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
class NotificationScreen extends StatefulWidget { class NotificationScreen extends StatefulWidget {
@ -76,7 +83,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
return ClipRect( return ClipRect(
child: Dismissible( child: Dismissible(
direction: element.readAt == null direction: element.readAt == null
? DismissDirection.vertical ? DismissDirection.horizontal
: DismissDirection.none, : DismissDirection.none,
key: Key(const Uuid().v4()), key: Key(const Uuid().v4()),
background: Container( background: Container(
@ -95,18 +102,92 @@ class _NotificationScreenState extends State<NotificationScreen> {
child: child:
const Icon(Icons.check, color: Colors.white), const Icon(Icons.check, color: Colors.white),
), ),
child: ListTile( child: Container(
contentPadding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 24, horizontal: 28,
vertical: 8, vertical: 16,
), ),
title: Text(element.title), child: Row(
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (element.subtitle != null) Icon(NotificationTopicIcons[element.topic]),
Text(element.subtitle!), const Gap(16),
Text(element.body), Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
if (element.readAt == null)
Badge(
label: Row(
children: [
const Icon(
Icons.new_releases_outlined,
color: Colors.white,
size: 12,
),
const Gap(4),
Text('unread'.tr),
],
),
).paddingOnly(bottom: 4),
Text(
element.title,
style: Theme.of(context)
.textTheme
.titleMedium,
),
if (element.subtitle != null)
Text(
element.subtitle!,
style: Theme.of(context)
.textTheme
.titleSmall,
),
if (element.subtitle != null)
const Gap(4),
MarkdownTextContent(
content: element.body,
isAutoWarp: true,
isSelectable: true,
parentId:
'notification-${element.id}',
),
if ([
'interactive.feedback',
'interactive.subscription'
].contains(element.topic) &&
element.metadata?['related_post'] !=
null)
_PostRelatedNotificationWidget(
metadata: element.metadata!,
),
const Gap(8),
Opacity(
opacity: 0.75,
child: Row(
children: [
RelativeDate(
element.createdAt,
style: TextStyle(fontSize: 12),
),
const Gap(4),
Text(
'·',
style: TextStyle(fontSize: 12),
),
const Gap(4),
RelativeDate(
element.createdAt,
style: TextStyle(fontSize: 12),
isFull: true,
),
],
),
),
],
),
),
], ],
), ),
), ),
@ -161,3 +242,31 @@ class NotificationButton extends StatelessWidget {
}); });
} }
} }
class _PostRelatedNotificationWidget extends StatelessWidget {
final Map<String, dynamic> metadata;
const _PostRelatedNotificationWidget({super.key, required this.metadata});
@override
Widget build(BuildContext context) {
return GestureDetector(
child: Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: PostItem(
item: Post.fromJson(metadata['related_post']),
isCompact: true,
).paddingAll(8),
),
onTap: () {
final data = Post.fromJson(metadata['related_post']);
Navigator.pop(context);
AppRouter.instance.pushNamed(
'postDetail',
pathParameters: {'id': data.id.toString()},
extra: data,
);
},
);
}
}

View File

@ -46,7 +46,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
Theme.of(context).colorScheme.onSurface.withOpacity(0.75); Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
List<Notification> get _pendingNotifications => List<Notification> get _pendingNotifications =>
List<Notification>.from(_nty.notifications) List<Notification>.from(_nty.notifications.where((x) => x.readAt == null))
..sort((a, b) => b.createdAt.compareTo(a.createdAt)); ..sort((a, b) => b.createdAt.compareTo(a.createdAt));
List<Post>? _currentPosts; List<Post>? _currentPosts;
@ -254,7 +254,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
), ),
Text( Text(
'notificationUnreadCount'.trParams({ 'notificationUnreadCount'.trParams({
'count': _nty.notifications.length.toString(), 'count': _pendingNotifications.length.toString(),
}), }),
), ),
], ],
@ -272,7 +272,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
), ),
], ],
).paddingOnly(left: 18, right: 18, bottom: 8), ).paddingOnly(left: 18, right: 18, bottom: 8),
if (_nty.notifications.isNotEmpty) if (_pendingNotifications.isNotEmpty)
SizedBox( SizedBox(
height: 76, height: 76,
child: ListView.separated( child: ListView.separated(

View File

@ -8,6 +8,7 @@ import 'package:flutter_animate/flutter_animate.dart';
import 'package:gal/gal.dart'; import 'package:gal/gal.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/attachment.dart'; import 'package:solian/models/attachment.dart';
import 'package:solian/platform.dart'; import 'package:solian/platform.dart';
@ -103,9 +104,10 @@ class _AttachmentFullScreenState extends State<AttachmentFullScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final metaTextStyle = TextStyle( final metaTextStyle = GoogleFonts.roboto(
fontSize: 12, fontSize: 12,
color: _unFocusColor, color: _unFocusColor,
height: 1,
); );
return DismissiblePage( return DismissiblePage(
@ -239,27 +241,43 @@ class _AttachmentFullScreenState extends State<AttachmentFullScreen> {
child: Wrap( child: Wrap(
spacing: 6, spacing: 6,
children: [ children: [
Text( if (widget.item.metadata?['exif'] == null)
'#${widget.item.rid}',
style: metaTextStyle,
),
if (widget.item.metadata?['width'] != null &&
widget.item.metadata?['height'] != null)
Text( Text(
'${widget.item.metadata?['width']}x${widget.item.metadata?['height']}', '#${widget.item.rid}',
style: metaTextStyle, style: metaTextStyle,
), ),
if (widget.item.metadata?['ratio'] != null) if (widget.item.metadata?['exif']?['Model'] != null)
Text( Text(
'${_getRatio().toPrecision(2)}', 'shotOn'.trParams({
'device': widget.item.metadata?['exif']
?['Model']
}),
style: metaTextStyle,
).paddingOnly(right: 2),
if (widget.item.metadata?['exif']?['ShutterSpeed'] !=
null)
Text(
widget.item.metadata?['exif']?['ShutterSpeed'],
style: metaTextStyle,
).paddingOnly(right: 2),
if (widget.item.metadata?['exif']?['ISO'] != null)
Text(
'ISO${widget.item.metadata?['exif']?['ISO']}',
style: metaTextStyle,
).paddingOnly(right: 2),
if (widget.item.metadata?['exif']?['Megapixels'] !=
null)
Text(
'${widget.item.metadata?['exif']?['Megapixels']}MP',
style: metaTextStyle,
)
else
Text(
widget.item.size.formatBytes(),
style: metaTextStyle, style: metaTextStyle,
), ),
Text( Text(
widget.item.size.formatBytes(), '${widget.item.metadata?['width']}x${widget.item.metadata?['height']}',
style: metaTextStyle,
),
Text(
widget.item.mimetype,
style: metaTextStyle, style: metaTextStyle,
), ),
], ],

View File

@ -4,20 +4,25 @@ import 'package:timeago/timeago.dart';
class RelativeDate extends StatelessWidget { class RelativeDate extends StatelessWidget {
final DateTime date; final DateTime date;
final TextStyle? style;
final bool isFull; final bool isFull;
const RelativeDate(this.date, {super.key, this.isFull = false}); const RelativeDate(this.date, {super.key, this.style, this.isFull = false});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (isFull) { if (isFull) {
return Text(DateFormat('y/M/d HH:mm').format(date)); return Text(
DateFormat('y/M/d HH:mm').format(date),
style: style,
);
} }
return Text( return Text(
format( format(
date, date,
locale: 'en_short', locale: 'en_short',
), ),
style: style,
); );
} }
} }