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'; import '../../../../shared/widgets/app_pressable.dart'; import '../../data/models/schedule_item_model.dart'; import '../../data/services/calendar_repository.dart'; import '../calendar_state_manager.dart'; import '../calendar_time_utils.dart'; import '../utils/event_color_resolver.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'; class CalendarDayWeekScreen extends StatefulWidget { final DateTime? initialDate; final bool resetToToday; const CalendarDayWeekScreen({ super.key, this.initialDate, this.resetToToday = false, }); @override State createState() => _CalendarDayWeekScreenState(); } class _CalendarDayWeekScreenState extends State with WidgetsBindingObserver { static const double _dayItemWidth = 44; static const double _dayItemGap = 12; static const double _minEventTapHeight = 32; static const List _dayNames = ['日', '一', '二', '三', '四', '五', '六']; final DayEventLayoutEngine _layoutEngine = const DayEventLayoutEngine(); final Map _activePointers = {}; final ScrollController _dayStripController = ScrollController(); DayViewScale _scale = DayViewScale.defaultScale(); DayViewScale _pinchStartScale = DayViewScale.defaultScale(); double? _pinchStartDistance; late final CalendarStateManager _calendarManager; late DateTime _selectedDate; late List _monthDates; List _events = const []; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _calendarManager = sl(); if (widget.resetToToday) { _calendarManager.resetToToday(); } _selectedDate = widget.initialDate ?? _calendarManager.selectedDate; _updateMonthDates(); _loadEvents(); WidgetsBinding.instance.addPostFrameCallback((_) { _scrollToSelectedDate(); }); } void _updateMonthDates() { _monthDates = monthDatesFor(_selectedDate); } Future _loadEvents({bool forceRefresh = false}) async { final events = await sl().getDayEvents( _selectedDate, forceRefresh: forceRefresh, ); if (!mounted) { return; } setState(() { _events = events; }); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _dayStripController.dispose(); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { _loadEvents(forceRefresh: true); } } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.todoBg, body: PopScope( canPop: false, onPopInvokedWithResult: (didPop, result) { if (!didPop) { returnToHomePreserveState(context, forceGoHome: true); } }, child: SafeArea( child: Stack( children: [ Positioned.fill( top: 154, bottom: 84, child: Listener( onPointerDown: _handlePointerDown, onPointerMove: _handlePointerMove, onPointerUp: _handlePointerUp, onPointerCancel: _handlePointerCancel, behavior: HitTestBehavior.translucent, child: SingleChildScrollView( child: Padding( padding: const EdgeInsets.only( left: AppSpacing.lg, right: AppSpacing.lg, top: 2, ), child: RepaintBoundary(child: _buildTimelineBoard()), ), ), ), ), Positioned(top: 0, left: 0, right: 0, child: _buildHeader()), Positioned(top: 68, left: 0, right: 0, child: _buildWeekStrip()), Positioned( bottom: 0, left: 0, right: 0, child: _buildBottomDock(), ), ], ), ), ), ); } void _goToToday() { final today = DateTime.now(); setState(() { _selectedDate = today; _scale = DayViewScale.defaultScale(); }); _calendarManager.setSelectedDate(today); _updateMonthDates(); _scrollToSelectedDate(animate: true); _loadEvents(); } void _handlePointerDown(PointerDownEvent event) { _activePointers[event.pointer] = event.position; if (_activePointers.length == 2) { final pointers = _activePointers.values.toList(growable: false); _pinchStartDistance = (pointers[0] - pointers[1]).distance; _pinchStartScale = _scale; } } void _handlePointerMove(PointerMoveEvent event) { if (!_activePointers.containsKey(event.pointer)) { return; } _activePointers[event.pointer] = event.position; if (_activePointers.length != 2 || _pinchStartDistance == null) { return; } final pointers = _activePointers.values.toList(growable: false); final currentDistance = (pointers[0] - pointers[1]).distance; final startDistance = _pinchStartDistance!; if (startDistance <= 0) { return; } final nextScale = _pinchStartScale.zoomByFactor( currentDistance / startDistance, ); if ((nextScale.hourHeight - _scale.hourHeight).abs() < 0.1) { return; } setState(() { _scale = nextScale; }); } void _handlePointerUp(PointerUpEvent event) { _handlePointerRemove(event.pointer); } void _handlePointerCancel(PointerCancelEvent event) { _handlePointerRemove(event.pointer); } void _handlePointerRemove(int pointer) { _activePointers.remove(pointer); if (_activePointers.length < 2) { _pinchStartDistance = null; } } Widget _buildHeader() { final monthLabel = '${_selectedDate.year}年${_selectedDate.month}月'; final isNotToday = !isSameDay(_selectedDate, DateTime.now()); return SizedBox( height: 68, child: Padding( padding: const EdgeInsets.only(left: 20, right: 20, top: 12, bottom: 8), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ AppPressable( borderRadius: BorderRadius.circular(AppRadius.xl), onTap: () => context.go('/calendar/month'), child: Container( height: 36, padding: const EdgeInsets.symmetric(horizontal: 10), decoration: BoxDecoration( color: AppColors.messageBtnWrap, borderRadius: BorderRadius.circular(AppRadius.xl), border: Border.all(color: AppColors.messageBtnBorder), ), child: Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ const Icon( LucideIcons.chevronLeft, size: 16, color: AppColors.slate700, ), const SizedBox(width: 6), AnimatedSwitcher( duration: const Duration(milliseconds: 160), switchInCurve: Curves.easeOut, switchOutCurve: Curves.easeOut, transitionBuilder: (child, animation) { return FadeTransition( opacity: animation, child: SlideTransition( position: Tween( begin: const Offset(0, 0.12), end: Offset.zero, ).animate(animation), child: child, ), ); }, child: Text( monthLabel, key: ValueKey(monthLabel), style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.slate700, ), ), ), ], ), ), ), const Spacer(), if (isNotToday) AppPressable( borderRadius: BorderRadius.circular(AppRadius.xl), onTap: _goToToday, child: Container( height: 36, padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration( color: AppColors.messageBtnWrap, borderRadius: BorderRadius.circular(AppRadius.xl), border: Border.all(color: AppColors.messageBtnBorder), ), child: const Center( child: Text( '今天', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.slate700, ), ), ), ), ), if (isNotToday) const SizedBox(width: 8), AppPressable( borderRadius: BorderRadius.circular(AppRadius.full), onTap: () => context.push( '${AppRoutes.calendarEventCreate}?date=${formatYmd(_selectedDate)}', ), child: Container( width: 36, height: 36, decoration: BoxDecoration( color: AppColors.blue600, borderRadius: BorderRadius.circular(AppRadius.full), ), child: const Icon( LucideIcons.plus, size: 20, color: AppColors.white, ), ), ), ], ), ), ); } Widget _buildWeekStrip() { final stripKey = ValueKey('${_selectedDate.year}-${_selectedDate.month}'); return SizedBox( height: 86, child: AnimatedSwitcher( duration: const Duration(milliseconds: 180), switchInCurve: Curves.easeOut, switchOutCurve: Curves.easeOut, child: ListView.separated( key: stripKey, controller: _dayStripController, physics: const BouncingScrollPhysics(), scrollDirection: Axis.horizontal, itemCount: _monthDates.length, separatorBuilder: (context, index) => const SizedBox(width: _dayItemGap), itemBuilder: (context, index) { final date = _monthDates[index]; final isSelected = isSameDay(date, _selectedDate); final isWeekend = date.weekday % 7 == 0 || date.weekday % 7 == 6; return AppPressable( borderRadius: BorderRadius.circular(AppRadius.xl), onTap: () { setState(() { _selectedDate = date; }); _calendarManager.setSelectedDate(date); _updateMonthDates(); _scrollToSelectedDate(animate: true); _loadEvents(); }, child: SizedBox( width: _dayItemWidth, child: _buildDayItem(date, isSelected, isWeekend), ), ); }, ), ), ); } void _scrollToSelectedDate({bool animate = false}) { if (!_dayStripController.hasClients) { return; } final index = _monthDates.indexWhere( (date) => isSameDay(date, _selectedDate), ); if (index < 0) { return; } final targetCenter = index * (_dayItemWidth + _dayItemGap) + (_dayItemWidth / 2); final viewport = _dayStripController.position.viewportDimension; var offset = targetCenter - (viewport / 2); final max = _dayStripController.position.maxScrollExtent; if (offset < 0) { offset = 0; } if (offset > max) { offset = max; } if (animate) { _dayStripController.animateTo( offset, duration: const Duration(milliseconds: 180), curve: Curves.easeOut, ); return; } _dayStripController.jumpTo(offset); } Widget _buildDayItem(DateTime date, bool isSelected, bool isWeekend) { return Column( crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ Text( _dayNames[date.weekday % 7], style: TextStyle( fontSize: 11, color: isWeekend ? AppColors.slate400 : AppColors.slate600, ), ), const SizedBox(height: 2), AnimatedContainer( duration: const Duration(milliseconds: 140), curve: Curves.easeOut, width: 32, height: 32, decoration: BoxDecoration( color: isSelected ? AppColors.blue100 : Colors.transparent, borderRadius: BorderRadius.circular(AppRadius.full), ), child: Center( child: Text( '${date.day}', style: TextStyle( fontSize: 17, fontWeight: isSelected ? FontWeight.w700 : FontWeight.w600, color: isSelected ? AppColors.blue600 : (isWeekend ? AppColors.slate400 : AppColors.slate900), ), ), ), ), ], ); } Widget _buildTimelineBoard() { final now = DateTime.now(); final showCurrent = shouldShowCurrentMarker(_selectedDate, now); return LayoutBuilder( builder: (context, constraints) { final boardWidth = constraints.maxWidth; final boardHeight = DayTimelineMetrics.timelineHeight(_scale); final eventAreaLeft = DayTimelineMetrics.eventAreaLeft(); final eventAreaWidth = DayTimelineMetrics.eventAreaWidth(boardWidth); final layouts = _layoutEngine.layout( events: _events, scale: _scale, eventAreaLeft: eventAreaLeft, eventAreaWidth: eventAreaWidth, ); return SizedBox( height: boardHeight, child: Stack( children: [ 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), ], ), ), ], ), ); }, ); } 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, ), ], ), ); } 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); 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, ), ), ), ], ); } 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); 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), ), ), ), ], ), ), ), ); } Widget _buildEventCard({ required DayEventLayout layout, required double boardHeight, }) { final eventColor = resolveEventColor( status: layout.event.status, colorHex: 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; return Positioned( top: top, left: layout.left, width: layout.width, height: tapHeight, child: GestureDetector( behavior: HitTestBehavior.translucent, onTap: () => context.push(AppRoutes.calendarEventDetail(layout.event.id)), child: Stack( children: [ Positioned( top: visualTop, left: 0, right: 0, height: layout.visualHeight, child: Container( 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( crossAxisAlignment: CrossAxisAlignment.center, children: [ Container( width: 6, height: 6, decoration: BoxDecoration( color: eventColor, shape: BoxShape.circle, ), ), 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, ), ), ], ), ), ), ], ), ), ); } Widget _buildBottomDock() { return BottomDock( activeTab: DockTab.calendar, onTodoTap: () { _calendarManager.setViewType(CalendarViewType.day); context.push(AppRoutes.todoList); }, onCalendarTap: () { _calendarManager.setViewType(CalendarViewType.day); context.push(AppRoutes.calendarMonth); }, onHomeTap: () => returnToHomePreserveState(context, forceGoHome: true), ); } }