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:
@@ -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(() {
|
||||
|
||||
Reference in New Issue
Block a user