feat: 实现 Auth 全局状态机与 401 统一处理机制

- 新增 AuthSessionInvalidated 事件处理 token 失效场景
- ApiInterceptor 新增 authFailureCallback 单飞机制
- AuthBloc 区分 manual logout 与 auto expiry 语义
- 新增 startup recovery fallback 防止启动卡死

feat: 重构 Calendar DayWeek 视图事件布局引擎

- 新增 DayEventLayoutEngine 解耦事件计算与渲染
- 新增 DayTimelineMetrics 统一时间轴常量
- 新增 DayViewScale 支持捏合缩放

feat: 新增 Settings 页面共享 UI 组件

- 新增 BackTitlePageHeader 统一页面 header
- 新增 DetailHeaderActionMenu 统一操作菜单
- 新增 DestructiveActionSheet 统一删除确认
- 新增 AppToggleSwitch 统一开关组件

feat: Chat UI Schema 支持导航操作

- 支持 navigation 类型 action 触发内部路由跳转
- 新增路径验证与参数处理

chore: 更新相关测试覆盖 auth 失效路径
This commit is contained in:
qzl
2026-03-18 13:35:25 +08:00
parent 19981964fb
commit b34697660d
56 changed files with 2602 additions and 784 deletions
@@ -0,0 +1,184 @@
import '../../data/models/schedule_item_model.dart';
import 'day_timeline_metrics.dart';
import 'day_view_scale.dart';
class DayEventLayout {
final ScheduleItemModel event;
final int startMinutes;
final int endMinutes;
final int column;
final int columnCount;
final double top;
final double geometryHeight;
final double visualHeight;
final double left;
final double width;
const DayEventLayout({
required this.event,
required this.startMinutes,
required this.endMinutes,
required this.column,
required this.columnCount,
required this.top,
required this.geometryHeight,
required this.visualHeight,
required this.left,
required this.width,
});
}
class DayEventLayoutEngine {
const DayEventLayoutEngine();
List<DayEventLayout> layout({
required List<ScheduleItemModel> events,
required DayViewScale scale,
required double eventAreaLeft,
required double eventAreaWidth,
double columnGap = DayTimelineMetrics.eventColumnGap,
}) {
if (events.isEmpty || eventAreaWidth <= 0) {
return const [];
}
final sorted =
events
.map(_EventSpan.fromEvent)
.where((span) => span.endMinutes > span.startMinutes)
.toList()
..sort((a, b) {
final byStart = a.startMinutes.compareTo(b.startMinutes);
if (byStart != 0) {
return byStart;
}
final byEnd = a.endMinutes.compareTo(b.endMinutes);
if (byEnd != 0) {
return byEnd;
}
return a.event.id.compareTo(b.event.id);
});
if (sorted.isEmpty) {
return const [];
}
final active = <_PlacedSpan>[];
final clusters = <List<_PlacedSpan>>[];
List<_PlacedSpan> currentCluster = [];
var clusterEnd = sorted.first.endMinutes;
for (final span in sorted) {
active.removeWhere((item) => item.endMinutes <= span.startMinutes);
if (currentCluster.isNotEmpty && span.startMinutes >= clusterEnd) {
clusters.add(currentCluster);
currentCluster = [];
clusterEnd = span.endMinutes;
} else if (span.endMinutes > clusterEnd) {
clusterEnd = span.endMinutes;
}
final usedColumns = active.map((item) => item.column).toSet();
var column = 0;
while (usedColumns.contains(column)) {
column++;
}
final placed = _PlacedSpan(
event: span.event,
startMinutes: span.startMinutes,
endMinutes: span.endMinutes,
column: column,
);
active.add(placed);
currentCluster.add(placed);
}
if (currentCluster.isNotEmpty) {
clusters.add(currentCluster);
}
final layouts = <DayEventLayout>[];
for (final cluster in clusters) {
final clusterColumnCount =
cluster.map((item) => item.column).reduce((a, b) => a > b ? a : b) +
1;
final totalGap = (clusterColumnCount - 1) * columnGap;
final columnWidth = clusterColumnCount > 0
? ((eventAreaWidth - totalGap) / clusterColumnCount).toDouble()
: eventAreaWidth;
for (final item in cluster) {
final top = scale.pixelsForMinutes(item.startMinutes);
final geometryHeight = scale.pixelsForMinutes(
item.endMinutes - item.startMinutes,
);
final visualHeight = geometryHeight < 1 ? 1.0 : geometryHeight;
final left = eventAreaLeft + item.column * (columnWidth + columnGap);
layouts.add(
DayEventLayout(
event: item.event,
startMinutes: item.startMinutes,
endMinutes: item.endMinutes,
column: item.column,
columnCount: clusterColumnCount,
top: top,
geometryHeight: geometryHeight,
visualHeight: visualHeight,
left: left,
width: columnWidth,
),
);
}
}
return layouts;
}
}
class _EventSpan {
final ScheduleItemModel event;
final int startMinutes;
final int endMinutes;
const _EventSpan({
required this.event,
required this.startMinutes,
required this.endMinutes,
});
factory _EventSpan.fromEvent(ScheduleItemModel event) {
final start = _minutesOfDay(event.startAt);
final end = event.endAt != null ? _minutesOfDay(event.endAt!) : start + 60;
final clampedStart = DayTimelineMetrics.clampMinuteOfDay(start);
var clampedEnd = DayTimelineMetrics.clampMinuteOfDay(end);
if (clampedEnd <= clampedStart) {
clampedEnd = DayTimelineMetrics.clampMinuteOfDay(clampedStart + 1);
}
return _EventSpan(
event: event,
startMinutes: clampedStart,
endMinutes: clampedEnd,
);
}
}
class _PlacedSpan {
final ScheduleItemModel event;
final int startMinutes;
final int endMinutes;
final int column;
const _PlacedSpan({
required this.event,
required this.startMinutes,
required this.endMinutes,
required this.column,
});
}
int _minutesOfDay(DateTime dateTime) {
return dateTime.hour * 60 + dateTime.minute;
}
@@ -0,0 +1,29 @@
import 'day_view_scale.dart';
class DayTimelineMetrics {
static const int hoursInDay = 24;
static const int minutesInHour = 60;
static const int minutesInDay = hoursInDay * minutesInHour;
static const double timeLabelWidth = 44;
static const double timeLabelGap = 8;
static const double eventRightInset = 4;
static const double eventColumnGap = 4;
static double timelineHeight(DayViewScale scale) {
return scale.pixelsForMinutes(minutesInDay);
}
static double eventAreaLeft() {
return timeLabelWidth + timeLabelGap;
}
static double eventAreaWidth(double boardWidth) {
final width = boardWidth - eventAreaLeft() - eventRightInset;
return width > 0 ? width : 0;
}
static int clampMinuteOfDay(int minute) {
return minute.clamp(0, minutesInDay).toInt();
}
}
@@ -0,0 +1,38 @@
class DayViewScale {
static const double defaultHourHeight = 34.0;
static const double minHourHeight = 17.0;
static const double maxHourHeight = 68.0;
final double hourHeight;
const DayViewScale({required this.hourHeight});
factory DayViewScale.defaultScale() {
return const DayViewScale(hourHeight: defaultHourHeight);
}
DayViewScale copyWith({double? hourHeight}) {
return DayViewScale(
hourHeight: _clampHourHeight(hourHeight ?? this.hourHeight),
);
}
DayViewScale zoomByFactor(double factor) {
if (factor <= 0) {
return this;
}
return copyWith(hourHeight: hourHeight * factor);
}
double pixelsForMinutes(int minutes) {
return (minutes / 60) * hourHeight;
}
double minutesForPixels(double pixels) {
return (pixels / hourHeight) * 60;
}
static double _clampHourHeight(double value) {
return value.clamp(minHourHeight, maxHourHeight);
}
}
@@ -1,15 +1,19 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_pressable.dart';
import '../../data/models/schedule_item_model.dart';
import '../../data/services/calendar_service.dart';
import '../calendar_state_manager.dart';
import '../calendar_time_utils.dart';
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';
import '../../data/services/calendar_service.dart';
import '../../data/models/schedule_item_model.dart';
class CalendarDayWeekScreen extends StatefulWidget {
final DateTime? initialDate;
@@ -29,20 +33,20 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
with WidgetsBindingObserver {
static const double _dayItemWidth = 44;
static const double _dayItemGap = 12;
static const double _eventLeftOffset = 52;
static const double _defaultHourHeight = 34.0;
static const double _minHourHeight = 17.0;
static const double _maxHourHeight = 68.0;
static const double _minEventTapHeight = 32;
static const List<String> _dayNames = ['', '', '', '', '', '', ''];
double _hourHeight = _defaultHourHeight;
final DayEventLayoutEngine _layoutEngine = const DayEventLayoutEngine();
final Map<int, Offset> _activePointers = {};
final ScrollController _dayStripController = ScrollController();
DayViewScale _scale = DayViewScale.defaultScale();
DayViewScale _pinchStartScale = DayViewScale.defaultScale();
double? _pinchStartDistance;
double _pinchStartHourHeight = _defaultHourHeight;
late final CalendarStateManager _calendarManager;
late DateTime _selectedDate;
late List<DateTime> _monthDates;
final ScrollController _dayStripController = ScrollController();
List<ScheduleItemModel> _events = const [];
@override
@@ -55,7 +59,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
_calendarManager.resetToToday();
}
_selectedDate = _calendarManager.selectedDate;
_selectedDate = widget.initialDate ?? _calendarManager.selectedDate;
_updateMonthDates();
_loadEvents();
@@ -159,7 +163,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
final today = DateTime.now();
setState(() {
_selectedDate = today;
_hourHeight = _defaultHourHeight;
_scale = DayViewScale.defaultScale();
});
_calendarManager.setSelectedDate(today);
_updateMonthDates();
@@ -172,7 +176,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
if (_activePointers.length == 2) {
final pointers = _activePointers.values.toList(growable: false);
_pinchStartDistance = (pointers[0] - pointers[1]).distance;
_pinchStartHourHeight = _hourHeight;
_pinchStartScale = _scale;
}
}
@@ -192,28 +196,27 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
return;
}
final nextHeight =
(_pinchStartHourHeight * (currentDistance / startDistance)).clamp(
_minHourHeight,
_maxHourHeight,
);
if ((nextHeight - _hourHeight).abs() < 0.1) {
final nextScale = _pinchStartScale.zoomByFactor(
currentDistance / startDistance,
);
if ((nextScale.hourHeight - _scale.hourHeight).abs() < 0.1) {
return;
}
setState(() {
_hourHeight = nextHeight;
_scale = nextScale;
});
}
void _handlePointerUp(PointerUpEvent event) {
_activePointers.remove(event.pointer);
if (_activePointers.length < 2) {
_pinchStartDistance = null;
}
_handlePointerRemove(event.pointer);
}
void _handlePointerCancel(PointerCancelEvent event) {
_activePointers.remove(event.pointer);
_handlePointerRemove(event.pointer);
}
void _handlePointerRemove(int pointer) {
_activePointers.remove(pointer);
if (_activePointers.length < 2) {
_pinchStartDistance = null;
}
@@ -221,6 +224,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
Widget _buildHeader() {
final monthLabel = '${_selectedDate.year}${_selectedDate.month}';
final isNotToday = !isSameDay(_selectedDate, DateTime.now());
return SizedBox(
height: 68,
@@ -281,7 +285,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
),
),
const Spacer(),
if (!isSameDay(_selectedDate, DateTime.now()))
if (isNotToday)
AppPressable(
borderRadius: BorderRadius.circular(AppRadius.xl),
onTap: _goToToday,
@@ -305,8 +309,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
),
),
),
if (!isSameDay(_selectedDate, DateTime.now()))
const SizedBox(width: 8),
if (isNotToday) const SizedBox(width: 8),
AppPressable(
borderRadius: BorderRadius.circular(AppRadius.full),
onTap: () => CreateEventSheet.show(
@@ -413,14 +416,12 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
}
Widget _buildDayItem(DateTime date, bool isSelected, bool isWeekend) {
final dayNames = ['', '', '', '', '', '', ''];
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text(
dayNames[date.weekday % 7],
_dayNames[date.weekday % 7],
style: TextStyle(
fontSize: 11,
color: isWeekend ? AppColors.slate400 : AppColors.slate600,
@@ -456,110 +457,203 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
Widget _buildTimelineBoard() {
final now = DateTime.now();
final showCurrent = shouldShowCurrentMarker(_selectedDate, now);
final events = _events;
final eventColumns = _calculateEventColumns(events);
return LayoutBuilder(
builder: (context, constraints) {
final boardWidth = constraints.maxWidth;
final boardHeight = DayTimelineMetrics.timelineHeight(_scale);
final eventAreaLeft = DayTimelineMetrics.eventAreaLeft();
final eventAreaWidth = DayTimelineMetrics.eventAreaWidth(boardWidth);
return SizedBox(
child: Stack(
clipBehavior: Clip.none,
children: [
Column(
final layouts = _layoutEngine.layout(
events: _events,
scale: _scale,
eventAreaLeft: eventAreaLeft,
eventAreaWidth: eventAreaWidth,
);
return SizedBox(
height: boardHeight,
child: Stack(
children: [
for (var hour = 0; hour <= 23; hour++) ...[
_buildTimelineRow(formatHour(hour)),
if (showCurrent && now.hour == hour)
_buildTimelineRow(formatHm(now), isCurrentTime: true),
],
_buildTimelineRow(formatHour(24), isDisabled: true),
RepaintBoundary(
child: _buildTimelineGrid(
boardHeight: boardHeight,
eventAreaLeft: eventAreaLeft,
),
),
if (showCurrent)
_buildCurrentTimeMarker(now: now, boardHeight: boardHeight),
RepaintBoundary(
child: Stack(
clipBehavior: Clip.none,
children: [
for (final layout in layouts)
_buildEventCard(layout: layout, boardHeight: boardHeight),
],
),
),
],
),
..._buildPositionedEvents(events, eventColumns),
);
},
);
}
Widget _buildTimelineGrid({
required double boardHeight,
required double eventAreaLeft,
}) {
return SizedBox(
height: boardHeight,
child: Stack(
children: [
for (var hour = 0; hour <= DayTimelineMetrics.hoursInDay; hour++)
_buildHourTick(
hour: hour,
boardHeight: boardHeight,
eventAreaLeft: eventAreaLeft,
),
],
),
);
}
List<int> _calculateEventColumns(List<ScheduleItemModel> events) {
if (events.isEmpty) return [];
Widget _buildHourTick({
required int hour,
required double boardHeight,
required double eventAreaLeft,
}) {
final minute = hour * DayTimelineMetrics.minutesInHour;
final y = _scale.pixelsForMinutes(minute);
final isDisabled = hour == DayTimelineMetrics.hoursInDay;
final labelTop = (y - 7).clamp(0.0, boardHeight - 14);
final columns = List<int>.filled(events.length, -1);
final columnHeights = <int, int>{};
for (var i = 0; i < events.length; i++) {
final event = events[i];
final eventStart = event.startAt.hour * 60 + event.startAt.minute;
final eventEnd = event.endAt != null
? event.endAt!.hour * 60 + event.endAt!.minute
: eventStart + 60;
var column = 0;
while (true) {
final columnEnd = columnHeights[column] ?? 0;
if (columnEnd <= eventStart) {
columns[i] = column;
columnHeights[column] = eventEnd;
break;
}
column++;
}
}
return columns;
return Stack(
children: [
Positioned(
top: y,
left: eventAreaLeft,
right: 0,
child: Container(
height: 1,
color: isDisabled ? AppColors.blue50 : AppColors.border,
),
),
Positioned(
top: labelTop,
left: 0,
width: DayTimelineMetrics.timeLabelWidth,
child: Text(
formatHour(hour),
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: isDisabled ? AppColors.slate300 : AppColors.slate400,
),
),
),
],
);
}
List<Widget> _buildPositionedEvents(
List<ScheduleItemModel> events,
List<int> columns,
) {
if (events.isEmpty) return [];
Widget _buildCurrentTimeMarker({
required DateTime now,
required double boardHeight,
}) {
final minute = now.hour * DayTimelineMetrics.minutesInHour + now.minute;
final top = _scale.pixelsForMinutes(minute).clamp(0.0, boardHeight);
final maxColumn = columns.reduce((a, b) => a > b ? a : b) + 1;
final eventWidgets = <Widget>[];
return Positioned(
top: top - 9,
left: 0,
right: 0,
child: IgnorePointer(
child: SizedBox(
height: 18,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: DayTimelineMetrics.timeLabelWidth,
height: 18,
decoration: BoxDecoration(
color: AppColors.red500,
borderRadius: BorderRadius.circular(9),
),
child: Center(
child: Text(
formatHm(now),
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
),
),
const SizedBox(width: DayTimelineMetrics.timeLabelGap),
Expanded(
child: Container(
height: 2,
decoration: BoxDecoration(
color: AppColors.red500,
borderRadius: BorderRadius.circular(99),
),
),
),
],
),
),
),
);
}
for (var i = 0; i < events.length; i++) {
final event = events[i];
final column = columns[i];
final eventColor = _parseColor(event.metadata?.color);
Widget _buildEventCard({
required DayEventLayout layout,
required double boardHeight,
}) {
final eventColor = _parseColor(layout.event.metadata?.color);
final isCompact = layout.visualHeight < 20;
final tapHeight = layout.visualHeight < _minEventTapHeight
? _minEventTapHeight
: layout.visualHeight;
final top = (layout.top - ((tapHeight - layout.visualHeight) / 2)).clamp(
0.0,
boardHeight - tapHeight,
);
final visualTop = layout.top - top;
final startMinutes = event.startAt.hour * 60 + event.startAt.minute;
final endMinutes = event.endAt != null
? event.endAt!.hour * 60 + event.endAt!.minute
: startMinutes + 60;
final durationMinutes = endMinutes - startMinutes;
final top = (startMinutes / 60) * _hourHeight;
final height = (durationMinutes / 60) * _hourHeight;
final eventWidth = maxColumn > 1
? (MediaQuery.of(context).size.width - _eventLeftOffset - 16) /
maxColumn
: MediaQuery.of(context).size.width - _eventLeftOffset - 16;
final left = _eventLeftOffset + column * eventWidth;
eventWidgets.add(
Positioned(
top: top,
left: left,
right: maxColumn > 1 ? null : 16,
width: maxColumn > 1 ? eventWidth - 4 : null,
height: height.clamp(24.0, double.infinity),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
context.push('/calendar/events/${event.id}');
},
return Positioned(
top: top,
left: layout.left,
width: layout.width,
height: tapHeight,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => context.push('/calendar/events/${layout.event.id}'),
child: Stack(
children: [
Positioned(
top: visualTop,
left: 0,
right: 0,
height: layout.visualHeight,
child: Container(
margin: const EdgeInsets.only(right: 4),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
margin: const EdgeInsets.only(
right: DayTimelineMetrics.eventColumnGap,
),
padding: isCompact
? const EdgeInsets.symmetric(horizontal: 4, vertical: 2)
: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: eventColor.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: eventColor, width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 6,
@@ -569,33 +663,34 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
shape: BoxShape.circle,
),
),
const SizedBox(width: 4),
Expanded(
child: Text(
event.title,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: eventColor,
if (!isCompact) const SizedBox(width: 4),
if (!isCompact)
Expanded(
child: Text(
layout.event.title,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: eventColor,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
),
],
),
);
}
return eventWidgets;
),
);
}
Color _parseColor(String? hex) {
if (hex == null || hex.isEmpty) return AppColors.blue600;
if (hex == null || hex.isEmpty) {
return AppColors.blue600;
}
try {
return Color(int.parse(hex.replaceFirst('#', '0xFF')));
} catch (_) {
@@ -603,74 +698,12 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
}
}
Widget _buildTimelineRow(
String time, {
bool isCurrentTime = false,
bool isDisabled = false,
}) {
return SizedBox(
height: _hourHeight,
child: Row(
children: [
SizedBox(
width: 44,
child: isCurrentTime
? Container(
width: 44,
height: 18,
decoration: BoxDecoration(
color: AppColors.red500,
borderRadius: BorderRadius.circular(9),
),
child: Center(
child: Text(
time,
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
),
)
: Text(
time,
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: isDisabled
? AppColors.slate300
: AppColors.slate400,
),
),
),
const SizedBox(width: 8),
Expanded(
child: isCurrentTime
? Container(
height: 2,
decoration: BoxDecoration(
color: AppColors.red500,
borderRadius: BorderRadius.circular(99),
),
)
: Container(
height: 1,
color: isDisabled ? AppColors.blue50 : AppColors.border,
),
),
],
),
);
}
Widget _buildBottomDock() {
return BottomDock(
activeTab: DockTab.calendar,
onTodoTap: () {
_calendarManager.setViewType(CalendarViewType.day);
context.push('/todo');
context.go('/todo');
},
onCalendarTap: () {
_calendarManager.setViewType(CalendarViewType.day);
@@ -5,12 +5,16 @@ import '../../../../core/di/injection.dart';
import '../../../../core/notifications/local_notification_service.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_loading_indicator.dart';
import '../../../../shared/widgets/page_header.dart' as widgets;
import '../../../../shared/widgets/back_title_page_header.dart';
import '../../../../shared/widgets/detail_header_action_menu.dart';
import '../../../../shared/widgets/destructive_action_sheet.dart';
import '../../data/services/calendar_service.dart';
import '../../data/models/schedule_item_model.dart';
import '../widgets/create_event_sheet.dart';
import '../widgets/calendar_share_dialog.dart';
enum _CalendarHeaderAction { edit, delete, share }
class CalendarEventDetailScreen extends StatefulWidget {
final String eventId;
@@ -95,8 +99,9 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
backgroundColor: const Color(0xFFF8FAFC),
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(context),
_buildHeader(event),
Expanded(child: _buildDetailOverlay(event)),
_buildInputContainer(),
],
@@ -105,20 +110,82 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
);
}
Widget _buildHeader(BuildContext context) {
return SizedBox(
height: 64,
child: Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 12, bottom: 8),
child: Row(
children: [
widgets.BackButton(onPressed: () => Navigator.of(context).pop()),
],
),
),
Widget _buildHeader(ScheduleItemModel event) {
return BackTitlePageHeader(
title: '日程详情',
onBack: () => context.pop(),
trailing: _buildHeaderActions(event),
);
}
Widget _buildHeaderActions(ScheduleItemModel event) {
final items = <DetailHeaderActionItem<_CalendarHeaderAction>>[];
if (event.canEdit) {
items.add(
const DetailHeaderActionItem<_CalendarHeaderAction>(
value: _CalendarHeaderAction.edit,
label: '编辑',
icon: LucideIcons.pencil,
),
);
}
if (event.canDelete) {
items.add(
const DetailHeaderActionItem<_CalendarHeaderAction>(
value: _CalendarHeaderAction.delete,
label: '删除',
icon: LucideIcons.trash2,
isDestructive: true,
),
);
}
if (event.canInvite) {
items.add(
const DetailHeaderActionItem<_CalendarHeaderAction>(
value: _CalendarHeaderAction.share,
label: '分享',
icon: LucideIcons.share2,
),
);
}
return DetailHeaderActionMenu<_CalendarHeaderAction>(
items: items,
onSelected: (action) => _handleHeaderAction(action, event),
);
}
void _handleHeaderAction(
_CalendarHeaderAction action,
ScheduleItemModel event,
) {
switch (action) {
case _CalendarHeaderAction.edit:
CreateEventSheet.edit(
context,
event,
onSaved: () {
setState(() {
_loadEvent();
});
},
);
return;
case _CalendarHeaderAction.delete:
_showDeleteConfirmation();
return;
case _CalendarHeaderAction.share:
CalendarShareDialog.show(
context,
event.id,
event.title,
canInvite: event.canInvite,
canEdit: event.canEdit,
);
return;
}
}
Widget _buildDetailOverlay(ScheduleItemModel event) {
final startAt = event.startAt;
final endAt = event.endAt;
@@ -198,10 +265,11 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
Widget _buildTitleRow(ScheduleItemModel event) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 4,
@@ -226,114 +294,28 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
],
),
),
Row(
children: [
if (event.canEdit)
_buildHeaderActionButton(
onTap: () => CreateEventSheet.edit(
context,
event,
onSaved: () {
setState(() {
_loadEvent();
});
},
),
icon: LucideIcons.pencil,
iconColor: AppColors.slate600,
backgroundColor: AppColors.surfaceTertiary,
borderColor: AppColors.borderTertiary,
),
if (event.canEdit) const SizedBox(width: 8),
if (event.canDelete)
_buildHeaderActionButton(
onTap: _showDeleteConfirmation,
icon: LucideIcons.trash2,
iconColor: AppColors.red500,
backgroundColor: AppColors.warningBackground,
borderColor: AppColors.messageRejectBorder,
),
if (event.canInvite) ...[
const SizedBox(width: 8),
_buildHeaderActionButton(
onTap: () => CalendarShareDialog.show(
context,
event.id,
event.title,
canInvite: event.canInvite,
canEdit: event.canEdit,
),
icon: LucideIcons.share2,
iconColor: AppColors.slate600,
backgroundColor: AppColors.blue50,
borderColor: AppColors.blue100,
),
],
],
),
],
);
}
Widget _buildHeaderActionButton({
required VoidCallback onTap,
required IconData icon,
required Color iconColor,
required Color backgroundColor,
required Color borderColor,
}) {
return SizedBox(
width: AppSpacing.xxl * 2,
height: AppSpacing.xxl * 2,
child: TextButton(
onPressed: onTap,
style: TextButton.styleFrom(
padding: const EdgeInsets.all(AppSpacing.none),
backgroundColor: backgroundColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
side: BorderSide(color: borderColor),
),
),
child: Icon(
icon,
size: AppSpacing.lg + AppSpacing.xs,
color: iconColor,
),
),
);
}
void _showDeleteConfirmation() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('删除日程'),
content: const Text('确定要删除这个日程吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
TextButton(
onPressed: () async {
await sl<CalendarService>().deleteEvent(widget.eventId);
try {
await sl<LocalNotificationService>().cancelEventReminder(
widget.eventId,
);
} catch (_) {}
if (!context.mounted) {
return;
}
Navigator.pop(context);
context.pop();
},
child: Text('删除', style: TextStyle(color: AppColors.red500)),
),
],
),
Future<void> _showDeleteConfirmation() async {
final confirmed = await showDestructiveActionSheet(
context,
title: '删除日程',
message: '确定要删除这个日程吗?',
confirmText: '确认删除',
);
if (!confirmed) {
return;
}
await sl<CalendarService>().deleteEvent(widget.eventId);
try {
await sl<LocalNotificationService>().cancelEventReminder(widget.eventId);
} catch (_) {}
if (!mounted) {
return;
}
context.pop();
}
Widget _buildDetailField(String label, String value) {
@@ -522,7 +522,7 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
activeTab: DockTab.calendar,
onTodoTap: () {
_calendarManager.setViewType(CalendarViewType.month);
context.push('/todo');
context.go('/todo');
},
onCalendarTap: () {},
onHomeTap: () => context.go('/home'),
@@ -57,6 +57,17 @@ class CreateEventSheet extends StatefulWidget {
class _CreateEventSheetState extends State<CreateEventSheet>
with SingleTickerProviderStateMixin {
static const List<int?> _defaultReminderOptions = [
null,
0,
5,
10,
15,
30,
60,
120,
];
late TabController _tabController;
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
@@ -89,7 +100,9 @@ class _CreateEventSheetState extends State<CreateEventSheet>
_endDate = event.endAt;
_endTime = event.endAt;
_selectedColor = event.metadata?.color ?? '#3B82F6';
_reminderMinutes = event.metadata?.reminderMinutes ?? 15;
_reminderMinutes = _sanitizeReminderMinutes(
event.metadata?.reminderMinutes,
);
} else {
final now =
widget.initialDate ?? _roundToNearestMinute(DateTime.now(), 5);
@@ -512,7 +525,7 @@ class _CreateEventSheetState extends State<CreateEventSheet>
}
Widget _buildReminderPicker() {
const options = <int?>[null, 0, 5, 10, 15, 30, 60, 120];
final options = _buildReminderOptions();
String labelOf(int? value) {
if (value == null) {
return '无提醒';
@@ -573,6 +586,24 @@ class _CreateEventSheetState extends State<CreateEventSheet>
);
}
int? _sanitizeReminderMinutes(int? minutes) {
if (minutes == null || minutes < 0) {
return null;
}
return minutes;
}
List<int?> _buildReminderOptions() {
final current = _sanitizeReminderMinutes(_reminderMinutes);
final nonNull = _defaultReminderOptions.whereType<int>().toSet();
if (current != null) {
nonNull.add(current);
}
final sorted = nonNull.toList()..sort();
return [null, ...sorted];
}
Future<void> _saveEvent() async {
if (_titleController.text.trim().isEmpty || _saving) return;
setState(() {