feat(apps): 重构 UI 架构为 presentation 层并新增 l10n 国际化支持

This commit is contained in:
qzl
2026-03-27 14:05:03 +08:00
parent b1f0eb8921
commit c592cc7854
178 changed files with 10748 additions and 5764 deletions
@@ -1,4 +1,4 @@
import 'package:social_app/core/api/i_api_client.dart';
import 'package:social_app/core/network/i_api_client.dart';
import 'models/schedule_item_model.dart';
@@ -20,12 +20,12 @@ class ScheduleItemModel {
final DateTime createdAt;
final DateTime updatedAt;
static const int PERMISSION_VIEW = 1;
static const int PERMISSION_INVITE = 2;
static const int PERMISSION_EDIT = 4;
static const int permissionView = 1;
static const int permissionInvite = 2;
static const int permissionEdit = 4;
bool get canEdit => isOwner || (permission & PERMISSION_EDIT) != 0;
bool get canInvite => isOwner || (permission & PERMISSION_INVITE) != 0;
bool get canEdit => isOwner || (permission & permissionEdit) != 0;
bool get canInvite => isOwner || (permission & permissionInvite) != 0;
bool get canDelete => isOwner;
ScheduleItemModel({
@@ -1,6 +1,6 @@
import 'package:social_app/core/api/i_api_client.dart';
import 'package:social_app/core/network/i_api_client.dart';
import 'package:social_app/core/cache/cache_invalidator.dart';
import 'package:social_app/core/di/injection.dart';
import 'package:social_app/app/di/injection.dart';
import '../calendar_api.dart';
import '../models/schedule_item_model.dart';
@@ -1,10 +1,11 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../../core/router/app_routes.dart';
import '../../../home/ui/navigation/home_return_policy.dart';
import '../../../../app/router/app_routes.dart';
import '../../../home/presentation/navigation/home_return_policy.dart';
import '../../../../core/di/injection.dart';
import '../../../../app/di/injection.dart';
import '../../../../core/l10n/l10n.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_pressable.dart';
import '../../data/models/schedule_item_model.dart';
@@ -36,7 +37,6 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
static const double _dayItemWidth = 44;
static const double _dayItemGap = 12;
static const double _minEventTapHeight = 32;
static const List<String> _dayNames = ['', '', '', '', '', '', ''];
final DayEventLayoutEngine _layoutEngine = const DayEventLayoutEngine();
final Map<int, Offset> _activePointers = {};
@@ -224,7 +224,10 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
}
Widget _buildHeader() {
final monthLabel = '${_selectedDate.year}${_selectedDate.month}';
final monthLabel = context.l10n.calendarDayWeekMonthYearLabel(
_selectedDate.year,
_selectedDate.month,
);
final isNotToday = !isSameDay(_selectedDate, DateTime.now());
return SizedBox(
@@ -298,10 +301,10 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: AppColors.messageBtnBorder),
),
child: const Center(
child: Center(
child: Text(
'今天',
style: TextStyle(
context.l10n.calendarToday,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.slate700,
@@ -448,7 +451,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
mainAxisSize: MainAxisSize.min,
children: [
Text(
_dayNames[date.weekday % 7],
_weekdayLabel(date),
style: TextStyle(
fontSize: 11,
color: isWeekend ? AppColors.slate400 : AppColors.slate600,
@@ -481,6 +484,11 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
);
}
String _weekdayLabel(DateTime date) {
const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
return labels[date.weekday % 7];
}
Widget _buildTimelineBoard() {
final now = DateTime.now();
final showCurrent = shouldShowCurrentMarker(_selectedDate, now);
@@ -1,9 +1,10 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/router/app_routes.dart';
import '../../../../core/notifications/local_notification_service.dart';
import 'package:social_app/core/l10n/l10n.dart';
import '../../../../app/di/injection.dart';
import '../../../../app/router/app_routes.dart';
import '../../../../features/notification/data/services/local_notification_service.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_loading_indicator.dart';
import '../../../../shared/widgets/back_title_page_header.dart';
@@ -72,7 +73,7 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const BackTitlePageHeader(title: '日程详情'),
BackTitlePageHeader(title: context.l10n.calendarDetailTitle),
Expanded(
child: Center(
child: Padding(
@@ -83,8 +84,8 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Text(
'未找到该日程',
Text(
context.l10n.calendarDetailNotFoundTitle,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
@@ -92,8 +93,8 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
),
),
const SizedBox(height: AppSpacing.sm),
const Text(
'可能已被删除,或你没有访问权限。',
Text(
context.l10n.calendarDetailNotFoundDesc,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
@@ -156,7 +157,7 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
Widget _buildHeader(ScheduleItemModel event) {
return BackTitlePageHeader(
title: '日程详情',
title: context.l10n.calendarDetailTitle,
onBack: () => context.pop(),
trailing: _buildHeaderActions(event),
);
@@ -168,7 +169,7 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
items.add(
DetailHeaderActionItem<_CalendarHeaderAction>(
value: _CalendarHeaderAction.edit,
label: '编辑',
label: context.l10n.commonEdit,
icon: LucideIcons.pencil,
),
);
@@ -177,7 +178,7 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
items.add(
DetailHeaderActionItem<_CalendarHeaderAction>(
value: _CalendarHeaderAction.delete,
label: '删除',
label: context.l10n.commonDelete,
icon: LucideIcons.trash2,
isDestructive: true,
),
@@ -187,7 +188,7 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
items.add(
DetailHeaderActionItem<_CalendarHeaderAction>(
value: _CalendarHeaderAction.share,
label: '分享',
label: context.l10n.commonShare,
icon: LucideIcons.share2,
),
);
@@ -197,7 +198,7 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
items.add(
DetailHeaderActionItem<_CalendarHeaderAction>(
value: _CalendarHeaderAction.archive,
label: '归档',
label: context.l10n.commonArchive,
icon: LucideIcons.archive,
enabled: !isExpired,
),
@@ -313,8 +314,8 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'时间安排',
Text(
context.l10n.calendarDetailTimeArrangement,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
@@ -341,8 +342,12 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
Widget _buildMetaSurface(ScheduleItemModel event) {
final startAt = event.startAt;
final dateStr =
'${startAt.year}${startAt.month}${startAt.day}${_getWeekday(startAt.weekday)}';
final dateStr = context.l10n.calendarDetailDateLabel(
startAt.year,
startAt.month,
startAt.day,
_getWeekday(startAt.weekday),
);
final color = resolveEventColor(
status: event.status,
colorHex: event.metadata?.color,
@@ -358,8 +363,8 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'基础信息',
Text(
context.l10n.calendarDetailBasicInfo,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
@@ -367,21 +372,21 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
),
),
const SizedBox(height: AppSpacing.md),
_buildDetailRow('日期', dateStr),
_buildDetailRow(context.l10n.calendarDetailDate, dateStr),
const SizedBox(height: AppSpacing.md),
_buildDetailRow(
'提醒',
context.l10n.calendarDetailReminder,
_formatReminderText(event.metadata?.reminderMinutes),
),
const SizedBox(height: AppSpacing.md),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(
SizedBox(
width: AppSpacing.xxl * 3,
child: Text(
'颜色',
style: TextStyle(
context.l10n.calendarDetailColor,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.slate500,
@@ -421,8 +426,8 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'补充信息',
Text(
context.l10n.calendarDetailExtraInfo,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
@@ -431,16 +436,22 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
),
if (event.metadata?.location?.trim().isNotEmpty ?? false) ...[
const SizedBox(height: AppSpacing.md),
_buildDetailRow('地点', event.metadata!.location!.trim()),
_buildDetailRow(
context.l10n.calendarDetailLocation,
event.metadata!.location!.trim(),
),
],
if (event.description?.trim().isNotEmpty ?? false) ...[
const SizedBox(height: AppSpacing.md),
_buildDetailRow('描述', event.description!.trim()),
_buildDetailRow(
context.l10n.calendarDetailDescription,
event.description!.trim(),
),
],
if (event.metadata?.notes?.trim().isNotEmpty ?? false) ...[
const SizedBox(height: AppSpacing.md),
_buildDetailRow(
'备注',
context.l10n.calendarDetailNotes,
event.metadata!.notes!.trim(),
multiline: true,
),
@@ -452,16 +463,25 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
String _formatReminderText(int? reminderMinutes) {
if (reminderMinutes == null) {
return '';
return context.l10n.calendarDetailReminderNone;
}
if (reminderMinutes == 0) {
return '准时提醒';
return context.l10n.calendarDetailReminderOnTime;
}
return '开始前$reminderMinutes分钟';
return context.l10n.calendarDetailReminderBeforeMinutes(reminderMinutes);
}
String _getWeekday(int weekday) {
const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
final l10n = context.l10n;
final weekdays = [
l10n.calendarWeekdayMon,
l10n.calendarWeekdayTue,
l10n.calendarWeekdayWed,
l10n.calendarWeekdayThu,
l10n.calendarWeekdayFri,
l10n.calendarWeekdaySat,
l10n.calendarWeekdaySun,
];
return weekdays[weekday - 1];
}
@@ -472,9 +492,9 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
Future<void> _showDeleteConfirmation() async {
final confirmed = await showDestructiveActionSheet(
context,
title: '删除日程',
message: '确定要删除这个日程吗?',
confirmText: '确认删除',
title: context.l10n.calendarDetailDeleteTitle,
message: context.l10n.calendarDetailDeleteMessage,
confirmText: context.l10n.calendarDetailDeleteConfirm,
);
if (!confirmed) {
return;
@@ -492,9 +512,9 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
Future<void> _archiveEvent() async {
final confirmed = await showDestructiveActionSheet(
context,
title: '归档日程',
message: '归档后此日程将标记为过期,确定要归档吗?',
confirmText: '确认归档',
title: context.l10n.calendarDetailArchiveTitle,
message: context.l10n.calendarDetailArchiveMessage,
confirmText: context.l10n.calendarDetailArchiveConfirm,
);
if (!confirmed) {
return;
@@ -506,14 +526,22 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
}
} catch (e) {
if (mounted) {
Toast.show(context, '归档失败', type: ToastType.error);
Toast.show(
context,
context.l10n.calendarDetailArchiveFailed,
type: ToastType.error,
);
}
}
}
String _formatRangeLabel(DateTime startAt, DateTime? endAt) {
final startLabel =
'${startAt.month}${startAt.day}${_getWeekday(startAt.weekday)} ${_formatTime(startAt)}';
final startLabel = context.l10n.calendarDetailDateTimeShort(
startAt.month,
startAt.day,
_getWeekday(startAt.weekday),
_formatTime(startAt),
);
if (endAt == null) {
return startLabel;
}
@@ -524,9 +552,13 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
if (isSameDay) {
return '$startLabel - ${_formatTime(endAt)}';
}
final endLabel =
'${endAt.month}${endAt.day}${_getWeekday(endAt.weekday)} ${_formatTime(endAt)}';
return '开始: $startLabel\n结束: $endLabel';
final endLabel = context.l10n.calendarDetailDateTimeShort(
endAt.month,
endAt.day,
_getWeekday(endAt.weekday),
_formatTime(endAt),
);
return context.l10n.calendarDetailRangeWithStartEnd(startLabel, endLabel);
}
Widget _buildStatusBadge(ScheduleStatus status) {
@@ -548,7 +580,9 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
),
),
child: Text(
isArchived ? '已过期' : '启用',
isArchived
? context.l10n.calendarDetailStatusExpired
: context.l10n.settingsJobStatusEnabled,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../../../../core/di/injection.dart';
import '../../../../app/di/injection.dart';
import '../../../../core/l10n/l10n.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_loading_indicator.dart';
import '../widgets/create_event_sheet.dart';
@@ -50,8 +51,12 @@ class _CalendarEventEditScreenState extends State<CalendarEventEditScreen> {
}
if (_event == null) {
return const Scaffold(
body: SafeArea(child: Center(child: Text('日程不存在或无权限'))),
return Scaffold(
body: SafeArea(
child: Center(
child: Text(context.l10n.calendarEventNoAccessOrMissing),
),
),
);
}
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../../../../core/di/injection.dart';
import '../../../../app/di/injection.dart';
import '../../../../core/l10n/l10n.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_loading_indicator.dart';
import '../../../../shared/widgets/back_title_page_header.dart';
@@ -52,8 +53,12 @@ class _CalendarEventShareScreenState extends State<CalendarEventShareScreen> {
final event = _event;
if (event == null) {
return const Scaffold(
body: SafeArea(child: Center(child: Text('日程不存在或无权限'))),
return Scaffold(
body: SafeArea(
child: Center(
child: Text(context.l10n.calendarEventNoAccessOrMissing),
),
),
);
}
@@ -63,7 +68,7 @@ class _CalendarEventShareScreenState extends State<CalendarEventShareScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const BackTitlePageHeader(title: '分享日程'),
BackTitlePageHeader(title: context.l10n.calendarShareTitle),
Expanded(
child: CalendarShareDialog(
eventId: event.id,
@@ -2,11 +2,12 @@ import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:go_router/go_router.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/router/app_routes.dart';
import 'package:social_app/core/l10n/l10n.dart';
import '../../../../app/di/injection.dart';
import '../../../../app/router/app_routes.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_pressable.dart';
import '../../../home/ui/navigation/home_return_policy.dart';
import '../../../home/presentation/navigation/home_return_policy.dart';
import '../calendar_state_manager.dart';
import '../calendar_time_utils.dart';
import '../utils/event_color_resolver.dart';
@@ -107,6 +108,7 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
}
Widget _buildHeader() {
final l10n = context.l10n;
final today = DateTime.now();
final isNotToday = !isSameDay(_selectedDate, today);
@@ -132,7 +134,7 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeOut,
child: Text(
'${_currentMonth.month}',
l10n.calendarMonthHeader(_currentMonth.month),
key: ValueKey(_currentMonth.month),
style: const TextStyle(
fontSize: 22,
@@ -163,9 +165,9 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: AppColors.messageBtnBorder),
),
child: const Center(
child: Center(
child: Text(
'今天',
l10n.calendarMonthToday,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
@@ -234,7 +236,16 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
}
Widget _buildWeekdayHeader() {
const weekdays = ['', '', '', '', '', '', ''];
final l10n = context.l10n;
final List<String> weekdays = [
l10n.calendarMonthWeekdaySunShort,
l10n.calendarMonthWeekdayMonShort,
l10n.calendarMonthWeekdayTueShort,
l10n.calendarMonthWeekdayWedShort,
l10n.calendarMonthWeekdayThuShort,
l10n.calendarMonthWeekdayFriShort,
l10n.calendarMonthWeekdaySatShort,
];
return SizedBox(
height: 40,
child: Padding(
@@ -452,6 +463,7 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
}
void _showMonthPicker() {
final l10n = context.l10n;
var selectedYear = _currentMonth.year;
var selectedMonth = _currentMonth.month;
@@ -472,7 +484,7 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
child: Text(l10n.commonCancel),
),
TextButton(
onPressed: () {
@@ -492,7 +504,7 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
_calendarManager.setSelectedDate(_selectedDate);
_loadMonthEvents();
},
child: const Text('确定'),
child: Text(l10n.commonConfirm),
),
],
),
@@ -512,7 +524,11 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
});
},
children: List.generate(20, (index) {
return Center(child: Text('${2020 + index}'));
return Center(
child: Text(
l10n.calendarMonthYearLabel(2020 + index),
),
);
}),
),
),
@@ -528,7 +544,11 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
});
},
children: List.generate(12, (index) {
return Center(child: Text('${index + 1}'));
return Center(
child: Text(
l10n.calendarMonthHeader(index + 1),
),
);
}),
),
),
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart' hide BackButton;
import 'package:social_app/core/l10n/l10n.dart';
import '../../../../core/di/injection.dart';
import '../../../../app/di/injection.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/toast/toast.dart';
@@ -47,7 +48,7 @@ class CalendarShareDialog extends StatefulWidget {
class _CalendarShareDialogState extends State<CalendarShareDialog> {
final _phoneController = TextEditingController();
bool _permissionView = true;
final bool _permissionView = true;
bool _permissionEdit = false;
bool _permissionInvite = false;
bool _isLoading = false;
@@ -59,9 +60,14 @@ class _CalendarShareDialogState extends State<CalendarShareDialog> {
}
Future<void> _handleShare() async {
final l10n = context.l10n;
final phone = _phoneController.text.trim();
if (phone.isEmpty) {
Toast.show(context, '请输入手机号', type: ToastType.error);
Toast.show(
context,
l10n.calendarSharePhoneRequired,
type: ToastType.error,
);
return;
}
@@ -77,12 +83,20 @@ class _CalendarShareDialogState extends State<CalendarShareDialog> {
invite: _permissionInvite,
);
if (mounted) {
Toast.show(context, '邀请已发送', type: ToastType.success);
Toast.show(
context,
l10n.calendarShareInviteSent,
type: ToastType.success,
);
Navigator.of(context).pop();
}
} catch (e) {
if (mounted) {
Toast.show(context, '发送邀请失败', type: ToastType.error);
Toast.show(
context,
l10n.calendarShareInviteFailed,
type: ToastType.error,
);
}
} finally {
if (mounted) {
@@ -93,6 +107,8 @@ class _CalendarShareDialogState extends State<CalendarShareDialog> {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Container(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
@@ -113,8 +129,8 @@ class _CalendarShareDialogState extends State<CalendarShareDialog> {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'分享日历',
Text(
l10n.calendarShareTitle,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
IconButton(
@@ -129,8 +145,8 @@ class _CalendarShareDialogState extends State<CalendarShareDialog> {
TextField(
controller: _phoneController,
decoration: InputDecoration(
labelText: '手机号',
hintText: '输入对方的 +86 手机号',
labelText: l10n.calendarSharePhoneLabel,
hintText: l10n.calendarSharePhoneHint,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
),
@@ -138,20 +154,28 @@ class _CalendarShareDialogState extends State<CalendarShareDialog> {
keyboardType: TextInputType.phone,
),
const SizedBox(height: AppSpacing.lg),
const Text('权限设置', style: TextStyle(fontWeight: FontWeight.w600)),
Text(
l10n.calendarSharePermissionTitle,
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: AppSpacing.sm),
_buildPermissionSwitch('查看', '可以查看此日历事件(必选)', true, null),
_buildPermissionSwitch(
'编辑',
'可以编辑此日历事件',
l10n.calendarSharePermissionView,
l10n.calendarSharePermissionViewDesc,
true,
null,
),
_buildPermissionSwitch(
l10n.calendarSharePermissionEdit,
l10n.calendarSharePermissionEditDesc,
_permissionEdit,
widget.canEdit
? (v) => setState(() => _permissionEdit = v)
: null,
),
_buildPermissionSwitch(
'邀请',
'可以邀请其他人',
l10n.calendarSharePermissionInvite,
l10n.calendarSharePermissionInviteDesc,
_permissionInvite,
widget.canInvite
? (v) => setState(() => _permissionInvite = v)
@@ -159,7 +183,7 @@ class _CalendarShareDialogState extends State<CalendarShareDialog> {
),
const SizedBox(height: AppSpacing.lg),
AppButton(
text: '发送邀请',
text: l10n.calendarShareSendInvite,
onPressed: _isLoading ? null : _handleShare,
isLoading: _isLoading,
),
@@ -1,7 +1,8 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/notifications/local_notification_service.dart';
import 'package:social_app/core/l10n/l10n.dart';
import '../../../../app/di/injection.dart';
import '../../../../features/notification/data/services/local_notification_service.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_loading_indicator.dart';
import '../../../../shared/widgets/app_selection_sheet.dart';
@@ -204,7 +205,9 @@ class _CreateEventSheetState extends State<CreateEventSheet>
Widget _buildPageHeader() {
return BackTitlePageHeader(
title: _isEditing ? '编辑日程' : '新建日程',
title: _isEditing
? context.l10n.calendarCreateEditTitle
: context.l10n.calendarCreateNewTitle,
onBack: () => Navigator.of(context).pop(),
trailing: ValueListenableBuilder<TextEditingValue>(
valueListenable: _titleController,
@@ -226,7 +229,7 @@ class _CreateEventSheetState extends State<CreateEventSheet>
trackColor: AppColors.blue200,
)
: Text(
'保存',
context.l10n.commonSave,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
@@ -266,7 +269,9 @@ class _CreateEventSheetState extends State<CreateEventSheet>
),
),
Text(
_isEditing ? '编辑日程' : '新建日程',
_isEditing
? context.l10n.calendarCreateEditTitle
: context.l10n.calendarCreateNewTitle,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
@@ -295,7 +300,7 @@ class _CreateEventSheetState extends State<CreateEventSheet>
trackColor: AppColors.blue200,
)
: Text(
'保存',
context.l10n.commonSave,
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
@@ -323,9 +328,9 @@ class _CreateEventSheetState extends State<CreateEventSheet>
labelColor: AppColors.blue600,
unselectedLabelColor: AppColors.slate600,
indicatorColor: AppColors.blue600,
tabs: const [
Tab(text: '基础'),
Tab(text: '进阶'),
tabs: [
Tab(text: context.l10n.calendarCreateTabBasic),
Tab(text: context.l10n.calendarCreateTabAdvanced),
],
),
);
@@ -345,38 +350,47 @@ class _CreateEventSheetState extends State<CreateEventSheet>
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTextField('标题', _titleController, '请输入日程标题'),
const SizedBox(height: 20),
_buildDateTimePicker('开始', _startDate, _startTime, (date, time) {
setState(() {
_startDate = date;
_startTime = time;
if (_endDate != null && _endTime != null) {
final endDateTime = DateTime(
_endDate!.year,
_endDate!.month,
_endDate!.day,
_endTime!.hour,
_endTime!.minute,
);
final startDateTime = DateTime(
date.year,
date.month,
date.day,
time.hour,
time.minute,
);
if (endDateTime.isBefore(startDateTime) ||
endDateTime.isAtSameMomentAs(startDateTime)) {
_endDate = date;
_endTime = time.add(const Duration(hours: 1));
}
}
});
}),
_buildTextField(
context.l10n.calendarCreateFieldTitle,
_titleController,
context.l10n.calendarCreateFieldTitleHint,
),
const SizedBox(height: 20),
_buildDateTimePicker(
'结束',
context.l10n.calendarCreateFieldStart,
_startDate,
_startTime,
(date, time) {
setState(() {
_startDate = date;
_startTime = time;
if (_endDate != null && _endTime != null) {
final endDateTime = DateTime(
_endDate!.year,
_endDate!.month,
_endDate!.day,
_endTime!.hour,
_endTime!.minute,
);
final startDateTime = DateTime(
date.year,
date.month,
date.day,
time.hour,
time.minute,
);
if (endDateTime.isBefore(startDateTime) ||
endDateTime.isAtSameMomentAs(startDateTime)) {
_endDate = date;
_endTime = time.add(const Duration(hours: 1));
}
}
});
},
),
const SizedBox(height: 20),
_buildDateTimePicker(
context.l10n.calendarCreateFieldEnd,
_endDate ?? _startDate,
_endTime ?? _startTime,
(date, time) {
@@ -426,15 +440,28 @@ class _CreateEventSheetState extends State<CreateEventSheet>
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTextField('描述', _descriptionController, '请输入描述'),
_buildTextField(
context.l10n.calendarCreateFieldDescription,
_descriptionController,
context.l10n.calendarCreateFieldDescriptionHint,
),
const SizedBox(height: 20),
_buildTextField('地点', _locationController, '请输入地点'),
_buildTextField(
context.l10n.calendarCreateFieldLocation,
_locationController,
context.l10n.calendarCreateFieldLocationHint,
),
const SizedBox(height: 20),
_buildReminderPicker(),
const SizedBox(height: 20),
_buildColorPicker(),
const SizedBox(height: 20),
_buildTextField('备注', _notesController, '请输入备注', maxLines: 3),
_buildTextField(
context.l10n.calendarDetailNotes,
_notesController,
context.l10n.calendarCreateFieldNotesHint,
maxLines: 3,
),
],
),
);
@@ -468,7 +495,7 @@ class _CreateEventSheetState extends State<CreateEventSheet>
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label + (isOptional ? '(可选)' : ''),
isOptional ? context.l10n.calendarCreateOptionalField(label) : label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
@@ -525,7 +552,13 @@ class _CreateEventSheetState extends State<CreateEventSheet>
}
String _formatDateTimeLabel(DateTime date, DateTime time) {
return '${date.year}${date.month}${date.day}${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
return context.l10n.calendarCreateDateTimeLabel(
date.year,
date.month,
date.day,
time.hour.toString().padLeft(2, '0'),
time.minute.toString().padLeft(2, '0'),
);
}
Future<(DateTime, DateTime)?> _pickDateTime(
@@ -550,8 +583,8 @@ class _CreateEventSheetState extends State<CreateEventSheet>
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'颜色',
Text(
context.l10n.calendarDetailColor,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
@@ -591,19 +624,19 @@ class _CreateEventSheetState extends State<CreateEventSheet>
Widget _buildReminderPicker() {
String labelOf(int? value) {
if (value == null) {
return '无提醒';
return context.l10n.calendarCreateReminderNone;
}
if (value == 0) {
return '准时提醒';
return context.l10n.calendarDetailReminderOnTime;
}
return '开始前$value分钟';
return context.l10n.calendarDetailReminderBeforeMinutes(value);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'提醒时间',
Text(
context.l10n.calendarCreateReminderTime,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
@@ -616,7 +649,7 @@ class _CreateEventSheetState extends State<CreateEventSheet>
final options = _buildReminderOptions();
final selected = await showAppSelectionSheet<int?>(
context,
title: '选择提醒时间',
title: context.l10n.calendarCreatePickReminderTime,
items: options
.map((v) => AppSelectionItem(value: v, label: labelOf(v)))
.toList(),
@@ -745,7 +778,11 @@ class _CreateEventSheetState extends State<CreateEventSheet>
await notificationService.upsertEventReminder(saved);
} catch (_) {
if (mounted) {
Toast.show(context, '提醒创建失败,请检查通知权限', type: ToastType.warning);
Toast.show(
context,
context.l10n.calendarCreateReminderPermissionFailed,
type: ToastType.warning,
);
}
}
@@ -755,7 +792,11 @@ class _CreateEventSheetState extends State<CreateEventSheet>
}
} catch (e) {
if (mounted) {
Toast.show(context, '保存失败: $e', type: ToastType.error);
Toast.show(
context,
context.l10n.todoSaveFailed('$e'),
type: ToastType.error,
);
}
} finally {
if (mounted) {
@@ -1,4 +1,5 @@
import 'package:flutter/cupertino.dart';
import 'package:social_app/core/l10n/l10n.dart';
import '../../../../core/theme/design_tokens.dart';
@@ -130,6 +131,8 @@ class _DateTimePickerSheetState extends State<DateTimePickerSheet> {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Container(
height: 420,
decoration: const BoxDecoration(
@@ -148,7 +151,7 @@ class _DateTimePickerSheetState extends State<DateTimePickerSheet> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildPickerLabel('日期'),
_buildPickerLabel(l10n.calendarDateTimePickerDateLabel),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
@@ -165,8 +168,8 @@ class _DateTimePickerSheetState extends State<DateTimePickerSheet> {
});
}, (v) => '$v'),
),
const Text(
'',
Text(
l10n.calendarDateTimePickerYearUnit,
style: TextStyle(
fontSize: 14,
color: AppColors.slate600,
@@ -186,8 +189,8 @@ class _DateTimePickerSheetState extends State<DateTimePickerSheet> {
});
}, (v) => '$v'),
),
const Text(
'',
Text(
l10n.calendarDateTimePickerMonthUnit,
style: TextStyle(
fontSize: 14,
color: AppColors.slate600,
@@ -201,8 +204,8 @@ class _DateTimePickerSheetState extends State<DateTimePickerSheet> {
(v) => '$v',
),
),
const Text(
'',
Text(
l10n.calendarDateTimePickerDayUnit,
style: TextStyle(
fontSize: 14,
color: AppColors.slate600,
@@ -220,7 +223,7 @@ class _DateTimePickerSheetState extends State<DateTimePickerSheet> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildPickerLabel('时间'),
_buildPickerLabel(l10n.calendarDateTimePickerTimeLabel),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
@@ -285,6 +288,8 @@ class _DateTimePickerSheetState extends State<DateTimePickerSheet> {
}
Widget _buildHeader() {
final l10n = context.l10n;
return Container(
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 16),
@@ -297,13 +302,13 @@ class _DateTimePickerSheetState extends State<DateTimePickerSheet> {
children: [
GestureDetector(
onTap: () => Navigator.pop(context),
child: const Text(
'取消',
style: TextStyle(fontSize: 17, color: AppColors.slate600),
child: Text(
l10n.commonCancel,
style: const TextStyle(fontSize: 17, color: AppColors.slate600),
),
),
const Text(
'选择时间',
Text(
l10n.calendarDateTimePickerTitle,
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
@@ -317,9 +322,9 @@ class _DateTimePickerSheetState extends State<DateTimePickerSheet> {
DateTime(2000, 1, 1, _selectedHour, _selectedMinute),
));
},
child: const Text(
'确定',
style: TextStyle(
child: Text(
l10n.commonConfirm,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.blue600,
@@ -1,23 +0,0 @@
enum ReminderAction {
archive('archive'),
snooze10m('snooze10m');
const ReminderAction(this.value);
final String value;
static ReminderAction fromValue(String raw) {
switch (raw) {
case 'archive':
case 'cancel':
case 'auto_archive':
return ReminderAction.archive;
case 'snooze10m':
case 'snooze_10m':
case 'timeout_30s':
return ReminderAction.snooze10m;
default:
throw ArgumentError.value(raw, 'raw', 'Unsupported reminder action');
}
}
}
@@ -1,182 +0,0 @@
class ReminderPayload {
final String eventId;
final String title;
final DateTime startAt;
final DateTime? endAt;
final String timezone;
final String? location;
final String? notes;
final String? color;
final ReminderPayloadMode mode;
final List<String> aggregateIds;
final int? fireTimeBucket;
final int version;
const ReminderPayload({
required this.eventId,
required this.title,
required this.startAt,
required this.timezone,
this.endAt,
this.location,
this.notes,
this.color,
this.mode = ReminderPayloadMode.single,
this.aggregateIds = const [],
this.fireTimeBucket,
this.version = 1,
});
ReminderPayload copyWith({
String? eventId,
String? title,
DateTime? startAt,
DateTime? endAt,
String? timezone,
String? location,
String? notes,
String? color,
ReminderPayloadMode? mode,
List<String>? aggregateIds,
int? fireTimeBucket,
int? version,
}) {
return ReminderPayload(
eventId: eventId ?? this.eventId,
title: title ?? this.title,
startAt: startAt ?? this.startAt,
endAt: endAt ?? this.endAt,
timezone: timezone ?? this.timezone,
location: location ?? this.location,
notes: notes ?? this.notes,
color: color ?? this.color,
mode: mode ?? this.mode,
aggregateIds: aggregateIds ?? this.aggregateIds,
fireTimeBucket: fireTimeBucket ?? this.fireTimeBucket,
version: version ?? this.version,
);
}
Map<String, dynamic> toJson() {
return {
'eventId': eventId,
'title': title,
'startAt': startAt.toIso8601String(),
'endAt': endAt?.toIso8601String(),
'timezone': timezone,
'location': location,
'notes': notes,
'color': color,
'mode': mode.value,
'aggregateIds': aggregateIds,
'fireTimeBucket': fireTimeBucket,
'version': version,
};
}
factory ReminderPayload.fromJson(Map<String, dynamic> json) {
final eventId = (json['eventId'] as String?) ?? '';
if (eventId.isEmpty) {
throw const FormatException('eventId is required');
}
final startAtRaw = json['startAt'] as String?;
if (startAtRaw == null || startAtRaw.isEmpty) {
throw const FormatException('startAt is required');
}
final parsedStartAt = DateTime.parse(startAtRaw);
final mode = ReminderPayloadMode.fromValue(
(json['mode'] as String?) ?? 'single',
);
final aggregateIds = (json['aggregateIds'] as List<dynamic>? ?? const [])
.map((item) => item.toString())
.toList();
if (mode == ReminderPayloadMode.aggregate && aggregateIds.length < 2) {
throw const FormatException('aggregateIds must contain at least 2 items');
}
return ReminderPayload(
eventId: eventId,
title: (json['title'] as String?) ?? '',
startAt: parsedStartAt,
endAt: json['endAt'] != null
? DateTime.parse(json['endAt'] as String)
: null,
timezone: (json['timezone'] as String?) ?? 'UTC',
location: json['location'] as String?,
notes: json['notes'] as String?,
color: json['color'] as String?,
mode: mode,
aggregateIds: aggregateIds,
fireTimeBucket: json['fireTimeBucket'] as int?,
version: (json['version'] as int?) ?? 1,
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
return other is ReminderPayload &&
other.eventId == eventId &&
other.title == title &&
other.startAt == startAt &&
other.endAt == endAt &&
other.timezone == timezone &&
other.location == location &&
other.notes == notes &&
other.color == color &&
other.mode == mode &&
_listEquals(other.aggregateIds, aggregateIds) &&
other.fireTimeBucket == fireTimeBucket &&
other.version == version;
}
@override
int get hashCode {
return Object.hash(
eventId,
title,
startAt,
endAt,
timezone,
location,
notes,
color,
mode,
Object.hashAll(aggregateIds),
fireTimeBucket,
version,
);
}
}
enum ReminderPayloadMode {
single('single'),
aggregate('aggregate');
const ReminderPayloadMode(this.value);
final String value;
static ReminderPayloadMode fromValue(String raw) {
return ReminderPayloadMode.values.firstWhere(
(item) => item.value == raw,
orElse: () => ReminderPayloadMode.single,
);
}
}
bool _listEquals(List<String> left, List<String> right) {
if (left.length != right.length) {
return false;
}
for (var i = 0; i < left.length; i++) {
if (left[i] != right[i]) {
return false;
}
}
return true;
}
@@ -1,67 +0,0 @@
import '../data/services/calendar_service.dart';
import '../../../core/notifications/local_notification_service.dart';
import 'models/reminder_action.dart';
import 'models/reminder_payload.dart';
class ReminderActionExecutor {
final CalendarService _calendarService;
final LocalNotificationService _notificationService;
ReminderActionExecutor({
required CalendarService calendarService,
required LocalNotificationService notificationService,
}) : _calendarService = calendarService,
_notificationService = notificationService;
Future<void> handleAction({
required ReminderAction action,
required ReminderPayload payload,
}) async {
final ids = payload.mode == ReminderPayloadMode.aggregate
? (payload.aggregateIds.isNotEmpty
? payload.aggregateIds
: <String>[payload.eventId])
: <String>[payload.eventId];
if (action == ReminderAction.archive) {
for (final id in ids) {
await _notificationService.cancelEventReminder(id);
await _archiveEvent(id);
}
return;
}
if (action == ReminderAction.snooze10m) {
for (final id in ids) {
await _snoozeEvent(id);
}
}
}
Future<void> _snoozeEvent(String eventId) async {
final event = await _calendarService.getEventById(eventId);
if (event == null) {
return;
}
final now = DateTime.now();
final endAt = event.endAt;
if (endAt != null && !now.isBefore(endAt)) {
await _notificationService.cancelEventReminder(eventId);
await _archiveEvent(eventId);
return;
}
final nextAt = now.add(const Duration(minutes: 10));
if (endAt != null && !nextAt.isBefore(endAt)) {
await _notificationService.cancelEventReminder(eventId);
await _archiveEvent(eventId);
return;
}
await _notificationService.scheduleReminderAt(event, nextAt);
}
Future<void> _archiveEvent(String eventId) async {
await _calendarService.archiveEvent(eventId);
}
}
@@ -1,31 +0,0 @@
import 'models/reminder_payload.dart';
class ReminderQueueManager {
ReminderPayload? _currentPayload;
final List<ReminderPayload> _pending = [];
void enqueueFromClick(ReminderPayload payload) {
_currentPayload = payload;
}
void enqueuePending(List<ReminderPayload> payloads) {
payloads.sort((a, b) => a.startAt.compareTo(b.startAt));
_pending.addAll(payloads);
}
ReminderPayload? get currentPayload => _currentPayload;
bool get isEmpty => _currentPayload == null && _pending.isEmpty;
void dequeueCurrent() {
_currentPayload = null;
if (_pending.isNotEmpty) {
_currentPayload = _pending.removeAt(0);
}
}
void clear() {
_currentPayload = null;
_pending.clear();
}
}
@@ -1,184 +0,0 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../reminders/reminder_queue_manager.dart';
import '../../reminders/models/reminder_payload.dart';
class ReminderOverlay extends StatefulWidget {
const ReminderOverlay({
super.key,
required this.queueManager,
required this.onComplete,
required this.onSnooze,
required this.onArchive,
});
final ReminderQueueManager queueManager;
final VoidCallback onComplete;
final void Function(int minutes) onSnooze;
final VoidCallback onArchive;
@override
State<ReminderOverlay> createState() => _ReminderOverlayState();
}
class _ReminderOverlayState extends State<ReminderOverlay> {
OverlayEntry? _overlayEntry;
ReminderPayload? get _currentPayload => widget.queueManager.currentPayload;
@override
void dispose() {
_hideSnoozeOptions();
super.dispose();
}
void _hideSnoozeOptions() {
_overlayEntry?.remove();
_overlayEntry = null;
}
void _showSnoozeDropdown() {
_hideSnoozeOptions();
final box = context.findRenderObject() as RenderBox?;
if (box == null) return;
final button = box.localToGlobal(Offset.zero);
_overlayEntry = OverlayEntry(
builder: (context) => Positioned(
left: button.dx,
top: button.dy + box.size.height + 4,
width: 120,
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(8),
child: Container(
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.borderSecondary),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_SnoozeOption(
label: '5 分钟',
onTap: () {
_hideSnoozeOptions();
_handleSnooze(5);
},
),
const Divider(height: 1, color: AppColors.borderSecondary),
_SnoozeOption(
label: '15 分钟',
onTap: () {
_hideSnoozeOptions();
_handleSnooze(15);
},
),
],
),
),
),
),
);
Overlay.of(context).insert(_overlayEntry!);
}
void _handleComplete() {
widget.onArchive();
widget.queueManager.dequeueCurrent();
widget.onComplete();
}
void _handleSnooze(int minutes) {
widget.onSnooze(minutes);
widget.queueManager.dequeueCurrent();
widget.onComplete();
}
@override
Widget build(BuildContext context) {
final payload = _currentPayload;
if (payload == null) {
return const SizedBox.shrink();
}
return Container(
color: AppColors.white,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
payload.title,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: AppColors.slate900,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
const SizedBox(height: AppSpacing.sm),
Text(
DateFormat('HH:mm').format(DateTime.now()),
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(color: AppColors.slate500),
textAlign: TextAlign.center,
),
const SizedBox(height: AppSpacing.xl),
Row(
children: [
Expanded(
child: AppButton(
text: '稍后提醒',
isOutlined: true,
onPressed: _showSnoozeDropdown,
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: AppButton(text: '完成', onPressed: _handleComplete),
),
],
),
],
),
),
),
);
}
}
class _SnoozeOption extends StatelessWidget {
const _SnoozeOption({required this.label, required this.onTap});
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
child: Text(
label,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: AppColors.slate900),
),
),
);
}
}