diff --git a/apps/lib/core/router/app_router.dart b/apps/lib/core/router/app_router.dart index ae8b0bf..871e0f2 100644 --- a/apps/lib/core/router/app_router.dart +++ b/apps/lib/core/router/app_router.dart @@ -62,6 +62,11 @@ GoRouter createAppRouter(AuthBloc authBloc) { }, routes: [ GoRoute(path: '/', builder: (context, state) => const LoginScreen()), + GoRoute( + path: '/calendar/events/:id', + builder: (context, state) => + CalendarEventDetailScreen(eventId: state.pathParameters['id']!), + ), GoRoute( path: '/register', builder: (context, state) => const RegisterScreen(), @@ -109,10 +114,6 @@ GoRouter createAppRouter(AuthBloc authBloc) { return CalendarMonthScreen(resetToToday: fromHome); }, ), - GoRoute( - path: '/calendar/events/:id', - builder: (context, state) => const CalendarEventDetailScreen(), - ), GoRoute( path: '/todo', builder: (context, state) => const TodoQuadrantsScreen(), diff --git a/apps/lib/features/calendar/data/models/schedule_item_model.dart b/apps/lib/features/calendar/data/models/schedule_item_model.dart new file mode 100644 index 0000000..38df388 --- /dev/null +++ b/apps/lib/features/calendar/data/models/schedule_item_model.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; + +enum ScheduleSourceType { manual, imported, agentGenerated } + +enum ScheduleStatus { active, completed, canceled, archived } + +class ScheduleItemModel { + final String id; + final String title; + final String? description; + final DateTime startAt; + final DateTime? endAt; + final String timezone; + final ScheduleMetadata? metadata; + final ScheduleSourceType sourceType; + final ScheduleStatus status; + final DateTime createdAt; + + ScheduleItemModel({ + required this.id, + required this.title, + this.description, + required this.startAt, + this.endAt, + this.timezone = 'Asia/Shanghai', + this.metadata, + this.sourceType = ScheduleSourceType.manual, + this.status = ScheduleStatus.active, + DateTime? createdAt, + }) : createdAt = createdAt ?? DateTime.now(); + + ScheduleItemModel copyWith({ + String? id, + String? title, + String? description, + DateTime? startAt, + DateTime? endAt, + String? timezone, + ScheduleMetadata? metadata, + ScheduleSourceType? sourceType, + ScheduleStatus? status, + DateTime? createdAt, + }) { + return ScheduleItemModel( + id: id ?? this.id, + title: title ?? this.title, + description: description ?? this.description, + startAt: startAt ?? this.startAt, + endAt: endAt ?? this.endAt, + timezone: timezone ?? this.timezone, + metadata: metadata ?? this.metadata, + sourceType: sourceType ?? this.sourceType, + status: status ?? this.status, + createdAt: createdAt ?? this.createdAt, + ); + } +} + +class ScheduleMetadata { + final String? color; + final String? location; + final String? notes; + final List? attachments; + + ScheduleMetadata({this.color, this.location, this.notes, this.attachments}); + + ScheduleMetadata copyWith({ + String? color, + String? location, + String? notes, + List? attachments, + }) { + return ScheduleMetadata( + color: color ?? this.color, + location: location ?? this.location, + notes: notes ?? this.notes, + attachments: attachments ?? this.attachments, + ); + } +} + +class Attachment { + final String name; + final String? url; + final String? content; + final String type; + + Attachment({ + required this.name, + this.url, + this.content, + this.type = 'document', + }); +} + +const defaultColors = [ + Color(0xFF3B82F6), + Color(0xFF8B5CF6), + Color(0xFF10B981), + Color(0xFFF59E0B), + Color(0xFFEF4444), +]; diff --git a/apps/lib/features/calendar/data/services/mock_calendar_service.dart b/apps/lib/features/calendar/data/services/mock_calendar_service.dart new file mode 100644 index 0000000..b6b4178 --- /dev/null +++ b/apps/lib/features/calendar/data/services/mock_calendar_service.dart @@ -0,0 +1,109 @@ +import 'package:social_app/core/config/env.dart'; +import '../models/schedule_item_model.dart'; + +class MockCalendarService { + static final MockCalendarService _instance = MockCalendarService._internal(); + factory MockCalendarService() => _instance; + + final List _events = []; + + MockCalendarService._internal(); + + List get events => List.unmodifiable(_events); + + List getEventsForDay(DateTime date) { + final dateOnly = DateTime(date.year, date.month, date.day); + return _events.where((event) { + final eventDate = DateTime( + event.startAt.year, + event.startAt.month, + event.startAt.day, + ); + return eventDate == dateOnly && event.status == ScheduleStatus.active; + }).toList()..sort((a, b) => a.startAt.compareTo(b.startAt)); + } + + List getEventsForRange(DateTime start, DateTime end) { + return _events.where((event) { + return event.startAt.isAfter(start.subtract(const Duration(days: 1))) && + event.startAt.isBefore(end.add(const Duration(days: 1))) && + event.status == ScheduleStatus.active; + }).toList()..sort((a, b) => a.startAt.compareTo(b.startAt)); + } + + ScheduleItemModel? getEventById(String id) { + try { + return _events.firstWhere((e) => e.id == id); + } catch (_) { + return null; + } + } + + void addEvent(ScheduleItemModel event) { + _events.add(event); + } + + void updateEvent(ScheduleItemModel event) { + final index = _events.indexWhere((e) => e.id == event.id); + if (index >= 0) { + _events[index] = event; + } + } + + void deleteEvent(String id) { + _events.removeWhere((e) => e.id == id); + } +} + +class CalendarService { + static final CalendarService _instance = CalendarService._internal(); + factory CalendarService() => _instance; + CalendarService._internal(); + + MockCalendarService get _mock => MockCalendarService(); + + List getEventsForDay(DateTime date) { + if (Env.isMockApi) { + return _mock.getEventsForDay(date); + } + throw UnimplementedError('Real API not implemented'); + } + + List getEventsForRange(DateTime start, DateTime end) { + if (Env.isMockApi) { + return _mock.getEventsForRange(start, end); + } + throw UnimplementedError('Real API not implemented'); + } + + ScheduleItemModel? getEventById(String id) { + if (Env.isMockApi) { + return _mock.getEventById(id); + } + throw UnimplementedError('Real API not implemented'); + } + + void addEvent(ScheduleItemModel event) { + if (Env.isMockApi) { + _mock.addEvent(event); + return; + } + throw UnimplementedError('Real API not implemented'); + } + + void updateEvent(ScheduleItemModel event) { + if (Env.isMockApi) { + _mock.updateEvent(event); + return; + } + throw UnimplementedError('Real API not implemented'); + } + + void deleteEvent(String id) { + if (Env.isMockApi) { + _mock.deleteEvent(id); + return; + } + throw UnimplementedError('Real API not implemented'); + } +} diff --git a/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart index 9f8aa91..e5afb8c 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart @@ -6,6 +6,9 @@ import '../../../../core/theme/design_tokens.dart'; import '../calendar_state_manager.dart'; import '../calendar_time_utils.dart'; import '../widgets/bottom_dock.dart'; +import '../widgets/create_event_sheet.dart'; +import '../../data/services/mock_calendar_service.dart'; +import '../../data/models/schedule_item_model.dart'; class CalendarDayWeekScreen extends StatefulWidget { final DateTime? initialDate; @@ -24,11 +27,14 @@ class CalendarDayWeekScreen extends StatefulWidget { class _CalendarDayWeekScreenState extends State { static const double _dayItemWidth = 44; static const double _dayItemGap = 12; + static const double _hourHeight = 34; + static const double _eventLeftOffset = 52; late final CalendarStateManager _calendarManager; late DateTime _selectedDate; late List _monthDates; final ScrollController _dayStripController = ScrollController(); + Key _eventsKey = UniqueKey(); @override void initState() { @@ -78,7 +84,10 @@ class _CalendarDayWeekScreenState extends State { children: [ _buildWeekStrip(), const SizedBox(height: 8), - _buildTimelineBoard(), + KeyedSubtree( + key: _eventsKey, + child: _buildTimelineBoard(), + ), ], ), ), @@ -129,6 +138,31 @@ class _CalendarDayWeekScreenState extends State { ), ), ), + const Spacer(), + GestureDetector( + onTap: () => CreateEventSheet.show( + context, + initialDate: _selectedDate, + onSaved: () { + setState(() { + _eventsKey = UniqueKey(); + }); + }, + ), + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: AppColors.blue600, + borderRadius: BorderRadius.circular(18), + ), + child: const Icon( + LucideIcons.plus, + size: 20, + color: Colors.white, + ), + ), + ), ], ), ), @@ -233,17 +267,157 @@ class _CalendarDayWeekScreenState extends State { Widget _buildTimelineBoard() { final now = DateTime.now(); final showCurrent = shouldShowCurrentMarker(_selectedDate, now); - final rows = []; + final events = CalendarService().getEventsForDay(_selectedDate); - for (var hour = 7; hour <= 22; hour++) { - rows.add(_buildTimelineRow(formatHour(hour))); - if (showCurrent && now.hour == hour) { - rows.add(_buildTimelineRow(formatHm(now), isCurrentTime: true)); + final eventColumns = _calculateEventColumns(events); + + return SizedBox( + child: Stack( + clipBehavior: Clip.none, + children: [ + Column( + 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), + ], + ), + ..._buildPositionedEvents(events, eventColumns), + ], + ), + ); + } + + List _calculateEventColumns(List events) { + if (events.isEmpty) return []; + + final columns = List.filled(events.length, -1); + final columnHeights = {}; + + 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++; } } - rows.add(_buildTimelineRow(formatHour(24), isDisabled: true)); - return Column(children: rows); + return columns; + } + + List _buildPositionedEvents( + List events, + List columns, + ) { + if (events.isEmpty) return []; + + final maxColumn = columns.reduce((a, b) => a > b ? a : b) + 1; + final eventWidgets = []; + + for (var i = 0; i < events.length; i++) { + final event = events[i]; + final column = columns[i]; + + 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: () { + final path = '/calendar/events/${event.id}'; + debugPrint('Navigating to: $path'); + context.push(path); + }, + child: Container( + margin: const EdgeInsets.only(right: 4), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: _parseColor( + event.metadata?.color, + ).withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: _parseColor(event.metadata?.color), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: _parseColor(event.metadata?.color), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + event.title, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: _parseColor(event.metadata?.color), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + return eventWidgets; + } + + Color _parseColor(String? hex) { + if (hex == null || hex.isEmpty) return AppColors.blue600; + try { + return Color(int.parse(hex.replaceFirst('#', '0xFF'))); + } catch (_) { + return AppColors.blue600; + } } Widget _buildTimelineRow( diff --git a/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart index 74d7c31..f610875 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart @@ -1,19 +1,86 @@ import 'package:flutter/material.dart'; import 'package:lucide_icons/lucide_icons.dart'; +import 'package:go_router/go_router.dart'; import '../../../../core/theme/design_tokens.dart'; +import '../../data/services/mock_calendar_service.dart'; +import '../../data/models/schedule_item_model.dart'; +import '../widgets/create_event_sheet.dart'; -class CalendarEventDetailScreen extends StatelessWidget { - const CalendarEventDetailScreen({super.key}); +class CalendarEventDetailScreen extends StatefulWidget { + final String eventId; + + const CalendarEventDetailScreen({super.key, required this.eventId}); + + @override + State createState() => + _CalendarEventDetailScreenState(); +} + +class _CalendarEventDetailScreenState extends State { + ScheduleItemModel? _event; + + @override + void initState() { + super.initState(); + _loadEvent(); + } + + void _loadEvent() { + try { + _event = CalendarService().getEventById(widget.eventId); + } catch (e) { + _event = null; + } + setState(() {}); + } @override Widget build(BuildContext context) { + if (_event == null) { + return Scaffold( + backgroundColor: const Color(0xFFF8FAFC), + body: SafeArea( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'Event not found', + style: TextStyle(color: AppColors.slate600), + ), + const SizedBox(height: 16), + GestureDetector( + onTap: () => context.pop(), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + decoration: BoxDecoration( + color: AppColors.blue600, + borderRadius: BorderRadius.circular(20), + ), + child: const Text( + '返回', + style: TextStyle(color: Colors.white), + ), + ), + ), + ], + ), + ), + ), + ); + } + + final event = _event!; return Scaffold( - backgroundColor: AppColors.surfaceSecondary, + backgroundColor: const Color(0xFFF8FAFC), body: SafeArea( child: Column( children: [ _buildHeader(context), - Expanded(child: _buildDetailOverlay()), + Expanded(child: _buildDetailOverlay(event)), _buildInputContainer(), ], ), @@ -34,7 +101,7 @@ class CalendarEventDetailScreen extends StatelessWidget { width: 36, height: 36, decoration: BoxDecoration( - color: AppColors.surfaceTertiary, + color: const Color(0xFFF8FAFF), borderRadius: BorderRadius.circular(18), border: Border.all(color: const Color(0xFFDEE7F6)), ), @@ -51,7 +118,15 @@ class CalendarEventDetailScreen extends StatelessWidget { ); } - Widget _buildDetailOverlay() { + 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); + return Padding( padding: const EdgeInsets.only(left: 16, right: 16, top: 8, bottom: 12), child: Container( @@ -62,82 +137,124 @@ class CalendarEventDetailScreen extends StatelessWidget { border: Border.all(color: const Color(0xFFD8E3F5)), ), child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildTitleRow(), - const SizedBox(height: 14), - Container(height: 1, color: const Color(0xFFE5E7EB)), - const SizedBox(height: 14), - _buildDetailField('日期', '2026年2月9日 周一'), - const SizedBox(height: 14), - _buildDetailField('时间范围', '16:00 - 17:30'), - const SizedBox(height: 14), - _buildDetailField('提醒时间', '开始前30分钟'), - const SizedBox(height: 14), - _buildColorField(), - const SizedBox(height: 14), - _buildDetailField('邀请人', 'Qiuzh'), - const SizedBox(height: 14), - _buildNotesField(), - ], + child: SizedBox( + width: double.infinity, + 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('提醒时间', '开始前30分钟'), + const SizedBox(height: 14), + _buildColorField(event.metadata?.color), + 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 _buildTitleRow() { + String _getWeekday(int weekday) { + const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; + return weekdays[weekday - 1]; + } + + String _formatTime(DateTime time) { + return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}'; + } + + Widget _buildTitleRow(ScheduleItemModel event) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( - children: [ - const Icon( - LucideIcons.calendarCheck2, - size: 18, - color: AppColors.blue600, - ), - const SizedBox(width: 10), - const Text( - '购票提醒', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - color: AppColors.slate900, + Expanded( + child: Row( + children: [ + Container( + width: 4, + height: 20, + decoration: BoxDecoration( + color: _parseColor(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, + ), + ), + ], + ), ), Row( children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: AppColors.surfaceTertiary, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.borderTertiary), + GestureDetector( + onTap: () => CreateEventSheet.edit( + context, + event, + onSaved: () { + setState(() { + _loadEvent(); + }); + }, ), - child: const Icon( - LucideIcons.pencil, - size: 18, - color: AppColors.slate600, + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: const Color(0xFFF8FAFF), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFDCE5F4)), + ), + child: const Icon( + LucideIcons.pencil, + size: 18, + color: AppColors.slate600, + ), ), ), const SizedBox(width: 8), - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: const Color(0xFFFFF1F2), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFFECACA)), - ), - child: const Icon( - LucideIcons.trash2, - size: 18, - color: AppColors.red500, + GestureDetector( + onTap: _showDeleteConfirmation, + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: const Color(0xFFFFF1F2), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFECACA)), + ), + child: const Icon( + LucideIcons.trash2, + size: 18, + color: AppColors.red500, + ), ), ), ], @@ -146,6 +263,30 @@ class CalendarEventDetailScreen extends StatelessWidget { ); } + 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: () { + CalendarService().deleteEvent(widget.eventId); + Navigator.pop(context); + context.pop(); + }, + child: Text('删除', style: TextStyle(color: AppColors.red500)), + ), + ], + ), + ); + } + Widget _buildDetailField(String label, String value) { return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -171,7 +312,8 @@ class CalendarEventDetailScreen extends StatelessWidget { ); } - Widget _buildColorField() { + Widget _buildColorField(String? colorHex) { + final color = _parseColor(colorHex); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -186,39 +328,18 @@ class CalendarEventDetailScreen extends StatelessWidget { const SizedBox(height: 6), Row( children: [ - _buildColorOption(const Color(0xFF3B82F6), isSelected: true), - const SizedBox(width: 10), - _buildColorOption(const Color(0xFF8B5CF6)), - const SizedBox(width: 10), - _buildColorOption(AppColors.success), - const SizedBox(width: 10), - _buildColorOption(AppColors.warning), - const SizedBox(width: 10), - _buildColorOption(AppColors.error), + Container( + width: 28, + height: 28, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), ], ), ], ); } - Widget _buildColorOption(Color color, {bool isSelected = false}) { - return Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - border: isSelected - ? Border.all(color: AppColors.slate900, width: 2) - : null, - ), - child: isSelected - ? const Icon(Icons.check, size: 16, color: Colors.white) - : null, - ); - } - - Widget _buildNotesField() { + Widget _buildNotesField(String notes) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -237,32 +358,31 @@ class CalendarEventDetailScreen extends StatelessWidget { decoration: BoxDecoration( color: const Color(0xFFFDFEFF), borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.borderTertiary), + border: Border.all(color: const Color(0xFFDCE5F4)), ), - child: const Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '记得提前准备好身份证', - style: TextStyle(fontSize: 14, color: AppColors.slate700), - ), - SizedBox(height: 8), - Text( - '出发前检查车票信息', - style: TextStyle(fontSize: 14, color: AppColors.slate700), - ), - ], + child: Text( + notes, + style: const TextStyle(fontSize: 14, color: AppColors.slate700), ), ), ], ); } + Color _parseColor(String? hex) { + if (hex == null || hex.isEmpty) return AppColors.blue600; + try { + return Color(int.parse(hex.replaceFirst('#', '0xFF'))); + } catch (_) { + return AppColors.blue600; + } + } + Widget _buildInputContainer() { return Container( height: 80, padding: const EdgeInsets.all(16), - color: AppColors.surfaceSecondary, + color: const Color(0xFFF8FAFC), child: Row( children: [ Container( @@ -271,7 +391,7 @@ class CalendarEventDetailScreen extends StatelessWidget { decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(18), - border: Border.all(color: AppColors.borderSecondary), + border: Border.all(color: const Color(0xFFDCE5F4)), ), child: const Icon( LucideIcons.plus, diff --git a/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart index 7e597c5..6c681c2 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart @@ -7,6 +7,8 @@ import '../../../../core/theme/design_tokens.dart'; import '../calendar_state_manager.dart'; import '../calendar_time_utils.dart'; import '../widgets/bottom_dock.dart'; +import '../widgets/create_event_sheet.dart'; +import '../../data/services/mock_calendar_service.dart'; class CalendarMonthScreen extends StatefulWidget { final bool resetToToday; @@ -21,6 +23,7 @@ class _CalendarMonthScreenState extends State { late final CalendarStateManager _calendarManager; late DateTime _currentMonth; late DateTime _selectedDate; + Key _eventsKey = UniqueKey(); @override void initState() { @@ -91,6 +94,30 @@ class _CalendarMonthScreenState extends State { ], ), ), + const Spacer(), + GestureDetector( + onTap: () => CreateEventSheet.show( + context, + onSaved: () { + setState(() { + _eventsKey = UniqueKey(); + }); + }, + ), + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: AppColors.blue600, + borderRadius: BorderRadius.circular(18), + ), + child: const Icon( + LucideIcons.plus, + size: 20, + color: Colors.white, + ), + ), + ), ], ), ), @@ -223,13 +250,25 @@ class _CalendarMonthScreenState extends State { }), ), const SizedBox(height: 10), - _buildWeekEvents(weekStart, startWeekday, daysInMonth), + KeyedSubtree( + key: _eventsKey, + child: _buildWeekEvents(weekStart, startWeekday, daysInMonth), + ), ], ), ); } Widget _buildWeekEvents(int weekStart, int startWeekday, int daysInMonth) { + final firstDayOfMonth = DateTime( + _currentMonth.year, + _currentMonth.month, + 1, + ); + final weekFirstDate = firstDayOfMonth.add( + Duration(days: weekStart - startWeekday), + ); + return SizedBox( height: 70, child: Row( @@ -239,12 +278,81 @@ class _CalendarMonthScreenState extends State { if (dayIndex < 1 || dayIndex > daysInMonth) { return const SizedBox(width: 38, height: 1); } - return const SizedBox(width: 38, height: 20); + + final date = weekFirstDate.add(Duration(days: index)); + final events = CalendarService().getEventsForDay(date); + final displayEvents = events.take(2).toList(); + final remainingCount = events.length - 2; + + return SizedBox( + width: 38, + height: 70, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...displayEvents.map((event) { + final color = _parseColor(event.metadata?.color); + return GestureDetector( + onTap: () { + _calendarManager.setSelectedDate(date); + context.push('/calendar/events/${event.id}'); + }, + child: Container( + margin: const EdgeInsets.only(bottom: 2), + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 2, + ), + decoration: BoxDecoration( + color: color.withOpacity(0.2), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + event.title, + style: TextStyle( + fontSize: 9, + color: color, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ); + }), + if (remainingCount > 0) + GestureDetector( + onTap: () { + _calendarManager.setSelectedDate(date); + _calendarManager.setViewType(CalendarViewType.day); + context.push('/calendar/dayweek?date=${formatYmd(date)}'); + }, + child: Text( + '+$remainingCount', + style: const TextStyle( + fontSize: 9, + color: AppColors.slate500, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); }), ), ); } + Color _parseColor(String? hex) { + if (hex == null || hex.isEmpty) return AppColors.blue600; + try { + return Color(int.parse(hex.replaceFirst('#', '0xFF'))); + } catch (_) { + return AppColors.blue600; + } + } + void _showMonthPicker() { showModalBottomSheet( context: context, diff --git a/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart b/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart new file mode 100644 index 0000000..96200bd --- /dev/null +++ b/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart @@ -0,0 +1,520 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import '../../../../core/theme/design_tokens.dart'; +import '../../data/models/schedule_item_model.dart'; +import '../../data/services/mock_calendar_service.dart'; + +class CreateEventSheet extends StatefulWidget { + final DateTime? initialDate; + final ScheduleItemModel? editingEvent; + final VoidCallback? onSaved; + + const CreateEventSheet({ + super.key, + this.initialDate, + this.editingEvent, + this.onSaved, + }); + + static Future show( + BuildContext context, { + DateTime? initialDate, + VoidCallback? onSaved, + }) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => + CreateEventSheet(initialDate: initialDate, onSaved: onSaved), + ); + } + + static Future edit( + BuildContext context, + ScheduleItemModel event, { + VoidCallback? onSaved, + }) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => + CreateEventSheet(editingEvent: event, onSaved: onSaved), + ); + } + + @override + State createState() => _CreateEventSheetState(); +} + +class _CreateEventSheetState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + final _titleController = TextEditingController(); + final _descriptionController = TextEditingController(); + final _locationController = TextEditingController(); + final _notesController = TextEditingController(); + + late DateTime _startDate; + late DateTime _startTime; + DateTime? _endDate; + DateTime? _endTime; + String _selectedColor = '#3B82F6'; + + bool get _isEditing => widget.editingEvent != null; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + + if (_isEditing) { + final event = widget.editingEvent!; + _titleController.text = event.title; + _descriptionController.text = event.description ?? ''; + _locationController.text = event.metadata?.location ?? ''; + _notesController.text = event.metadata?.notes ?? ''; + _startDate = event.startAt; + _startTime = event.startAt; + _endDate = event.endAt; + _endTime = event.endAt; + _selectedColor = event.metadata?.color ?? '#3B82F6'; + } else { + final now = widget.initialDate ?? DateTime.now(); + _startDate = now; + _startTime = now; + _endDate = now; + _endTime = now.add(const Duration(hours: 1)); + } + + _titleController.addListener(() => setState(() {})); + } + + @override + void dispose() { + _tabController.dispose(); + _titleController.dispose(); + _descriptionController.dispose(); + _locationController.dispose(); + _notesController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + height: MediaQuery.of(context).size.height * 0.85, + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + children: [ + _buildHeader(), + _buildTabBar(), + Expanded(child: _buildTabContent()), + ], + ), + ); + } + + Widget _buildHeader() { + return Container( + height: 56, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + GestureDetector( + onTap: () => Navigator.pop(context), + child: const Icon( + LucideIcons.x, + size: 24, + color: AppColors.slate700, + ), + ), + Text( + _isEditing ? '编辑日程' : '新建日程', + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + color: AppColors.slate900, + ), + ), + GestureDetector( + onTap: _saveEvent, + child: Text( + '保存', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + color: _titleController.text.trim().isNotEmpty + ? AppColors.blue600 + : AppColors.slate400, + ), + ), + ), + ], + ), + ); + } + + Widget _buildTabBar() { + return Container( + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: AppColors.border)), + ), + child: TabBar( + controller: _tabController, + labelColor: AppColors.blue600, + unselectedLabelColor: AppColors.slate600, + indicatorColor: AppColors.blue600, + tabs: const [ + Tab(text: '基础'), + Tab(text: '进阶'), + ], + ), + ); + } + + Widget _buildTabContent() { + return TabBarView( + controller: _tabController, + children: [_buildBasicTab(), _buildAdvancedTab()], + ); + } + + Widget _buildBasicTab() { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTextField('标题', _titleController, '请输入日程标题'), + const SizedBox(height: 20), + _buildDateTimePicker('开始', _startDate, _startTime, (date, time) { + setState(() { + _startDate = date; + _startTime = time; + }); + }), + const SizedBox(height: 20), + _buildDateTimePicker( + '结束', + _endDate ?? _startDate, + _endTime ?? _startTime, + (date, time) { + setState(() { + _endDate = date; + _endTime = time; + }); + }, + isOptional: true, + ), + ], + ), + ); + } + + Widget _buildAdvancedTab() { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTextField('描述', _descriptionController, '请输入描述'), + const SizedBox(height: 20), + _buildTextField('地点', _locationController, '请输入地点'), + const SizedBox(height: 20), + _buildColorPicker(), + const SizedBox(height: 20), + _buildTextField('备注', _notesController, '请输入备注', maxLines: 3), + ], + ), + ); + } + + Widget _buildTextField( + String label, + TextEditingController controller, + String hint, { + int maxLines = 1, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.slate700, + ), + ), + const SizedBox(height: 8), + TextField( + controller: controller, + maxLines: maxLines, + decoration: InputDecoration( + hintText: hint, + hintStyle: const TextStyle(color: AppColors.slate400), + filled: true, + fillColor: const Color(0xFFF1F5F9), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + ), + ), + ], + ); + } + + Widget _buildDateTimePicker( + String label, + DateTime date, + DateTime time, + Function(DateTime, DateTime) onChanged, { + bool isOptional = false, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label + (isOptional ? '(可选)' : ''), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.slate700, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () => _showDatePicker(date, (newDate) { + onChanged(newDate, time); + }), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '${date.year}年${date.month}月${date.day}日', + style: const TextStyle( + fontSize: 15, + color: AppColors.slate900, + ), + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: GestureDetector( + onTap: () => _showTimePicker(time, (newTime) { + onChanged(date, newTime); + }), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}', + style: const TextStyle( + fontSize: 15, + color: AppColors.slate900, + ), + ), + ), + ), + ), + ], + ), + ], + ); + } + + Widget _buildColorPicker() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '颜色', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.slate700, + ), + ), + const SizedBox(height: 8), + Row( + children: defaultColors.map((color) { + final colorHex = + '#${color.value.toRadixString(16).substring(2).toUpperCase()}'; + final isSelected = _selectedColor == colorHex; + return GestureDetector( + onTap: () => setState(() => _selectedColor = colorHex), + child: Container( + margin: const EdgeInsets.only(right: 12), + width: 32, + height: 32, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: isSelected + ? Border.all(color: AppColors.slate900, width: 2) + : null, + ), + child: isSelected + ? const Icon(Icons.check, size: 16, color: Colors.white) + : null, + ), + ); + }).toList(), + ), + ], + ); + } + + void _showDatePicker(DateTime initial, Function(DateTime) onChanged) { + showModalBottomSheet( + context: context, + builder: (context) => Container( + height: 280, + color: Colors.white, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消'), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('确定'), + ), + ], + ), + Expanded( + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.date, + initialDateTime: initial, + onDateTimeChanged: onChanged, + ), + ), + ], + ), + ), + ); + } + + void _showTimePicker(DateTime initial, Function(DateTime) onChanged) { + showModalBottomSheet( + context: context, + builder: (context) => Container( + height: 280, + color: Colors.white, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消'), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('确定'), + ), + ], + ), + Expanded( + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.time, + initialDateTime: initial, + onDateTimeChanged: onChanged, + ), + ), + ], + ), + ), + ); + } + + void _saveEvent() { + if (_titleController.text.trim().isEmpty) return; + + final startAt = DateTime( + _startDate.year, + _startDate.month, + _startDate.day, + _startTime.hour, + _startTime.minute, + ); + + DateTime? endAt; + if (_endDate != null && _endTime != null) { + endAt = DateTime( + _endDate!.year, + _endDate!.month, + _endDate!.day, + _endTime!.hour, + _endTime!.minute, + ); + } + + final metadata = ScheduleMetadata( + color: _selectedColor, + location: _locationController.text.trim().isNotEmpty + ? _locationController.text.trim() + : null, + notes: _notesController.text.trim().isNotEmpty + ? _notesController.text.trim() + : null, + ); + + final event = ScheduleItemModel( + id: _isEditing + ? widget.editingEvent!.id + : 'evt_${DateTime.now().millisecondsSinceEpoch}', + title: _titleController.text.trim(), + description: _descriptionController.text.trim().isNotEmpty + ? _descriptionController.text.trim() + : null, + startAt: startAt, + endAt: endAt, + metadata: metadata, + ); + + final service = CalendarService(); + if (_isEditing) { + service.updateEvent(event); + } else { + service.addEvent(event); + } + + widget.onSaved?.call(); + Navigator.pop(context); + } +}