feat: 重构 Reminder Notification 系统并更新应用包名
This commit is contained in:
@@ -1,443 +0,0 @@
|
||||
import 'package:flutter/material.dart' hide BackButton;
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../../app/di/injection.dart';
|
||||
import '../../../../features/calendar/data/repositories/calendar_repository.dart';
|
||||
import '../../../../features/messages/data/models/inbox_message.dart';
|
||||
import '../../../../features/messages/data/repositories/inbox_repository.dart';
|
||||
import '../../../../features/contacts/data/repositories/user_repository.dart';
|
||||
import '../../../../core/l10n/l10n.dart';
|
||||
import '../../../../shared/widgets/app_loading_indicator.dart';
|
||||
import '../../../../shared/widgets/page_header.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
|
||||
class MessageInviteDetailScreen extends StatefulWidget {
|
||||
final String inviteId;
|
||||
|
||||
const MessageInviteDetailScreen({super.key, required this.inviteId});
|
||||
|
||||
@override
|
||||
State<MessageInviteDetailScreen> createState() =>
|
||||
_MessageInviteDetailScreenState();
|
||||
}
|
||||
|
||||
class _MessageInviteDetailScreenState extends State<MessageInviteDetailScreen> {
|
||||
late final InboxRepository _inboxRepository;
|
||||
late final CalendarRepository _calendarRepository;
|
||||
late final UserRepository _userRepository;
|
||||
|
||||
InboxMessage? _message;
|
||||
String? _calendarTitle;
|
||||
String? _senderName;
|
||||
bool _loading = true;
|
||||
bool _submitting = false;
|
||||
String? _error;
|
||||
|
||||
ColorScheme get _colorScheme => Theme.of(context).colorScheme;
|
||||
|
||||
bool get _isPending => _message?.status == InboxMessageStatus.pending;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_inboxRepository = sl<InboxRepository>();
|
||||
_calendarRepository = sl<CalendarRepository>();
|
||||
_userRepository = sl<UserRepository>();
|
||||
_loadDetail();
|
||||
}
|
||||
|
||||
Future<void> _loadDetail() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final results = await Future.wait([
|
||||
_inboxRepository.getMessages(isRead: false),
|
||||
_inboxRepository.getMessages(isRead: true),
|
||||
]);
|
||||
final messages = [...results[0], ...results[1]];
|
||||
InboxMessage? message;
|
||||
for (final item in messages) {
|
||||
if (item.id == widget.inviteId) {
|
||||
message = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (message == null) {
|
||||
throw StateError(L10n.current.messagesInviteDetailNotFound);
|
||||
}
|
||||
|
||||
String? calendarTitle;
|
||||
if (message.scheduleItemId != null) {
|
||||
try {
|
||||
final event = await _calendarRepository.getById(
|
||||
message.scheduleItemId!,
|
||||
);
|
||||
calendarTitle = event.title;
|
||||
} catch (_) {
|
||||
calendarTitle = null;
|
||||
}
|
||||
}
|
||||
|
||||
String? senderName;
|
||||
if (message.senderId != null) {
|
||||
try {
|
||||
final sender = await _userRepository.getById(message.senderId!);
|
||||
senderName = sender.username;
|
||||
} catch (_) {
|
||||
senderName = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_message = message;
|
||||
_calendarTitle = calendarTitle;
|
||||
_senderName = senderName;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_error = e.toString().replaceFirst('Bad state: ', '');
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _acceptInvite() async {
|
||||
final message = _message;
|
||||
final itemId = message?.scheduleItemId;
|
||||
if (message == null || itemId == null || _submitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
await _calendarRepository.acceptSubscription(itemId);
|
||||
await _inboxRepository.markAsRead(message.id);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
Toast.show(
|
||||
context,
|
||||
context.l10n.messagesInviteAcceptedToast,
|
||||
type: ToastType.success,
|
||||
);
|
||||
await _loadDetail();
|
||||
} catch (_) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
Toast.show(
|
||||
context,
|
||||
context.l10n.messagesInviteOperationFailed,
|
||||
type: ToastType.error,
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _rejectInvite() async {
|
||||
final message = _message;
|
||||
final itemId = message?.scheduleItemId;
|
||||
if (message == null || itemId == null || _submitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
await _calendarRepository.rejectSubscription(itemId);
|
||||
await _inboxRepository.markAsRead(message.id);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
Toast.show(
|
||||
context,
|
||||
context.l10n.messagesInviteRejectedToast,
|
||||
type: ToastType.success,
|
||||
);
|
||||
await _loadDetail();
|
||||
} catch (_) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
Toast.show(
|
||||
context,
|
||||
context.l10n.messagesInviteOperationFailed,
|
||||
type: ToastType.error,
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_loading) {
|
||||
return const Scaffold(
|
||||
body: SafeArea(child: Center(child: AppLoadingIndicator(size: 22))),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: _colorScheme.surface,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
PageHeader(leading: BackButton(onPressed: () => context.pop())),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(left: 20, right: 20, bottom: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSummaryCard(),
|
||||
const SizedBox(height: 14),
|
||||
_buildCalendarTip(),
|
||||
const SizedBox(height: 14),
|
||||
_buildActionRow(),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
_error!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummaryCard() {
|
||||
final message = _message;
|
||||
final statusText = message == null
|
||||
? context.l10n.commonUnknown
|
||||
: switch (message.status) {
|
||||
InboxMessageStatus.pending => context.l10n.messagesStatusPending,
|
||||
InboxMessageStatus.accepted =>
|
||||
context.l10n.messagesInviteStatusAccepted,
|
||||
InboxMessageStatus.rejected =>
|
||||
context.l10n.messagesInviteStatusRejected,
|
||||
InboxMessageStatus.dismissed =>
|
||||
context.l10n.messagesInviteStatusHandled,
|
||||
};
|
||||
|
||||
final createdAt = message?.createdAt;
|
||||
final createdAtText = createdAt == null
|
||||
? context.l10n.commonUnknown
|
||||
: DateFormat.yMd(context.l10n.localeName).add_Hm().format(createdAt);
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: _colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: _colorScheme.outlineVariant),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.messagesInviteDetailTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
context.l10n.messagesInviteEvent(
|
||||
_calendarTitle ?? context.l10n.messagesInviteUnnamedEvent,
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: _colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
context.l10n.messagesInviteSender(
|
||||
_senderName ?? context.l10n.messagesInviteUnknownUser,
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: _colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
context.l10n.messagesInviteTime(createdAtText),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: _colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
context.l10n.messagesInviteStatus(statusText),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: _colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
context.l10n.messagesInviteId(widget.inviteId),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: _colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCalendarTip() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: _colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: _colorScheme.outlineVariant),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 14,
|
||||
color: _colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.l10n.messagesInviteTip,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: _colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionRow() {
|
||||
if (!_isPending) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: _colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: _colorScheme.outlineVariant),
|
||||
),
|
||||
child: Text(
|
||||
context.l10n.messagesInviteAlreadyHandled,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: _colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: 46,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: _submitting ? null : _rejectInvite,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: _colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: _colorScheme.error),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.close, size: 15, color: _colorScheme.error),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
context.l10n.messagesReject,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: _submitting ? null : _acceptInvite,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: _colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: _colorScheme.primary),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.check, size: 15, color: _colorScheme.primary),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
context.l10n.messagesAccept,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart' hide BackButton;
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../app/di/injection.dart';
|
||||
import '../../../../app/router/app_routes.dart';
|
||||
import '../../../../core/inbox/inbox_sync_store.dart';
|
||||
import '../../../../features/calendar/data/repositories/calendar_repository.dart';
|
||||
import '../../../../features/contacts/data/repositories/friend_repository.dart';
|
||||
import '../../../../features/messages/data/repositories/inbox_repository.dart';
|
||||
import '../../../../features/contacts/data/models/friend_request.dart';
|
||||
@@ -33,11 +36,15 @@ class MessageInviteListScreen extends StatefulWidget {
|
||||
class _MessageInviteListScreenState extends State<MessageInviteListScreen> {
|
||||
late final InboxRepository _inboxRepository;
|
||||
late final FriendRepository _friendRepository;
|
||||
late final CalendarRepository _calendarRepository;
|
||||
late final InboxSyncStore _inboxSyncStore;
|
||||
|
||||
List<MessageWithFriend> _unreadMessages = [];
|
||||
List<MessageWithFriend> _readMessages = [];
|
||||
bool _isLoading = false;
|
||||
bool _isInitialLoading = true;
|
||||
bool _isPullRefreshing = false;
|
||||
bool _isHydrating = false;
|
||||
bool _pendingStoreSync = false;
|
||||
int _activeTabIndex = 0;
|
||||
|
||||
ColorScheme get _colorScheme => Theme.of(context).colorScheme;
|
||||
@@ -47,26 +54,65 @@ class _MessageInviteListScreenState extends State<MessageInviteListScreen> {
|
||||
super.initState();
|
||||
_inboxRepository = sl<InboxRepository>();
|
||||
_friendRepository = sl<FriendRepository>();
|
||||
_loadMessages();
|
||||
_calendarRepository = sl<CalendarRepository>();
|
||||
_inboxSyncStore = sl<InboxSyncStore>();
|
||||
_inboxSyncStore.addListener(_handleInboxStoreChanged);
|
||||
unawaited(_bootstrapInbox());
|
||||
}
|
||||
|
||||
Future<void> _loadMessages({bool showPageLoader = true}) async {
|
||||
if (_isLoading || _isPullRefreshing) {
|
||||
@override
|
||||
void dispose() {
|
||||
_inboxSyncStore.removeListener(_handleInboxStoreChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleInboxStoreChanged() {
|
||||
if (_isInitialLoading || _isPullRefreshing || _isHydrating) {
|
||||
_pendingStoreSync = true;
|
||||
return;
|
||||
}
|
||||
if (mounted) {
|
||||
unawaited(
|
||||
_syncMessagesFromStore(forceSnapshot: false, fromPullRefresh: false),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _bootstrapInbox() async {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_isInitialLoading = true;
|
||||
});
|
||||
await _inboxSyncStore.ensureStarted();
|
||||
await _syncMessagesFromStore(forceSnapshot: false, fromPullRefresh: false);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_isInitialLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _syncMessagesFromStore({
|
||||
required bool forceSnapshot,
|
||||
required bool fromPullRefresh,
|
||||
}) async {
|
||||
if (_isHydrating) {
|
||||
_pendingStoreSync = true;
|
||||
return;
|
||||
}
|
||||
_isHydrating = true;
|
||||
if (mounted && fromPullRefresh) {
|
||||
setState(() {
|
||||
_isLoading = showPageLoader;
|
||||
_isPullRefreshing = !showPageLoader;
|
||||
_isPullRefreshing = true;
|
||||
});
|
||||
}
|
||||
try {
|
||||
final results = await Future.wait([
|
||||
_inboxRepository.getMessages(isRead: false),
|
||||
_inboxRepository.getMessages(isRead: true),
|
||||
]);
|
||||
final unreadRaw = results[0];
|
||||
final readRaw = results[1];
|
||||
if (forceSnapshot) {
|
||||
await _inboxSyncStore.refreshSnapshot();
|
||||
}
|
||||
final unreadRaw = _inboxSyncStore.unreadMessages;
|
||||
final readRaw = _inboxSyncStore.readMessages;
|
||||
|
||||
final allMessages = [...unreadRaw, ...readRaw];
|
||||
final friendshipIds = allMessages
|
||||
@@ -90,15 +136,23 @@ class _MessageInviteListScreenState extends State<MessageInviteListScreen> {
|
||||
setState(() {
|
||||
_unreadMessages = unread;
|
||||
_readMessages = read;
|
||||
_isLoading = false;
|
||||
_isPullRefreshing = false;
|
||||
});
|
||||
_isHydrating = false;
|
||||
if (_pendingStoreSync) {
|
||||
_pendingStoreSync = false;
|
||||
await _syncMessagesFromStore(
|
||||
forceSnapshot: false,
|
||||
fromPullRefresh: false,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_isPullRefreshing = false;
|
||||
});
|
||||
_isHydrating = false;
|
||||
_pendingStoreSync = false;
|
||||
Toast.show(
|
||||
context,
|
||||
context.l10n.messagesLoadFailed,
|
||||
@@ -108,7 +162,7 @@ class _MessageInviteListScreenState extends State<MessageInviteListScreen> {
|
||||
}
|
||||
|
||||
Future<void> _onRefreshMessages() async {
|
||||
await _loadMessages(showPageLoader: false);
|
||||
await _syncMessagesFromStore(forceSnapshot: true, fromPullRefresh: true);
|
||||
}
|
||||
|
||||
List<MessageWithFriend> _mapMessagesWithFriend(
|
||||
@@ -128,17 +182,19 @@ class _MessageInviteListScreenState extends State<MessageInviteListScreen> {
|
||||
switch (message.messageType) {
|
||||
case InboxMessageType.calendar:
|
||||
final content = _parseCalendarContent(message.content);
|
||||
if (content == null) return;
|
||||
if (content == null) {
|
||||
_showProtocolErrorToast();
|
||||
return;
|
||||
}
|
||||
|
||||
final type = content['type'] as String?;
|
||||
if (type == 'invite') {
|
||||
context.push(AppRoutes.messageInviteDetail(message.id));
|
||||
} else if (type == 'update') {
|
||||
if (message.scheduleItemId != null) {
|
||||
context.push(
|
||||
AppRoutes.calendarEventDetail(message.scheduleItemId!),
|
||||
);
|
||||
}
|
||||
final isHandled = message.status != InboxMessageStatus.pending;
|
||||
_showCalendarInviteSheet(item, isReadOnly: isHandled);
|
||||
} else if (type == 'updated' || type == 'deleted') {
|
||||
_showCalendarChangeSheet(item);
|
||||
} else {
|
||||
_showProtocolErrorToast();
|
||||
}
|
||||
return;
|
||||
case InboxMessageType.friendRequest:
|
||||
@@ -159,9 +215,127 @@ class _MessageInviteListScreenState extends State<MessageInviteListScreen> {
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _parseCalendarContent(Map<String, dynamic>? content) {
|
||||
if (content == null) {
|
||||
return null;
|
||||
}
|
||||
final type = content['type'];
|
||||
final schemaVersion = content['schema_version'];
|
||||
final item = content['item'];
|
||||
final actor = content['actor'];
|
||||
final summary = content['summary'];
|
||||
if (type is! String || schemaVersion is! int || schemaVersion != 2) {
|
||||
return null;
|
||||
}
|
||||
if (item is! Map<String, dynamic> || actor is! Map<String, dynamic>) {
|
||||
return null;
|
||||
}
|
||||
final itemId = item['id'];
|
||||
final itemTitle = item['title'];
|
||||
final actorId = actor['user_id'];
|
||||
final actorName = actor['username'];
|
||||
if (itemId is! String || itemTitle is! String || itemTitle.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (actorId is! String ||
|
||||
actorName is! String ||
|
||||
actorName.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (summary is! String || summary.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (type == 'updated') {
|
||||
final changes = content['changes'];
|
||||
if (changes is! List) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
void _showProtocolErrorToast() {
|
||||
Toast.show(
|
||||
context,
|
||||
context.l10n.messagesProtocolInvalid,
|
||||
type: ToastType.error,
|
||||
);
|
||||
}
|
||||
|
||||
void _showCalendarChangeSheet(MessageWithFriend item) {
|
||||
final message = item.message;
|
||||
final content = _parseCalendarContent(message.content);
|
||||
if (content == null) {
|
||||
_showProtocolErrorToast();
|
||||
return;
|
||||
}
|
||||
final actor = content['actor'] as Map<String, dynamic>;
|
||||
final actorName =
|
||||
actor['username'] as String? ?? context.l10n.messagesUnknownActor;
|
||||
final summary = content['summary'] as String;
|
||||
final type = content['type'] as String;
|
||||
final changes =
|
||||
(content['changes'] as List?)
|
||||
?.whereType<Map<String, dynamic>>()
|
||||
.toList() ??
|
||||
const [];
|
||||
|
||||
final details = <String>[];
|
||||
if (changes.isNotEmpty) {
|
||||
for (final entry in changes) {
|
||||
final label =
|
||||
entry['label'] as String? ?? (entry['field'] as String? ?? '-');
|
||||
final before = entry['display_before'] as String? ?? '-';
|
||||
final after = entry['display_after'] as String? ?? '-';
|
||||
details.add('$label: $before -> $after');
|
||||
}
|
||||
}
|
||||
final description = details.isEmpty
|
||||
? summary
|
||||
: ([summary, ...details].join('\n'));
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: _colorScheme.surface.withValues(alpha: 0),
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => MessageActionSheet(
|
||||
title: type == 'deleted'
|
||||
? context.l10n.messagesCalendarDeletedBy(actorName)
|
||||
: context.l10n.messagesCalendarUpdatedBy(actorName),
|
||||
description: description,
|
||||
isReadOnly: true,
|
||||
icon: type == 'deleted'
|
||||
? Icons.delete_outline
|
||||
: Icons.edit_calendar_outlined,
|
||||
iconColor: type == 'deleted'
|
||||
? _colorScheme.error
|
||||
: _colorScheme.primary,
|
||||
primaryActionText: context.l10n.messagesAcknowledge,
|
||||
onPrimaryAction: () async {
|
||||
await _markMessageAsRead(message);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _markMessageAsRead(InboxMessage message) async {
|
||||
if (message.isRead) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await _inboxRepository.markAsRead(message.id);
|
||||
await _syncMessagesFromStore(forceSnapshot: true, fromPullRefresh: false);
|
||||
} catch (_) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
Toast.show(
|
||||
context,
|
||||
context.l10n.messagesActionFailed,
|
||||
type: ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _showFriendRequestSheet(
|
||||
MessageWithFriend item, {
|
||||
bool isReadOnly = false,
|
||||
@@ -207,6 +381,98 @@ class _MessageInviteListScreenState extends State<MessageInviteListScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _showCalendarInviteSheet(
|
||||
MessageWithFriend item, {
|
||||
bool isReadOnly = false,
|
||||
}) {
|
||||
final message = item.message;
|
||||
final parsed = _parseCalendarContent(message.content);
|
||||
if (parsed == null) {
|
||||
_showProtocolErrorToast();
|
||||
return;
|
||||
}
|
||||
final itemMap = parsed['item'] as Map<String, dynamic>;
|
||||
final actorMap = parsed['actor'] as Map<String, dynamic>;
|
||||
final title = (itemMap['title'] as String).trim();
|
||||
final resolvedTitle = context.l10n.messagesInviteEvent(title);
|
||||
final statusText = isReadOnly ? _calendarStatusLabel(message.status) : null;
|
||||
final actorName =
|
||||
actorMap['username'] as String? ?? context.l10n.messagesUnknownActor;
|
||||
final actorPhone = actorMap['phone'] as String?;
|
||||
final summary = parsed['summary'] as String;
|
||||
final detailLines = <String>[
|
||||
summary,
|
||||
'${context.l10n.messagesCalendarInviteActorLabel}: $actorName${(actorPhone != null && actorPhone.isNotEmpty) ? ' / $actorPhone' : ''}',
|
||||
];
|
||||
final startAt = _formatInviteDateTime(itemMap['start_at']);
|
||||
final endAt = _formatInviteDateTime(itemMap['end_at']);
|
||||
final timezone = itemMap['timezone'] as String?;
|
||||
if (startAt != null) {
|
||||
detailLines.add(
|
||||
'${context.l10n.messagesCalendarInviteTimeLabel}: ${endAt != null ? '$startAt - $endAt' : startAt}${timezone != null && timezone.isNotEmpty ? ' ($timezone)' : ''}',
|
||||
);
|
||||
}
|
||||
final descriptionText = itemMap['description'] as String?;
|
||||
if (descriptionText != null && descriptionText.trim().isNotEmpty) {
|
||||
detailLines.add(
|
||||
'${context.l10n.messagesCalendarInviteDescriptionLabel}: ${descriptionText.trim()}',
|
||||
);
|
||||
}
|
||||
if (isReadOnly) {
|
||||
detailLines.add(context.l10n.messagesInviteAlreadyHandled);
|
||||
}
|
||||
final description = detailLines.join('\n');
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: _colorScheme.surface.withValues(alpha: 0),
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => MessageActionSheet(
|
||||
title: resolvedTitle,
|
||||
description: description,
|
||||
statusText: statusText,
|
||||
isReadOnly: isReadOnly,
|
||||
icon: Icons.event_outlined,
|
||||
iconColor: _colorScheme.primary,
|
||||
onAccept: isReadOnly
|
||||
? null
|
||||
: () async {
|
||||
await _processCalendarInvite(item, accept: true);
|
||||
},
|
||||
onDecline: isReadOnly
|
||||
? null
|
||||
: () async {
|
||||
await _processCalendarInvite(item, accept: false);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String? _formatInviteDateTime(Object? raw) {
|
||||
if (raw is! String || raw.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final dt = DateTime.tryParse(raw);
|
||||
if (dt == null) {
|
||||
return null;
|
||||
}
|
||||
final local = dt.toLocal();
|
||||
final month = local.month.toString().padLeft(2, '0');
|
||||
final day = local.day.toString().padLeft(2, '0');
|
||||
final hour = local.hour.toString().padLeft(2, '0');
|
||||
final minute = local.minute.toString().padLeft(2, '0');
|
||||
return '${local.year}-$month-$day $hour:$minute';
|
||||
}
|
||||
|
||||
String _calendarStatusLabel(InboxMessageStatus status) {
|
||||
return switch (status) {
|
||||
InboxMessageStatus.pending => context.l10n.messagesStatusPending,
|
||||
InboxMessageStatus.accepted => context.l10n.messagesInviteStatusAccepted,
|
||||
InboxMessageStatus.rejected => context.l10n.messagesInviteStatusRejected,
|
||||
InboxMessageStatus.dismissed => context.l10n.messagesInviteStatusHandled,
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> _processFriendRequest(
|
||||
MessageWithFriend item, {
|
||||
required bool accept,
|
||||
@@ -243,7 +509,7 @@ class _MessageInviteListScreenState extends State<MessageInviteListScreen> {
|
||||
}
|
||||
}
|
||||
await _inboxRepository.markAsRead(message.id);
|
||||
await _loadMessages();
|
||||
await _syncMessagesFromStore(forceSnapshot: true, fromPullRefresh: false);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
Toast.show(
|
||||
@@ -255,6 +521,54 @@ class _MessageInviteListScreenState extends State<MessageInviteListScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _processCalendarInvite(
|
||||
MessageWithFriend item, {
|
||||
required bool accept,
|
||||
}) async {
|
||||
final message = item.message;
|
||||
final scheduleItemId = message.scheduleItemId;
|
||||
if (scheduleItemId == null) {
|
||||
Toast.show(
|
||||
context,
|
||||
context.l10n.messagesInviteOperationFailed,
|
||||
type: ToastType.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (accept) {
|
||||
await _calendarRepository.acceptSubscription(scheduleItemId);
|
||||
if (mounted) {
|
||||
Toast.show(
|
||||
context,
|
||||
context.l10n.messagesInviteAcceptedToast,
|
||||
type: ToastType.success,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await _calendarRepository.rejectSubscription(scheduleItemId);
|
||||
if (mounted) {
|
||||
Toast.show(
|
||||
context,
|
||||
context.l10n.messagesInviteRejectedToast,
|
||||
type: ToastType.success,
|
||||
);
|
||||
}
|
||||
}
|
||||
await _inboxRepository.markAsRead(message.id);
|
||||
await _syncMessagesFromStore(forceSnapshot: true, fromPullRefresh: false);
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
Toast.show(
|
||||
context,
|
||||
context.l10n.messagesInviteOperationFailed,
|
||||
type: ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -264,7 +578,7 @@ class _MessageInviteListScreenState extends State<MessageInviteListScreen> {
|
||||
children: [
|
||||
_buildHeader(context),
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
child: _isInitialLoading
|
||||
? const Center(
|
||||
child: AppLoadingIndicator(
|
||||
size: 22,
|
||||
@@ -479,6 +793,42 @@ class _MessageCard extends StatelessWidget {
|
||||
InboxMessage get message => item.message;
|
||||
FriendRequest? get friendRequest => item.friendRequest;
|
||||
|
||||
Map<String, dynamic>? _parseCalendarContent(Map<String, dynamic>? content) {
|
||||
if (content == null) {
|
||||
return null;
|
||||
}
|
||||
final type = content['type'];
|
||||
final schemaVersion = content['schema_version'];
|
||||
final item = content['item'];
|
||||
final actor = content['actor'];
|
||||
final summary = content['summary'];
|
||||
if (type is! String || schemaVersion is! int || schemaVersion != 2) {
|
||||
return null;
|
||||
}
|
||||
if (item is! Map<String, dynamic> || actor is! Map<String, dynamic>) {
|
||||
return null;
|
||||
}
|
||||
final itemId = item['id'];
|
||||
final itemTitle = item['title'];
|
||||
final actorId = actor['user_id'];
|
||||
final actorName = actor['username'];
|
||||
if (itemId is! String || itemTitle is! String || itemTitle.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (actorId is! String ||
|
||||
actorName is! String ||
|
||||
actorName.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (summary is! String || summary.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (type == 'updated' && content['changes'] is! List) {
|
||||
return null;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
@@ -555,19 +905,39 @@ class _MessageCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
if (message.messageType == InboxMessageType.calendar) {
|
||||
final data = message.content;
|
||||
return data?['title'] as String? ?? L10n.current.messagesCalendarInvite;
|
||||
final data = _parseCalendarContent(message.content);
|
||||
if (data == null) {
|
||||
return L10n.current.messagesProtocolInvalidCardTitle;
|
||||
}
|
||||
final type = data['type'];
|
||||
final item = data['item'];
|
||||
final itemTitle = item is Map<String, dynamic>
|
||||
? item['title'] as String?
|
||||
: null;
|
||||
if (type == 'invite' &&
|
||||
itemTitle != null &&
|
||||
itemTitle.trim().isNotEmpty) {
|
||||
return L10n.current.messagesInviteEvent(itemTitle);
|
||||
}
|
||||
if (type == 'updated' &&
|
||||
itemTitle != null &&
|
||||
itemTitle.trim().isNotEmpty) {
|
||||
return L10n.current.messagesCalendarCardUpdatedWithTitle(itemTitle);
|
||||
}
|
||||
if (type == 'deleted' &&
|
||||
itemTitle != null &&
|
||||
itemTitle.trim().isNotEmpty) {
|
||||
return L10n.current.messagesCalendarCardDeletedWithTitle(itemTitle);
|
||||
}
|
||||
return L10n.current.messagesProtocolInvalidCardTitle;
|
||||
}
|
||||
return L10n.current.messagesSystemMessage;
|
||||
}
|
||||
|
||||
String _content() {
|
||||
if (message.messageType == InboxMessageType.calendar) {
|
||||
Map<String, dynamic>? data;
|
||||
if (message.content != null) {
|
||||
data = message.content;
|
||||
}
|
||||
if (data == null) return L10n.current.messagesTapToView;
|
||||
final data = _parseCalendarContent(message.content);
|
||||
if (data == null) return L10n.current.messagesProtocolInvalidCardDesc;
|
||||
|
||||
final type = data['type'] as String?;
|
||||
if (type == 'invite') {
|
||||
@@ -579,10 +949,12 @@ class _MessageCard extends StatelessWidget {
|
||||
} else if (status == InboxMessageStatus.rejected) {
|
||||
return L10n.current.messagesInviteRejected;
|
||||
}
|
||||
} else if (type == 'update') {
|
||||
} else if (type == 'updated') {
|
||||
return L10n.current.messagesCalendarUpdated;
|
||||
} else if (type == 'deleted') {
|
||||
return L10n.current.messagesCalendarDeleted;
|
||||
}
|
||||
return L10n.current.messagesTapToView;
|
||||
return L10n.current.messagesProtocolInvalidCardDesc;
|
||||
}
|
||||
return message.content?['message'] as String? ??
|
||||
L10n.current.messagesTapToView;
|
||||
|
||||
@@ -12,6 +12,8 @@ class MessageActionSheet extends StatelessWidget {
|
||||
final VoidCallback? onDecline;
|
||||
final IconData? icon;
|
||||
final Color? iconColor;
|
||||
final String? primaryActionText;
|
||||
final VoidCallback? onPrimaryAction;
|
||||
|
||||
const MessageActionSheet({
|
||||
super.key,
|
||||
@@ -23,6 +25,8 @@ class MessageActionSheet extends StatelessWidget {
|
||||
this.onDecline,
|
||||
this.icon,
|
||||
this.iconColor,
|
||||
this.primaryActionText,
|
||||
this.onPrimaryAction,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -124,6 +128,17 @@ class MessageActionSheet extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
] else if (primaryActionText != null && onPrimaryAction != null) ...[
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: AppButton(
|
||||
text: primaryActionText!,
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
onPrimaryAction?.call();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
SizedBox(height: MediaQuery.of(context).padding.bottom + 12),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user