feat(apps/calendar): 新增日历事件创建/编辑/分享功能

This commit is contained in:
zl-q
2026-03-19 00:51:51 +08:00
parent adccecd691
commit 14ccf2cb28
8 changed files with 959 additions and 654 deletions
@@ -1,6 +1,8 @@
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 '../../../../core/di/injection.dart';
import '../../../../core/theme/design_tokens.dart';
@@ -14,7 +16,6 @@ import '../dayweek/day_event_layout_engine.dart';
import '../dayweek/day_timeline_metrics.dart';
import '../dayweek/day_view_scale.dart';
import '../widgets/bottom_dock.dart';
import '../widgets/create_event_sheet.dart';
class CalendarDayWeekScreen extends StatefulWidget {
final DateTime? initialDate;
@@ -118,7 +119,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
canPop: false,
onPopInvokedWithResult: (didPop, result) {
if (!didPop) {
context.go('/home');
returnToHomePreserveState(context);
}
},
child: SafeArea(
@@ -313,10 +314,8 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
if (isNotToday) const SizedBox(width: 8),
AppPressable(
borderRadius: BorderRadius.circular(AppRadius.full),
onTap: () => CreateEventSheet.show(
context,
initialDate: _selectedDate,
onSaved: _loadEvents,
onTap: () => context.push(
'${AppRoutes.calendarEventCreate}?date=${formatYmd(_selectedDate)}',
),
child: Container(
width: 36,
@@ -636,7 +635,8 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
height: tapHeight,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => context.push('/calendar/events/${layout.event.id}'),
onTap: () =>
context.push(AppRoutes.calendarEventDetail(layout.event.id)),
child: Stack(
children: [
Positioned(
@@ -696,13 +696,13 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
activeTab: DockTab.calendar,
onTodoTap: () {
_calendarManager.setViewType(CalendarViewType.day);
context.go('/todo');
context.push(AppRoutes.todoList);
},
onCalendarTap: () {
_calendarManager.setViewType(CalendarViewType.day);
context.go('/calendar/month');
context.push(AppRoutes.calendarMonth);
},
onHomeTap: () => context.go('/home'),
onHomeTap: () => returnToHomePreserveState(context),
);
}
}
@@ -0,0 +1,20 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/design_tokens.dart';
import '../widgets/create_event_sheet.dart';
class CalendarEventCreateScreen extends StatelessWidget {
final DateTime? initialDate;
const CalendarEventCreateScreen({super.key, this.initialDate});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
body: SafeArea(
child: CreateEventSheet(initialDate: initialDate, pageMode: true),
),
);
}
}
@@ -2,6 +2,7 @@ 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 '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_loading_indicator.dart';
@@ -11,8 +12,6 @@ import '../../../../shared/widgets/destructive_action_sheet.dart';
import '../../data/services/calendar_service.dart';
import '../../data/models/schedule_item_model.dart';
import '../utils/event_color_resolver.dart';
import '../widgets/create_event_sheet.dart';
import '../widgets/calendar_share_dialog.dart';
enum _CalendarHeaderAction { edit, delete, share }
@@ -58,34 +57,50 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
}
if (_event == null) {
return Scaffold(
backgroundColor: const Color(0xFFF8FAFC),
backgroundColor: AppColors.background,
body: SafeArea(
child: Center(
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [AppColors.homeBackgroundTop, AppColors.background],
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Event not found',
style: TextStyle(color: AppColors.slate600),
),
const SizedBox(height: 16),
SizedBox(
height: AppSpacing.xxl * 2,
child: TextButton(
onPressed: () => context.pop(),
style: TextButton.styleFrom(
const BackTitlePageHeader(title: '日程详情'),
Expanded(
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
horizontal: AppSpacing.xl,
),
backgroundColor: AppColors.blue600,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.full),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Text(
'未找到该日程',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.slate700,
),
),
const SizedBox(height: AppSpacing.sm),
const Text(
'可能已被删除,或你没有访问权限。',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: AppColors.slate500,
),
),
],
),
),
child: const Text(
'返回',
style: TextStyle(color: AppColors.white),
),
),
),
],
@@ -97,15 +112,41 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
final event = _event!;
return Scaffold(
backgroundColor: const Color(0xFFF8FAFC),
backgroundColor: AppColors.background,
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(event),
Expanded(child: _buildDetailOverlay(event)),
_buildInputContainer(),
],
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [AppColors.homeBackgroundTop, AppColors.background],
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(event),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(
AppSpacing.lg,
AppSpacing.sm,
AppSpacing.lg,
AppSpacing.xl,
),
children: [
_buildHeroSurface(event),
const SizedBox(height: AppSpacing.md),
_buildMetaSurface(event),
if (_hasExtraInfo(event)) ...[
const SizedBox(height: AppSpacing.md),
_buildExtraSurface(event),
],
],
),
),
],
),
),
),
);
@@ -123,7 +164,7 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
final items = <DetailHeaderActionItem<_CalendarHeaderAction>>[];
if (event.canEdit) {
items.add(
const DetailHeaderActionItem<_CalendarHeaderAction>(
DetailHeaderActionItem<_CalendarHeaderAction>(
value: _CalendarHeaderAction.edit,
label: '编辑',
icon: LucideIcons.pencil,
@@ -132,7 +173,7 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
}
if (event.canDelete) {
items.add(
const DetailHeaderActionItem<_CalendarHeaderAction>(
DetailHeaderActionItem<_CalendarHeaderAction>(
value: _CalendarHeaderAction.delete,
label: '删除',
icon: LucideIcons.trash2,
@@ -142,7 +183,7 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
}
if (event.canInvite) {
items.add(
const DetailHeaderActionItem<_CalendarHeaderAction>(
DetailHeaderActionItem<_CalendarHeaderAction>(
value: _CalendarHeaderAction.share,
label: '分享',
icon: LucideIcons.share2,
@@ -162,85 +203,220 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
) {
switch (action) {
case _CalendarHeaderAction.edit:
CreateEventSheet.edit(
context,
event,
onSaved: () {
setState(() {
_loadEvent();
});
},
);
context.push(AppRoutes.calendarEventEdit(event.id)).then((_) {
_loadEvent();
});
return;
case _CalendarHeaderAction.delete:
_showDeleteConfirmation();
return;
case _CalendarHeaderAction.share:
CalendarShareDialog.show(
context,
event.id,
event.title,
canInvite: event.canInvite,
canEdit: event.canEdit,
);
context.push(AppRoutes.calendarEventShare(event.id));
return;
}
}
Widget _buildDetailOverlay(ScheduleItemModel event) {
final startAt = event.startAt;
final endAt = event.endAt;
final dateStr =
'${startAt.year}${startAt.month}${startAt.day}${_getWeekday(startAt.weekday)}';
final timeStr = endAt != null
? '${_formatTime(startAt)} - ${_formatTime(endAt)}'
: _formatTime(startAt);
Widget _buildHeroSurface(ScheduleItemModel event) {
final color = resolveEventColor(
status: event.status,
colorHex: event.metadata?.color,
);
final timeRange = _formatRangeLabel(event.startAt, event.endAt);
return Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 8, bottom: 12),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: const Color(0xFFD8E3F5)),
),
child: SingleChildScrollView(
child: SizedBox(
return Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: AppColors.borderTertiary),
boxShadow: [
BoxShadow(
color: AppColors.slate200.withValues(alpha: 0.38),
blurRadius: AppRadius.lg,
offset: const Offset(0, AppSpacing.xs),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: AppSpacing.sm,
height: AppSpacing.xxl,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(AppRadius.full),
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
event.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: AppColors.slate900,
),
),
const SizedBox(height: AppSpacing.xs),
_buildStatusBadge(event.status),
],
),
),
],
),
const SizedBox(height: AppSpacing.md),
Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.surfaceInfoLight,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.borderQuaternary),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTitleRow(event),
const SizedBox(height: 14),
Container(height: 1, color: const Color(0xFFE5E7EB)),
const SizedBox(height: 14),
_buildDetailField('日期', dateStr),
const SizedBox(height: 14),
_buildDetailField('时间范围', timeStr),
const SizedBox(height: 14),
_buildDetailField(
'提醒时间',
_formatReminderText(event.metadata?.reminderMinutes),
const Text(
'时间安排',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.slate500,
),
),
const SizedBox(height: AppSpacing.xs),
Text(
timeRange,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.slate800,
),
),
const SizedBox(height: 14),
_buildColorField(event),
const SizedBox(height: 14),
if (event.metadata?.location != null) ...[
_buildDetailField('地点', event.metadata!.location!),
const SizedBox(height: 14),
],
if (event.description != null) ...[
_buildDetailField('描述', event.description!),
const SizedBox(height: 14),
],
if (event.metadata?.notes != null) ...[
_buildNotesField(event.metadata!.notes!),
],
],
),
),
),
],
),
);
}
Widget _buildMetaSurface(ScheduleItemModel event) {
final startAt = event.startAt;
final dateStr =
'${startAt.year}${startAt.month}${startAt.day}${_getWeekday(startAt.weekday)}';
final color = resolveEventColor(
status: event.status,
colorHex: event.metadata?.color,
);
return Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: AppColors.borderSecondary),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'基础信息',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.slate500,
),
),
const SizedBox(height: AppSpacing.md),
_buildDetailRow('日期', dateStr),
const SizedBox(height: AppSpacing.md),
_buildDetailRow(
'提醒',
_formatReminderText(event.metadata?.reminderMinutes),
),
const SizedBox(height: AppSpacing.md),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(
width: AppSpacing.xxl * 3,
child: Text(
'颜色',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.slate500,
),
),
),
Container(
width: AppSpacing.xl,
height: AppSpacing.xl,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(color: AppColors.borderSecondary),
),
),
],
),
],
),
);
}
bool _hasExtraInfo(ScheduleItemModel event) {
return (event.metadata?.location?.trim().isNotEmpty ?? false) ||
(event.description?.trim().isNotEmpty ?? false) ||
(event.metadata?.notes?.trim().isNotEmpty ?? false);
}
Widget _buildExtraSurface(ScheduleItemModel event) {
return Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.surfaceSecondary,
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: AppColors.borderSecondary),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'补充信息',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.slate500,
),
),
if (event.metadata?.location?.trim().isNotEmpty ?? false) ...[
const SizedBox(height: AppSpacing.md),
_buildDetailRow('地点', event.metadata!.location!.trim()),
],
if (event.description?.trim().isNotEmpty ?? false) ...[
const SizedBox(height: AppSpacing.md),
_buildDetailRow('描述', event.description!.trim()),
],
if (event.metadata?.notes?.trim().isNotEmpty ?? false) ...[
const SizedBox(height: AppSpacing.md),
_buildDetailRow(
'备注',
event.metadata!.notes!.trim(),
multiline: true,
),
],
],
),
);
}
@@ -264,44 +440,6 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
}
Widget _buildTitleRow(ScheduleItemModel event) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 4,
height: 20,
decoration: BoxDecoration(
color: resolveEventColor(
status: event.status,
colorHex: event.metadata?.color,
),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 10),
Flexible(
child: Text(
event.title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.slate900,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
);
}
Future<void> _showDeleteConfirmation() async {
final confirmed = await showDestructiveActionSheet(
context,
@@ -322,140 +460,76 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
context.pop();
}
Widget _buildDetailField(String label, String value) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.slate400,
),
),
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.slate900,
),
),
],
);
String _formatRangeLabel(DateTime startAt, DateTime? endAt) {
final dateLabel =
'${startAt.month}${startAt.day}${_getWeekday(startAt.weekday)}';
if (endAt == null) {
return '$dateLabel ${_formatTime(startAt)}';
}
return '$dateLabel ${_formatTime(startAt)} - ${_formatTime(endAt)}';
}
Widget _buildColorField(ScheduleItemModel event) {
final color = resolveEventColor(
status: event.status,
colorHex: event.metadata?.color,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'日程颜色',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.slate400,
),
),
const SizedBox(height: 6),
Row(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
],
),
],
);
}
Widget _buildNotesField(String notes) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'备注',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.slate400,
),
),
const SizedBox(height: 6),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFFDFEFF),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFDCE5F4)),
),
child: Text(
notes,
style: const TextStyle(fontSize: 14, color: AppColors.slate700),
),
),
],
);
}
Widget _buildInputContainer() {
Widget _buildStatusBadge(ScheduleStatus status) {
final isArchived = status == ScheduleStatus.archived;
return Container(
height: 80,
padding: const EdgeInsets.all(16),
color: const Color(0xFFF8FAFC),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: const Color(0xFFDCE5F4)),
),
child: const Icon(
LucideIcons.plus,
size: 20,
color: AppColors.slate500,
),
),
const SizedBox(width: 8),
Expanded(
child: Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
),
child: Row(
children: [
const Expanded(
child: Text(
'输入消息...',
style: TextStyle(fontSize: 14, color: AppColors.slate400),
),
),
const Icon(
LucideIcons.mic,
size: 20,
color: AppColors.slate500,
),
],
),
),
),
],
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: AppSpacing.xs,
),
decoration: BoxDecoration(
color: isArchived
? AppColors.feedbackWarningSurface
: AppColors.feedbackSuccessSurface,
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(
color: isArchived
? AppColors.feedbackWarningBorder
: AppColors.feedbackSuccessBorder,
),
),
child: Text(
isArchived ? '已过期' : '启用',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: isArchived
? AppColors.feedbackWarningText
: AppColors.feedbackSuccessText,
),
),
);
}
Widget _buildDetailRow(String label, String value, {bool multiline = false}) {
return Row(
crossAxisAlignment: multiline
? CrossAxisAlignment.start
: CrossAxisAlignment.center,
children: [
SizedBox(
width: AppSpacing.xxl * 3,
child: Text(
label,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.slate500,
),
),
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
value,
style: TextStyle(
fontSize: 14,
height: multiline ? 1.4 : 1.0,
fontWeight: FontWeight.w600,
color: AppColors.slate800,
),
),
),
],
);
}
}
@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_loading_indicator.dart';
import '../widgets/create_event_sheet.dart';
import '../../data/models/schedule_item_model.dart';
import '../../data/services/calendar_service.dart';
class CalendarEventEditScreen extends StatefulWidget {
final String eventId;
const CalendarEventEditScreen({super.key, required this.eventId});
@override
State<CalendarEventEditScreen> createState() =>
_CalendarEventEditScreenState();
}
class _CalendarEventEditScreenState extends State<CalendarEventEditScreen> {
ScheduleItemModel? _event;
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
_event = await sl<CalendarService>().getEventById(widget.eventId);
} catch (_) {
_event = null;
}
if (!mounted) {
return;
}
setState(() {
_loading = false;
});
}
@override
Widget build(BuildContext context) {
if (_loading) {
return const Scaffold(
body: SafeArea(child: Center(child: AppLoadingIndicator(size: 22))),
);
}
if (_event == null) {
return const Scaffold(
body: SafeArea(child: Center(child: Text('日程不存在或无权限'))),
);
}
return Scaffold(
backgroundColor: AppColors.background,
body: SafeArea(
child: CreateEventSheet(editingEvent: _event, pageMode: true),
),
);
}
}
@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_loading_indicator.dart';
import '../../../../shared/widgets/back_title_page_header.dart';
import '../../data/models/schedule_item_model.dart';
import '../../data/services/calendar_service.dart';
import '../widgets/calendar_share_dialog.dart';
class CalendarEventShareScreen extends StatefulWidget {
final String eventId;
const CalendarEventShareScreen({super.key, required this.eventId});
@override
State<CalendarEventShareScreen> createState() =>
_CalendarEventShareScreenState();
}
class _CalendarEventShareScreenState extends State<CalendarEventShareScreen> {
ScheduleItemModel? _event;
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
_event = await sl<CalendarService>().getEventById(widget.eventId);
} catch (_) {
_event = null;
}
if (!mounted) {
return;
}
setState(() {
_loading = false;
});
}
@override
Widget build(BuildContext context) {
if (_loading) {
return const Scaffold(
body: SafeArea(child: Center(child: AppLoadingIndicator(size: 22))),
);
}
final event = _event;
if (event == null) {
return const Scaffold(
body: SafeArea(child: Center(child: Text('日程不存在或无权限'))),
);
}
return Scaffold(
backgroundColor: AppColors.background,
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const BackTitlePageHeader(title: '分享日程'),
Expanded(
child: CalendarShareDialog(
eventId: event.id,
eventTitle: event.title,
canInvite: event.canInvite,
canEdit: event.canEdit,
),
),
],
),
),
);
}
}
@@ -3,13 +3,14 @@ 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 '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_pressable.dart';
import '../../../home/ui/navigation/home_return_policy.dart';
import '../calendar_state_manager.dart';
import '../calendar_time_utils.dart';
import '../utils/event_color_resolver.dart';
import '../widgets/bottom_dock.dart';
import '../widgets/create_event_sheet.dart';
import '../../data/models/schedule_item_model.dart';
import '../../data/services/calendar_service.dart';
@@ -104,7 +105,7 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
canPop: false,
onPopInvokedWithResult: (didPop, result) {
if (!didPop) {
context.go('/home');
returnToHomePreserveState(context);
}
},
child: SafeArea(
@@ -171,10 +172,7 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
const Spacer(),
AppPressable(
borderRadius: BorderRadius.circular(AppRadius.full),
onTap: () => CreateEventSheet.show(
context,
onSaved: _loadMonthEvents,
),
onTap: () => context.push(AppRoutes.calendarEventCreate),
child: Container(
width: 36,
height: 36,
@@ -293,7 +291,9 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
});
_calendarManager.setSelectedDate(date);
_calendarManager.setViewType(CalendarViewType.month);
context.push('/calendar/dayweek?date=${formatYmd(date)}');
context.push(
'${AppRoutes.calendarDayWeek}?date=${formatYmd(date)}',
);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
@@ -400,7 +400,9 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
onTap: () {
_calendarManager.setSelectedDate(date);
_calendarManager.setViewType(CalendarViewType.day);
context.push('/calendar/dayweek?date=${formatYmd(date)}');
context.push(
'${AppRoutes.calendarDayWeek}?date=${formatYmd(date)}',
);
},
child: Text(
'+$remainingCount',
@@ -517,10 +519,10 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
activeTab: DockTab.calendar,
onTodoTap: () {
_calendarManager.setViewType(CalendarViewType.month);
context.go('/todo');
context.push(AppRoutes.todoList);
},
onCalendarTap: () {},
onHomeTap: () => context.go('/home'),
onHomeTap: () => returnToHomePreserveState(context),
);
}
}
@@ -1,4 +1,3 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../../core/di/injection.dart';
@@ -6,8 +5,10 @@ import '../../../../core/notifications/local_notification_service.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_loading_indicator.dart';
import '../../../../shared/widgets/app_sheet_input_field.dart';
import '../../../../shared/widgets/back_title_page_header.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import 'date_time_picker_sheet.dart';
import '../../data/models/schedule_item_model.dart';
import '../../data/services/calendar_service.dart';
@@ -15,12 +16,14 @@ class CreateEventSheet extends StatefulWidget {
final DateTime? initialDate;
final ScheduleItemModel? editingEvent;
final VoidCallback? onSaved;
final bool pageMode;
const CreateEventSheet({
super.key,
this.initialDate,
this.editingEvent,
this.onSaved,
this.pageMode = false,
});
static Future<void> show(
@@ -135,6 +138,20 @@ class _CreateEventSheetState extends State<CreateEventSheet>
@override
Widget build(BuildContext context) {
if (widget.pageMode) {
return Container(
color: AppColors.background,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildPageHeader(),
_buildTabBar(),
Expanded(child: _buildTabContent()),
],
),
);
}
return AnimatedPadding(
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
@@ -171,6 +188,44 @@ class _CreateEventSheetState extends State<CreateEventSheet>
);
}
Widget _buildPageHeader() {
return BackTitlePageHeader(
title: _isEditing ? '编辑日程' : '新建日程',
onBack: () => Navigator.of(context).pop(),
trailing: ValueListenableBuilder<TextEditingValue>(
valueListenable: _titleController,
builder: (context, value, child) {
final enabled = value.text.trim().isNotEmpty && !_saving;
return SizedBox(
height: AppSpacing.xxl * 2,
child: TextButton(
onPressed: enabled ? _saveEvent : null,
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
minimumSize: const Size(AppSpacing.none, AppSpacing.none),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: _saving
? const AppLoadingIndicator(
variant: AppLoadingVariant.button,
size: 18,
trackColor: AppColors.blue200,
)
: Text(
'保存',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: enabled ? AppColors.blue600 : AppColors.slate400,
),
),
),
);
},
),
);
}
Widget _buildHeader() {
return Container(
height: 56,
@@ -473,7 +528,7 @@ class _CreateEventSheetState extends State<CreateEventSheet>
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) => _DateTimePickerSheet(
builder: (context) => DateTimePickerSheet(
initialDate: date,
initialTime: time,
minTime: minTime,
@@ -587,10 +642,7 @@ class _CreateEventSheetState extends State<CreateEventSheet>
}
int? _sanitizeReminderMinutes(int? minutes) {
if (minutes == null || minutes < 0) {
return null;
}
return minutes;
return (minutes != null && minutes >= 0) ? minutes : null;
}
List<int?> _buildReminderOptions() {
@@ -692,368 +744,3 @@ class _CreateEventSheetState extends State<CreateEventSheet>
}
}
}
class _DateTimePickerSheet extends StatefulWidget {
final DateTime initialDate;
final DateTime initialTime;
final DateTime? minTime;
const _DateTimePickerSheet({
required this.initialDate,
required this.initialTime,
this.minTime,
});
@override
State<_DateTimePickerSheet> createState() => _DateTimePickerSheetState();
}
class _DateTimePickerSheetState extends State<_DateTimePickerSheet> {
late int _selectedYear;
late int _selectedMonth;
late int _selectedDay;
late int _selectedHour;
late int _selectedMinute;
late FixedExtentScrollController _yearController;
late FixedExtentScrollController _monthController;
late FixedExtentScrollController _dayController;
late FixedExtentScrollController _hourController;
late FixedExtentScrollController _minuteController;
static final int _baseYear = DateTime.now().year;
static final List<int> _years = List.generate(21, (i) => _baseYear - 10 + i);
static final List<int> _months = List.generate(12, (i) => i + 1);
static final List<int> _allHours = List.generate(24, (i) => i);
static final List<int> _allMinutes = List.generate(60, (i) => i);
List<int> _days = [];
late List<int> _filteredHours;
late List<int> _filteredMinutes;
List<int> _getFilteredHours() {
if (widget.minTime == null) return _allHours;
final minDate = widget.minTime!;
if (_selectedYear > minDate.year ||
(_selectedYear == minDate.year && _selectedMonth > minDate.month) ||
(_selectedYear == minDate.year &&
_selectedMonth == minDate.month &&
_selectedDay > minDate.day)) {
return _allHours;
}
if (_selectedYear == minDate.year &&
_selectedMonth == minDate.month &&
_selectedDay == minDate.day) {
return _allHours.where((h) => h > minDate.hour).toList();
}
return _allHours;
}
List<int> _getFilteredMinutes() {
if (widget.minTime == null) return _allMinutes;
final minDate = widget.minTime!;
if (_selectedYear > minDate.year ||
(_selectedYear == minDate.year && _selectedMonth > minDate.month) ||
(_selectedYear == minDate.year &&
_selectedMonth == minDate.month &&
_selectedDay > minDate.day)) {
return _allMinutes;
}
if (_selectedYear == minDate.year &&
_selectedMonth == minDate.month &&
_selectedDay == minDate.day &&
_selectedHour == minDate.hour) {
return _allMinutes.where((m) => m > minDate.minute).toList();
}
return _allMinutes;
}
@override
void initState() {
super.initState();
_selectedYear = widget.initialDate.year;
_selectedMonth = widget.initialDate.month;
_selectedDay = widget.initialDate.day;
_selectedHour = widget.initialTime.hour;
_selectedMinute = widget.initialTime.minute;
_filteredHours = _getFilteredHours();
_filteredMinutes = _getFilteredMinutes();
_updateDays();
_yearController = FixedExtentScrollController(
initialItem: _years.indexOf(_selectedYear),
);
_monthController = FixedExtentScrollController(
initialItem: _selectedMonth - 1,
);
_dayController = FixedExtentScrollController(initialItem: _selectedDay - 1);
_hourController = FixedExtentScrollController(
initialItem: _filteredHours.indexOf(_selectedHour),
);
_minuteController = FixedExtentScrollController(
initialItem: _filteredMinutes.indexOf(_selectedMinute),
);
}
void _updateDays() {
_days = List.generate(
DateTime(_selectedYear, _selectedMonth + 1, 0).day,
(i) => i + 1,
);
}
@override
void dispose() {
_yearController.dispose();
_monthController.dispose();
_dayController.dispose();
_hourController.dispose();
_minuteController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
height: 420,
decoration: const BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
_buildHeader(),
Expanded(
child: Row(
children: [
Expanded(
flex: 3,
child: Column(
children: [
_buildPickerLabel('日期'),
Expanded(
child: Row(
children: [
Expanded(
child: _buildPicker(_years, _yearController, (v) {
setState(() {
_selectedYear = v;
_updateDays();
if (_selectedDay > _days.length) {
_selectedDay = _days.length;
_dayController.jumpToItem(_selectedDay - 1);
}
});
}, (v) => '$v'),
),
const Text(
'',
style: TextStyle(
fontSize: 14,
color: AppColors.slate600,
),
),
Expanded(
child: _buildPicker(_months, _monthController, (
v,
) {
setState(() {
_selectedMonth = v;
_updateDays();
if (_selectedDay > _days.length) {
_selectedDay = _days.length;
_dayController.jumpToItem(_selectedDay - 1);
}
});
}, (v) => '$v'),
),
const Text(
'',
style: TextStyle(
fontSize: 14,
color: AppColors.slate600,
),
),
Expanded(
child: _buildPicker(
_days,
_dayController,
(v) => setState(() => _selectedDay = v),
(v) => '$v',
),
),
const Text(
'',
style: TextStyle(
fontSize: 14,
color: AppColors.slate600,
),
),
],
),
),
],
),
),
Container(width: 1, height: 180, color: AppColors.border),
Expanded(
flex: 2,
child: Column(
children: [
_buildPickerLabel('时间'),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: _buildPicker(
_filteredHours,
_hourController,
(v) {
setState(() {
_selectedHour = v;
_filteredMinutes = _getFilteredMinutes();
if (_selectedMinute >
_filteredMinutes.last) {
_selectedMinute =
_filteredMinutes.isNotEmpty
? _filteredMinutes.last
: 0;
_minuteController.jumpToItem(
_filteredMinutes.indexOf(
_selectedMinute,
),
);
}
});
},
(v) => v.toString().padLeft(2, '0'),
itemExtent: 50,
),
),
const Text(
' : ',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppColors.slate600,
),
),
Expanded(
child: _buildPicker(
_filteredMinutes,
_minuteController,
(v) => setState(() => _selectedMinute = v),
(v) => v.toString().padLeft(2, '0'),
itemExtent: 50,
),
),
],
),
),
],
),
),
],
),
),
],
),
);
}
Widget _buildHeader() {
return Container(
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: AppColors.border)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
GestureDetector(
onTap: () => Navigator.pop(context),
child: const Text(
'取消',
style: TextStyle(fontSize: 17, color: AppColors.slate600),
),
),
const Text(
'选择时间',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.slate900,
),
),
GestureDetector(
onTap: () {
Navigator.pop(context, (
DateTime(_selectedYear, _selectedMonth, _selectedDay),
DateTime(2000, 1, 1, _selectedHour, _selectedMinute),
));
},
child: const Text(
'确定',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.blue600,
),
),
),
],
),
);
}
Widget _buildPickerLabel(String label) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(
label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.slate700,
),
),
);
}
Widget _buildPicker(
List<int> items,
FixedExtentScrollController controller,
ValueChanged<int> onChanged,
String Function(int) formatter, {
double itemExtent = 40,
}) {
return CupertinoPicker(
scrollController: controller,
itemExtent: itemExtent,
magnification: 1.2,
squeeze: 0.8,
useMagnifier: true,
onSelectedItemChanged: (index) => onChanged(items[index]),
selectionOverlay: Container(
decoration: BoxDecoration(
border: Border.symmetric(
horizontal: BorderSide(
color: AppColors.blue100.withValues(alpha: 0.5),
width: 1,
),
),
),
),
children: List<Widget>.generate(items.length, (index) {
return Center(
child: Text(
formatter(items[index]),
style: const TextStyle(fontSize: 18, color: AppColors.slate900),
),
);
}),
);
}
}
@@ -0,0 +1,377 @@
import 'package:flutter/cupertino.dart';
import '../../../../core/theme/design_tokens.dart';
class DateTimePickerSheet extends StatefulWidget {
final DateTime initialDate;
final DateTime initialTime;
final DateTime? minTime;
const DateTimePickerSheet({
super.key,
required this.initialDate,
required this.initialTime,
this.minTime,
});
@override
State<DateTimePickerSheet> createState() => _DateTimePickerSheetState();
}
class _DateTimePickerSheetState extends State<DateTimePickerSheet> {
late int _selectedYear;
late int _selectedMonth;
late int _selectedDay;
late int _selectedHour;
late int _selectedMinute;
late FixedExtentScrollController _yearController;
late FixedExtentScrollController _monthController;
late FixedExtentScrollController _dayController;
late FixedExtentScrollController _hourController;
late FixedExtentScrollController _minuteController;
static final int _baseYear = DateTime.now().year;
static final List<int> _years = List.generate(21, (i) => _baseYear - 10 + i);
static final List<int> _months = List.generate(12, (i) => i + 1);
static final List<int> _allHours = List.generate(24, (i) => i);
static final List<int> _allMinutes = List.generate(60, (i) => i);
List<int> _days = [];
late List<int> _filteredHours;
late List<int> _filteredMinutes;
List<int> _getFilteredHours() {
if (widget.minTime == null) return _allHours;
final minDate = widget.minTime!;
if (_selectedYear > minDate.year ||
(_selectedYear == minDate.year && _selectedMonth > minDate.month) ||
(_selectedYear == minDate.year &&
_selectedMonth == minDate.month &&
_selectedDay > minDate.day)) {
return _allHours;
}
if (_selectedYear == minDate.year &&
_selectedMonth == minDate.month &&
_selectedDay == minDate.day) {
return _allHours.where((h) => h > minDate.hour).toList();
}
return _allHours;
}
List<int> _getFilteredMinutes() {
if (widget.minTime == null) return _allMinutes;
final minDate = widget.minTime!;
if (_selectedYear > minDate.year ||
(_selectedYear == minDate.year && _selectedMonth > minDate.month) ||
(_selectedYear == minDate.year &&
_selectedMonth == minDate.month &&
_selectedDay > minDate.day)) {
return _allMinutes;
}
if (_selectedYear == minDate.year &&
_selectedMonth == minDate.month &&
_selectedDay == minDate.day &&
_selectedHour == minDate.hour) {
return _allMinutes.where((m) => m > minDate.minute).toList();
}
return _allMinutes;
}
@override
void initState() {
super.initState();
_selectedYear = widget.initialDate.year;
_selectedMonth = widget.initialDate.month;
_selectedDay = widget.initialDate.day;
_selectedHour = widget.initialTime.hour;
_selectedMinute = widget.initialTime.minute;
_filteredHours = _getFilteredHours();
_filteredMinutes = _getFilteredMinutes();
_updateDays();
_yearController = FixedExtentScrollController(
initialItem: _years.indexOf(_selectedYear),
);
_monthController = FixedExtentScrollController(
initialItem: _selectedMonth - 1,
);
_dayController = FixedExtentScrollController(initialItem: _selectedDay - 1);
_hourController = FixedExtentScrollController(
initialItem: _filteredHours.indexOf(_selectedHour),
);
_minuteController = FixedExtentScrollController(
initialItem: _filteredMinutes.indexOf(_selectedMinute),
);
}
void _updateDays() {
_days = List.generate(
DateTime(_selectedYear, _selectedMonth + 1, 0).day,
(i) => i + 1,
);
}
@override
void dispose() {
_yearController.dispose();
_monthController.dispose();
_dayController.dispose();
_hourController.dispose();
_minuteController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
height: 420,
decoration: const BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
_buildHeader(),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
flex: 3,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildPickerLabel('日期'),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: _buildPicker(_years, _yearController, (v) {
setState(() {
_selectedYear = v;
_updateDays();
if (_selectedDay > _days.length) {
_selectedDay = _days.length;
_dayController.jumpToItem(_selectedDay - 1);
}
});
}, (v) => '$v'),
),
const Text(
'',
style: TextStyle(
fontSize: 14,
color: AppColors.slate600,
),
),
Expanded(
child: _buildPicker(_months, _monthController, (
v,
) {
setState(() {
_selectedMonth = v;
_updateDays();
if (_selectedDay > _days.length) {
_selectedDay = _days.length;
_dayController.jumpToItem(_selectedDay - 1);
}
});
}, (v) => '$v'),
),
const Text(
'',
style: TextStyle(
fontSize: 14,
color: AppColors.slate600,
),
),
Expanded(
child: _buildPicker(
_days,
_dayController,
(v) => setState(() => _selectedDay = v),
(v) => '$v',
),
),
const Text(
'',
style: TextStyle(
fontSize: 14,
color: AppColors.slate600,
),
),
],
),
),
],
),
),
Container(width: 1, height: 180, color: AppColors.border),
Expanded(
flex: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildPickerLabel('时间'),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: _buildPicker(
_filteredHours,
_hourController,
(v) {
setState(() {
_selectedHour = v;
_filteredMinutes = _getFilteredMinutes();
if (_filteredMinutes.isEmpty) {
_selectedMinute = 0;
return;
}
if (_selectedMinute >
_filteredMinutes.last) {
_selectedMinute = _filteredMinutes.last;
_minuteController.jumpToItem(
_filteredMinutes.indexOf(
_selectedMinute,
),
);
}
});
},
(v) => v.toString().padLeft(2, '0'),
itemExtent: 50,
),
),
const Text(
' : ',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppColors.slate600,
),
),
Expanded(
child: _buildPicker(
_filteredMinutes,
_minuteController,
(v) => setState(() => _selectedMinute = v),
(v) => v.toString().padLeft(2, '0'),
itemExtent: 50,
),
),
],
),
),
],
),
),
],
),
),
],
),
);
}
Widget _buildHeader() {
return Container(
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: AppColors.border)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
GestureDetector(
onTap: () => Navigator.pop(context),
child: const Text(
'取消',
style: TextStyle(fontSize: 17, color: AppColors.slate600),
),
),
const Text(
'选择时间',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.slate900,
),
),
GestureDetector(
onTap: () {
Navigator.pop(context, (
DateTime(_selectedYear, _selectedMonth, _selectedDay),
DateTime(2000, 1, 1, _selectedHour, _selectedMinute),
));
},
child: const Text(
'确定',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.blue600,
),
),
),
],
),
);
}
Widget _buildPickerLabel(String label) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(
label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.slate700,
),
textAlign: TextAlign.center,
),
);
}
Widget _buildPicker(
List<int> items,
FixedExtentScrollController controller,
ValueChanged<int> onChanged,
String Function(int) formatter, {
double itemExtent = 40,
}) {
return CupertinoPicker(
scrollController: controller,
itemExtent: itemExtent,
magnification: 1.2,
squeeze: 0.8,
useMagnifier: true,
onSelectedItemChanged: (index) => onChanged(items[index]),
selectionOverlay: Container(
decoration: BoxDecoration(
border: Border.symmetric(
horizontal: BorderSide(
color: AppColors.blue100.withValues(alpha: 0.5),
width: 1,
),
),
),
),
children: List<Widget>.generate(items.length, (index) {
return Center(
child: Text(
formatter(items[index]),
style: const TextStyle(fontSize: 18, color: AppColors.slate900),
),
);
}),
);
}
}