feat(apps): 重构 UI 架构为 presentation 层并新增 l10n 国际化支持

This commit is contained in:
qzl
2026-03-27 14:05:03 +08:00
parent b1f0eb8921
commit c592cc7854
178 changed files with 10748 additions and 5764 deletions
@@ -0,0 +1,754 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../../app/router/app_routes.dart';
import '../../../home/presentation/navigation/home_return_policy.dart';
import '../../../../app/di/injection.dart';
import '../../../../core/l10n/l10n.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<CalendarDayWeekScreen> createState() => _CalendarDayWeekScreenState();
}
class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
with WidgetsBindingObserver {
static const double _dayItemWidth = 44;
static const double _dayItemGap = 12;
static const double _minEventTapHeight = 32;
final DayEventLayoutEngine _layoutEngine = const DayEventLayoutEngine();
final Map<int, Offset> _activePointers = {};
final ScrollController _dayStripController = ScrollController();
final ScrollController _timelineController = ScrollController();
DayViewScale _scale = DayViewScale.defaultScale();
DayViewScale _pinchStartScale = DayViewScale.defaultScale();
double? _pinchStartDistance;
late final CalendarStateManager _calendarManager;
late DateTime _selectedDate;
late List<DateTime> _monthDates;
List<ScheduleItemModel> _events = const [];
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_calendarManager = sl<CalendarStateManager>();
if (widget.resetToToday) {
_calendarManager.resetToToday();
}
_selectedDate = widget.initialDate ?? _calendarManager.selectedDate;
_updateMonthDates();
_loadEvents();
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToSelectedDate();
_scrollTimelineToNow();
});
}
void _updateMonthDates() {
_monthDates = monthDatesFor(_selectedDate);
}
Future<void> _loadEvents({bool forceRefresh = false}) async {
final events = await sl<CalendarRepository>().getDayEvents(
_selectedDate,
forceRefresh: forceRefresh,
);
if (!mounted) {
return;
}
setState(() {
_events = events;
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_dayStripController.dispose();
_timelineController.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(
controller: _timelineController,
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);
_scrollTimelineToNow(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;
}
if (nextScale.hourHeight < _scale.hourHeight &&
_scale.hourHeight <= DayViewScale.minHourHeight) {
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 = context.l10n.calendarDayWeekMonthYearLabel(
_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<Offset>(
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: Center(
child: Text(
context.l10n.calendarToday,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.slate700,
),
),
),
),
),
if (isNotToday) const SizedBox(width: 8),
AppPressable(
borderRadius: BorderRadius.circular(AppRadius.full),
onTap: () async {
final changed = await context.push<bool>(
'${AppRoutes.calendarEventCreate}?date=${formatYmd(_selectedDate)}',
);
if (changed == true) {
await _loadEvents(forceRefresh: true);
}
},
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);
}
void _scrollTimelineToNow({bool animate = false}) {
if (!_timelineController.hasClients) {
return;
}
final now = DateTime.now();
final minuteOfDay = now.hour * 60 + now.minute;
final targetY = _scale.pixelsForMinutes(minuteOfDay);
final viewportHeight = _timelineController.position.viewportDimension;
final offset = targetY - (viewportHeight / 3);
final max = _timelineController.position.maxScrollExtent;
final clampedOffset = offset.clamp(0.0, max);
if (animate) {
_timelineController.animateTo(
clampedOffset,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
return;
}
_timelineController.jumpTo(clampedOffset);
}
Widget _buildDayItem(DateTime date, bool isSelected, bool isWeekend) {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text(
_weekdayLabel(date),
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),
),
),
),
),
],
);
}
String _weekdayLabel(DateTime date) {
const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
return labels[date.weekday % 7];
}
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,
viewDate: _selectedDate,
);
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 isLastHour = hour == DayTimelineMetrics.hoursInDay;
final adjustedY = isLastHour ? boardHeight - 1 : y;
final labelTop = (adjustedY - 7).clamp(0.0, boardHeight - 14);
return Stack(
children: [
Positioned(
top: adjustedY,
left: eventAreaLeft,
right: 0,
child: Container(height: 1, color: 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: 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 isArchived = layout.event.status == ScheduleStatus.archived;
Color eventColor;
if (isArchived) {
eventColor = AppColors.slate400;
} else {
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: () async {
final changed = await context.push<bool>(
AppRoutes.calendarEventDetail(layout.event.id),
);
if (changed == true) {
await _loadEvents(forceRefresh: true);
}
},
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),
);
}
}
@@ -0,0 +1,20 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/design_tokens.dart';
import '../widgets/create_event_sheet.dart';
class CalendarEventCreateScreen extends StatelessWidget {
final DateTime? initialDate;
const CalendarEventCreateScreen({super.key, this.initialDate});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
body: SafeArea(
child: CreateEventSheet(initialDate: initialDate, pageMode: true),
),
);
}
}
@@ -0,0 +1,629 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:go_router/go_router.dart';
import 'package:social_app/core/l10n/l10n.dart';
import '../../../../app/di/injection.dart';
import '../../../../app/router/app_routes.dart';
import '../../../../features/notification/data/services/local_notification_service.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_loading_indicator.dart';
import '../../../../shared/widgets/back_title_page_header.dart';
import '../../../../shared/widgets/detail_header_action_menu.dart';
import '../../../../shared/widgets/destructive_action_sheet.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../data/services/calendar_service.dart';
import '../../data/models/schedule_item_model.dart';
import '../utils/event_color_resolver.dart';
enum _CalendarHeaderAction { edit, delete, share, archive }
class CalendarEventDetailScreen extends StatefulWidget {
final String eventId;
const CalendarEventDetailScreen({super.key, required this.eventId});
@override
State<CalendarEventDetailScreen> createState() =>
_CalendarEventDetailScreenState();
}
class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
ScheduleItemModel? _event;
bool _loading = true;
@override
void initState() {
super.initState();
_loadEvent();
}
Future<void> _loadEvent() async {
try {
_event = await sl<CalendarService>().getEventById(widget.eventId);
} catch (e) {
_event = null;
} finally {
_loading = false;
}
if (mounted) {
setState(() {});
}
}
@override
Widget build(BuildContext context) {
if (_loading) {
return const Scaffold(
body: SafeArea(child: Center(child: AppLoadingIndicator(size: 22))),
);
}
if (_event == null) {
return Scaffold(
backgroundColor: AppColors.background,
body: SafeArea(
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [AppColors.homeBackgroundTop, AppColors.background],
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
BackTitlePageHeader(title: context.l10n.calendarDetailTitle),
Expanded(
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xl,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
context.l10n.calendarDetailNotFoundTitle,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.slate700,
),
),
const SizedBox(height: AppSpacing.sm),
Text(
context.l10n.calendarDetailNotFoundDesc,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: AppColors.slate500,
),
),
],
),
),
),
),
],
),
),
),
);
}
final event = _event!;
return Scaffold(
backgroundColor: AppColors.background,
body: SafeArea(
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [AppColors.homeBackgroundTop, AppColors.background],
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(event),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(
AppSpacing.lg,
AppSpacing.sm,
AppSpacing.lg,
AppSpacing.xl,
),
children: [
_buildHeroSurface(event),
const SizedBox(height: AppSpacing.md),
_buildMetaSurface(event),
if (_hasExtraInfo(event)) ...[
const SizedBox(height: AppSpacing.md),
_buildExtraSurface(event),
],
],
),
),
],
),
),
),
);
}
Widget _buildHeader(ScheduleItemModel event) {
return BackTitlePageHeader(
title: context.l10n.calendarDetailTitle,
onBack: () => context.pop(),
trailing: _buildHeaderActions(event),
);
}
Widget _buildHeaderActions(ScheduleItemModel event) {
final items = <DetailHeaderActionItem<_CalendarHeaderAction>>[];
if (event.canEdit) {
items.add(
DetailHeaderActionItem<_CalendarHeaderAction>(
value: _CalendarHeaderAction.edit,
label: context.l10n.commonEdit,
icon: LucideIcons.pencil,
),
);
}
if (event.canDelete) {
items.add(
DetailHeaderActionItem<_CalendarHeaderAction>(
value: _CalendarHeaderAction.delete,
label: context.l10n.commonDelete,
icon: LucideIcons.trash2,
isDestructive: true,
),
);
}
if (event.canInvite) {
items.add(
DetailHeaderActionItem<_CalendarHeaderAction>(
value: _CalendarHeaderAction.share,
label: context.l10n.commonShare,
icon: LucideIcons.share2,
),
);
}
if (event.status != ScheduleStatus.archived && event.canEdit) {
final isExpired = _isEventExpired(event);
items.add(
DetailHeaderActionItem<_CalendarHeaderAction>(
value: _CalendarHeaderAction.archive,
label: context.l10n.commonArchive,
icon: LucideIcons.archive,
enabled: !isExpired,
),
);
}
return DetailHeaderActionMenu<_CalendarHeaderAction>(
items: items,
onSelected: (action) => _handleHeaderAction(action, event),
);
}
bool _isEventExpired(ScheduleItemModel event) {
final now = DateTime.now();
if (event.endAt != null) {
return event.endAt!.isBefore(now);
}
return event.startAt.isBefore(now);
}
Future<void> _handleHeaderAction(
_CalendarHeaderAction action,
ScheduleItemModel event,
) async {
switch (action) {
case _CalendarHeaderAction.edit:
final changed = await context.push<bool>(
AppRoutes.calendarEventEdit(event.id),
);
if (changed == true) {
_loadEvent();
if (mounted) context.pop(true);
}
return;
case _CalendarHeaderAction.delete:
_showDeleteConfirmation();
return;
case _CalendarHeaderAction.share:
context.push(AppRoutes.calendarEventShare(event.id));
return;
case _CalendarHeaderAction.archive:
_archiveEvent();
return;
}
}
Widget _buildHeroSurface(ScheduleItemModel event) {
final color = resolveEventColor(
status: event.status,
colorHex: event.metadata?.color,
);
final timeRange = _formatRangeLabel(event.startAt, event.endAt);
return Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: AppColors.borderTertiary),
boxShadow: [
BoxShadow(
color: AppColors.slate200.withValues(alpha: 0.38),
blurRadius: AppRadius.lg,
offset: const Offset(0, AppSpacing.xs),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: AppSpacing.sm,
height: AppSpacing.xxl,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(AppRadius.full),
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
event.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: AppColors.slate900,
),
),
const SizedBox(height: AppSpacing.xs),
_buildStatusBadge(event.status),
],
),
),
],
),
const SizedBox(height: AppSpacing.md),
Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.surfaceInfoLight,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.borderQuaternary),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.calendarDetailTimeArrangement,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.slate500,
),
),
const SizedBox(height: AppSpacing.xs),
Text(
timeRange,
maxLines: 2,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.slate800,
),
),
],
),
),
],
),
);
}
Widget _buildMetaSurface(ScheduleItemModel event) {
final startAt = event.startAt;
final dateStr = context.l10n.calendarDetailDateLabel(
startAt.year,
startAt.month,
startAt.day,
_getWeekday(startAt.weekday),
);
final color = resolveEventColor(
status: event.status,
colorHex: event.metadata?.color,
);
return Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: AppColors.borderSecondary),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.calendarDetailBasicInfo,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.slate500,
),
),
const SizedBox(height: AppSpacing.md),
_buildDetailRow(context.l10n.calendarDetailDate, dateStr),
const SizedBox(height: AppSpacing.md),
_buildDetailRow(
context.l10n.calendarDetailReminder,
_formatReminderText(event.metadata?.reminderMinutes),
),
const SizedBox(height: AppSpacing.md),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: AppSpacing.xxl * 3,
child: Text(
context.l10n.calendarDetailColor,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.slate500,
),
),
),
Container(
width: AppSpacing.xl,
height: AppSpacing.xl,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(color: AppColors.borderSecondary),
),
),
],
),
],
),
);
}
bool _hasExtraInfo(ScheduleItemModel event) {
return (event.metadata?.location?.trim().isNotEmpty ?? false) ||
(event.description?.trim().isNotEmpty ?? false) ||
(event.metadata?.notes?.trim().isNotEmpty ?? false);
}
Widget _buildExtraSurface(ScheduleItemModel event) {
return Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.surfaceSecondary,
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: AppColors.borderSecondary),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.calendarDetailExtraInfo,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.slate500,
),
),
if (event.metadata?.location?.trim().isNotEmpty ?? false) ...[
const SizedBox(height: AppSpacing.md),
_buildDetailRow(
context.l10n.calendarDetailLocation,
event.metadata!.location!.trim(),
),
],
if (event.description?.trim().isNotEmpty ?? false) ...[
const SizedBox(height: AppSpacing.md),
_buildDetailRow(
context.l10n.calendarDetailDescription,
event.description!.trim(),
),
],
if (event.metadata?.notes?.trim().isNotEmpty ?? false) ...[
const SizedBox(height: AppSpacing.md),
_buildDetailRow(
context.l10n.calendarDetailNotes,
event.metadata!.notes!.trim(),
multiline: true,
),
],
],
),
);
}
String _formatReminderText(int? reminderMinutes) {
if (reminderMinutes == null) {
return context.l10n.calendarDetailReminderNone;
}
if (reminderMinutes == 0) {
return context.l10n.calendarDetailReminderOnTime;
}
return context.l10n.calendarDetailReminderBeforeMinutes(reminderMinutes);
}
String _getWeekday(int weekday) {
final l10n = context.l10n;
final weekdays = [
l10n.calendarWeekdayMon,
l10n.calendarWeekdayTue,
l10n.calendarWeekdayWed,
l10n.calendarWeekdayThu,
l10n.calendarWeekdayFri,
l10n.calendarWeekdaySat,
l10n.calendarWeekdaySun,
];
return weekdays[weekday - 1];
}
String _formatTime(DateTime time) {
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
}
Future<void> _showDeleteConfirmation() async {
final confirmed = await showDestructiveActionSheet(
context,
title: context.l10n.calendarDetailDeleteTitle,
message: context.l10n.calendarDetailDeleteMessage,
confirmText: context.l10n.calendarDetailDeleteConfirm,
);
if (!confirmed) {
return;
}
await sl<CalendarService>().deleteEvent(widget.eventId);
try {
await sl<LocalNotificationService>().cancelEventReminder(widget.eventId);
} catch (_) {}
if (!mounted) {
return;
}
context.pop(true);
}
Future<void> _archiveEvent() async {
final confirmed = await showDestructiveActionSheet(
context,
title: context.l10n.calendarDetailArchiveTitle,
message: context.l10n.calendarDetailArchiveMessage,
confirmText: context.l10n.calendarDetailArchiveConfirm,
);
if (!confirmed) {
return;
}
try {
await sl<CalendarService>().archiveEvent(widget.eventId);
if (mounted) {
context.pop(true);
}
} catch (e) {
if (mounted) {
Toast.show(
context,
context.l10n.calendarDetailArchiveFailed,
type: ToastType.error,
);
}
}
}
String _formatRangeLabel(DateTime startAt, DateTime? endAt) {
final startLabel = context.l10n.calendarDetailDateTimeShort(
startAt.month,
startAt.day,
_getWeekday(startAt.weekday),
_formatTime(startAt),
);
if (endAt == null) {
return startLabel;
}
final isSameDay =
startAt.year == endAt.year &&
startAt.month == endAt.month &&
startAt.day == endAt.day;
if (isSameDay) {
return '$startLabel - ${_formatTime(endAt)}';
}
final endLabel = context.l10n.calendarDetailDateTimeShort(
endAt.month,
endAt.day,
_getWeekday(endAt.weekday),
_formatTime(endAt),
);
return context.l10n.calendarDetailRangeWithStartEnd(startLabel, endLabel);
}
Widget _buildStatusBadge(ScheduleStatus status) {
final isArchived = status == ScheduleStatus.archived;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: AppSpacing.xs,
),
decoration: BoxDecoration(
color: isArchived
? AppColors.feedbackWarningSurface
: AppColors.feedbackSuccessSurface,
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(
color: isArchived
? AppColors.feedbackWarningBorder
: AppColors.feedbackSuccessBorder,
),
),
child: Text(
isArchived
? context.l10n.calendarDetailStatusExpired
: context.l10n.settingsJobStatusEnabled,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: isArchived
? AppColors.feedbackWarningText
: AppColors.feedbackSuccessText,
),
),
);
}
Widget _buildDetailRow(String label, String value, {bool multiline = false}) {
return Row(
crossAxisAlignment: multiline
? CrossAxisAlignment.start
: CrossAxisAlignment.center,
children: [
SizedBox(
width: AppSpacing.xxl * 3,
child: Text(
label,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.slate500,
),
),
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
value,
style: TextStyle(
fontSize: 14,
height: multiline ? 1.4 : 1.0,
fontWeight: FontWeight.w600,
color: AppColors.slate800,
),
),
),
],
);
}
}
@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import '../../../../app/di/injection.dart';
import '../../../../core/l10n/l10n.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_loading_indicator.dart';
import '../widgets/create_event_sheet.dart';
import '../../data/models/schedule_item_model.dart';
import '../../data/services/calendar_service.dart';
class CalendarEventEditScreen extends StatefulWidget {
final String eventId;
const CalendarEventEditScreen({super.key, required this.eventId});
@override
State<CalendarEventEditScreen> createState() =>
_CalendarEventEditScreenState();
}
class _CalendarEventEditScreenState extends State<CalendarEventEditScreen> {
ScheduleItemModel? _event;
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
_event = await sl<CalendarService>().getEventById(widget.eventId);
} catch (_) {
_event = null;
}
if (!mounted) {
return;
}
setState(() {
_loading = false;
});
}
@override
Widget build(BuildContext context) {
if (_loading) {
return const Scaffold(
body: SafeArea(child: Center(child: AppLoadingIndicator(size: 22))),
);
}
if (_event == null) {
return Scaffold(
body: SafeArea(
child: Center(
child: Text(context.l10n.calendarEventNoAccessOrMissing),
),
),
);
}
return Scaffold(
backgroundColor: AppColors.background,
body: SafeArea(
child: CreateEventSheet(editingEvent: _event, pageMode: true),
),
);
}
}
@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import '../../../../app/di/injection.dart';
import '../../../../core/l10n/l10n.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_loading_indicator.dart';
import '../../../../shared/widgets/back_title_page_header.dart';
import '../../data/models/schedule_item_model.dart';
import '../../data/services/calendar_service.dart';
import '../widgets/calendar_share_dialog.dart';
class CalendarEventShareScreen extends StatefulWidget {
final String eventId;
const CalendarEventShareScreen({super.key, required this.eventId});
@override
State<CalendarEventShareScreen> createState() =>
_CalendarEventShareScreenState();
}
class _CalendarEventShareScreenState extends State<CalendarEventShareScreen> {
ScheduleItemModel? _event;
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
_event = await sl<CalendarService>().getEventById(widget.eventId);
} catch (_) {
_event = null;
}
if (!mounted) {
return;
}
setState(() {
_loading = false;
});
}
@override
Widget build(BuildContext context) {
if (_loading) {
return const Scaffold(
body: SafeArea(child: Center(child: AppLoadingIndicator(size: 22))),
);
}
final event = _event;
if (event == null) {
return Scaffold(
body: SafeArea(
child: Center(
child: Text(context.l10n.calendarEventNoAccessOrMissing),
),
),
);
}
return Scaffold(
backgroundColor: AppColors.background,
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
BackTitlePageHeader(title: context.l10n.calendarShareTitle),
Expanded(
child: CalendarShareDialog(
eventId: event.id,
eventTitle: event.title,
canInvite: event.canInvite,
canEdit: event.canEdit,
),
),
],
),
),
);
}
}
@@ -0,0 +1,578 @@
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:go_router/go_router.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:social_app/core/l10n/l10n.dart';
import '../../../../app/di/injection.dart';
import '../../../../app/router/app_routes.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_pressable.dart';
import '../../../home/presentation/navigation/home_return_policy.dart';
import '../calendar_state_manager.dart';
import '../calendar_time_utils.dart';
import '../utils/event_color_resolver.dart';
import '../widgets/bottom_dock.dart';
import '../../data/models/schedule_item_model.dart';
import '../../data/services/calendar_repository.dart';
class CalendarMonthScreen extends StatefulWidget {
final bool resetToToday;
const CalendarMonthScreen({super.key, this.resetToToday = false});
@override
State<CalendarMonthScreen> createState() => _CalendarMonthScreenState();
}
class _CalendarMonthScreenState extends State<CalendarMonthScreen>
with WidgetsBindingObserver {
late final CalendarStateManager _calendarManager;
late DateTime _currentMonth;
late DateTime _selectedDate;
final Map<String, List<ScheduleItemModel>> _eventsByDay = {};
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_calendarManager = sl<CalendarStateManager>();
if (widget.resetToToday) {
_calendarManager.resetToToday();
}
final savedDate = _calendarManager.selectedDate;
_selectedDate = savedDate;
_currentMonth = DateTime(savedDate.year, savedDate.month, 1);
_loadMonthEvents();
}
Future<void> _loadMonthEvents({bool forceRefresh = false}) async {
final events = await sl<CalendarRepository>().getMonthEvents(
_currentMonth,
forceRefresh: forceRefresh,
);
if (!mounted) {
return;
}
_eventsByDay.clear();
for (final event in events) {
final key = formatYmd(event.startAt);
_eventsByDay[key] = [...(_eventsByDay[key] ?? const []), event];
}
setState(() {});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_loadMonthEvents(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: Column(
children: [
_buildHeader(),
Expanded(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.only(bottom: 84),
child: _buildMonthContent(),
),
),
),
_buildBottomDock(),
],
),
),
),
);
}
Widget _buildHeader() {
final l10n = context.l10n;
final today = DateTime.now();
final isNotToday = !isSameDay(_selectedDate, today);
return SizedBox(
height: 76,
child: Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 14, bottom: 6),
child: Column(
children: [
SizedBox(
height: 56,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
AppPressable(
borderRadius: BorderRadius.circular(AppRadius.md),
onTap: _showMonthPicker,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 160),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeOut,
child: Text(
l10n.calendarMonthHeader(_currentMonth.month),
key: ValueKey(_currentMonth.month),
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: AppColors.slate900,
),
),
),
const SizedBox(width: 6),
const Icon(
LucideIcons.chevronDown,
size: 16,
color: AppColors.slate900,
),
],
),
),
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: Center(
child: Text(
l10n.calendarMonthToday,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.slate700,
),
),
),
),
),
if (isNotToday) const SizedBox(width: 8),
AppPressable(
borderRadius: BorderRadius.circular(AppRadius.full),
onTap: () async {
final changed = await context.push<bool>(
AppRoutes.calendarEventCreate,
);
if (changed == true) {
await _loadMonthEvents(forceRefresh: true);
}
},
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,
),
),
),
],
),
),
],
),
),
);
}
void _goToToday() {
final today = DateTime.now();
final targetMonth = DateTime(today.year, today.month, 1);
setState(() {
_selectedDate = today;
if (_currentMonth.year != targetMonth.year ||
_currentMonth.month != targetMonth.month) {
_currentMonth = targetMonth;
}
});
_calendarManager.setSelectedDate(today);
_loadMonthEvents(forceRefresh: true);
}
Widget _buildMonthContent() {
return Column(
children: [
_buildWeekdayHeader(),
Container(height: 1, color: AppColors.border),
..._buildWeeks(),
],
);
}
Widget _buildWeekdayHeader() {
final l10n = context.l10n;
final List<String> weekdays = [
l10n.calendarMonthWeekdaySunShort,
l10n.calendarMonthWeekdayMonShort,
l10n.calendarMonthWeekdayTueShort,
l10n.calendarMonthWeekdayWedShort,
l10n.calendarMonthWeekdayThuShort,
l10n.calendarMonthWeekdayFriShort,
l10n.calendarMonthWeekdaySatShort,
];
return SizedBox(
height: 40,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: weekdays
.map(
(day) => SizedBox(
width: 36,
child: Center(
child: Text(
day,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppColors.slate400,
),
),
),
),
)
.toList(),
),
),
);
}
List<Widget> _buildWeeks() {
final firstDayOfMonth = DateTime(
_currentMonth.year,
_currentMonth.month,
1,
);
final lastDayOfMonth = DateTime(
_currentMonth.year,
_currentMonth.month + 1,
0,
);
final startWeekday = firstDayOfMonth.weekday % 7;
final daysInMonth = lastDayOfMonth.day;
final totalCells = ((daysInMonth + startWeekday) / 7).ceil() * 7;
final weeks = <Widget>[];
for (var weekStart = 0; weekStart < totalCells; weekStart += 7) {
weeks.add(_buildWeekRow(weekStart, startWeekday, daysInMonth));
if (weekStart + 7 < totalCells) {
weeks.add(Container(height: 1, color: AppColors.border));
}
}
return weeks;
}
Widget _buildWeekRow(int weekStart, int startWeekday, int daysInMonth) {
return Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 14, bottom: 0),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.generate(7, (index) {
final dayIndex = weekStart + index - startWeekday + 1;
if (dayIndex < 1 || dayIndex > daysInMonth) {
return SizedBox(width: 36, height: 36);
}
final date = DateTime(
_currentMonth.year,
_currentMonth.month,
dayIndex,
);
final isSelected = isSameDay(_selectedDate, date);
return AppPressable(
borderRadius: BorderRadius.circular(AppRadius.full),
onTap: () {
setState(() {
_selectedDate = date;
});
_calendarManager.setSelectedDate(date);
_calendarManager.setViewType(CalendarViewType.month);
context.push(
'${AppRoutes.calendarDayWeek}?date=${formatYmd(date)}',
);
},
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: isSelected ? AppColors.blue100 : Colors.transparent,
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: Center(
child: Text(
'$dayIndex',
style: TextStyle(
fontSize: 15,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
color: isSelected
? AppColors.blue600
: AppColors.slate900,
),
),
),
),
);
}),
),
const SizedBox(height: 10),
_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(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.generate(7, (index) {
final dayIndex = weekStart + index - startWeekday + 1;
if (dayIndex < 1 || dayIndex > daysInMonth) {
return const SizedBox(width: 38, height: 1);
}
final date = weekFirstDate.add(Duration(days: index));
final events = _eventsByDay[formatYmd(date)] ?? const [];
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 = resolveEventColor(
status: event.status,
colorHex: event.metadata?.color,
);
return AppPressable(
borderRadius: BorderRadius.circular(AppRadius.sm),
onTap: () async {
_calendarManager.setSelectedDate(date);
final changed = await context.push<bool>(
'/calendar/events/${event.id}',
);
if (changed == true) {
await _loadMonthEvents(forceRefresh: true);
}
},
child: Container(
margin: const EdgeInsets.only(bottom: 2),
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 2,
),
decoration: BoxDecoration(
color: color.withValues(alpha: 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)
AppPressable(
borderRadius: BorderRadius.circular(AppRadius.sm),
onTap: () {
_calendarManager.setSelectedDate(date);
_calendarManager.setViewType(CalendarViewType.day);
context.push(
'${AppRoutes.calendarDayWeek}?date=${formatYmd(date)}',
);
},
child: Text(
'+$remainingCount',
style: const TextStyle(
fontSize: 9,
color: AppColors.slate500,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}),
),
);
}
void _showMonthPicker() {
final l10n = context.l10n;
var selectedYear = _currentMonth.year;
var selectedMonth = _currentMonth.month;
showModalBottomSheet(
context: context,
backgroundColor: AppColors.white,
builder: (context) {
return StatefulBuilder(
builder: (context, setSheetState) {
return SizedBox(
height: 300,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.commonCancel),
),
TextButton(
onPressed: () {
Navigator.pop(context);
setState(() {
_currentMonth = DateTime(
selectedYear,
selectedMonth,
1,
);
_selectedDate = DateTime(
selectedYear,
selectedMonth,
1,
);
});
_calendarManager.setSelectedDate(_selectedDate);
_loadMonthEvents();
},
child: Text(l10n.commonConfirm),
),
],
),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: CupertinoPicker(
itemExtent: 40,
scrollController: FixedExtentScrollController(
initialItem: _currentMonth.year - 2020,
),
onSelectedItemChanged: (index) {
setSheetState(() {
selectedYear = 2020 + index;
});
},
children: List.generate(20, (index) {
return Center(
child: Text(
l10n.calendarMonthYearLabel(2020 + index),
),
);
}),
),
),
Expanded(
child: CupertinoPicker(
itemExtent: 40,
scrollController: FixedExtentScrollController(
initialItem: _currentMonth.month - 1,
),
onSelectedItemChanged: (index) {
setSheetState(() {
selectedMonth = index + 1;
});
},
children: List.generate(12, (index) {
return Center(
child: Text(
l10n.calendarMonthHeader(index + 1),
),
);
}),
),
),
],
),
),
],
),
);
},
);
},
);
}
Widget _buildBottomDock() {
return BottomDock(
activeTab: DockTab.calendar,
onTodoTap: () {
_calendarManager.setViewType(CalendarViewType.month);
context.push(AppRoutes.todoList);
},
onCalendarTap: () {},
onHomeTap: () => returnToHomePreserveState(context, forceGoHome: true),
);
}
}