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,55 @@
import 'package:flutter/material.dart';
enum CalendarViewType { day, month }
class CalendarState {
final CalendarViewType viewType;
final DateTime selectedDate;
CalendarState({required this.viewType, required this.selectedDate});
CalendarState copyWith({CalendarViewType? viewType, DateTime? selectedDate}) {
return CalendarState(
viewType: viewType ?? this.viewType,
selectedDate: selectedDate ?? this.selectedDate,
);
}
}
class CalendarStateManager extends ChangeNotifier {
CalendarState _state;
CalendarStateManager()
: _state = CalendarState(
viewType: CalendarViewType.month,
selectedDate: DateTime.now(),
);
CalendarState get state => _state;
CalendarViewType get viewType => _state.viewType;
DateTime get selectedDate => _state.selectedDate;
void setViewType(CalendarViewType type) {
_state = _state.copyWith(viewType: type);
notifyListeners();
}
void setSelectedDate(DateTime date) {
_state = _state.copyWith(selectedDate: date);
notifyListeners();
}
void resetToToday() {
final now = DateTime.now();
_state = CalendarState(
viewType: CalendarViewType.month,
selectedDate: DateTime(now.year, now.month, now.day),
);
notifyListeners();
}
void refresh() {
notifyListeners();
}
}
@@ -0,0 +1,58 @@
DateTime weekStartFor(DateTime date) {
return date.subtract(Duration(days: date.weekday % 7));
}
bool isSameDay(DateTime a, DateTime b) {
return a.year == b.year && a.month == b.month && a.day == b.day;
}
bool shouldShowCurrentMarker(DateTime selectedDate, DateTime now) {
return isSameDay(selectedDate, now);
}
String formatHm(DateTime dateTime) {
final hour = dateTime.hour.toString().padLeft(2, '0');
final minute = dateTime.minute.toString().padLeft(2, '0');
return '$hour:$minute';
}
String formatHour(int hour) {
if (hour == 24) {
return '00:00';
}
return '${hour.toString().padLeft(2, '0')}:00';
}
DateTime? parseYmd(String? ymd) {
if (ymd == null) {
return null;
}
final matched = RegExp(r'^(\d{4})-(\d{2})-(\d{2})$').firstMatch(ymd);
if (matched == null) {
return null;
}
final year = int.parse(matched.group(1)!);
final month = int.parse(matched.group(2)!);
final day = int.parse(matched.group(3)!);
final parsed = DateTime(year, month, day);
if (parsed.year != year || parsed.month != month || parsed.day != day) {
return null;
}
return parsed;
}
String formatYmd(DateTime dateTime) {
final year = dateTime.year.toString().padLeft(4, '0');
final month = dateTime.month.toString().padLeft(2, '0');
final day = dateTime.day.toString().padLeft(2, '0');
return '$year-$month-$day';
}
List<DateTime> monthDatesFor(DateTime date) {
final monthStart = DateTime(date.year, date.month, 1);
final monthEnd = DateTime(date.year, date.month + 1, 0);
return List.generate(
monthEnd.day,
(index) => DateTime(monthStart.year, monthStart.month, index + 1),
);
}
@@ -0,0 +1,240 @@
import '../../data/models/schedule_item_model.dart';
import 'day_timeline_metrics.dart';
import 'day_view_scale.dart';
class DayEventLayout {
final ScheduleItemModel event;
final int startMinutes;
final int endMinutes;
final int column;
final int columnCount;
final double top;
final double geometryHeight;
final double visualHeight;
final double left;
final double width;
const DayEventLayout({
required this.event,
required this.startMinutes,
required this.endMinutes,
required this.column,
required this.columnCount,
required this.top,
required this.geometryHeight,
required this.visualHeight,
required this.left,
required this.width,
});
}
class DayEventLayoutEngine {
const DayEventLayoutEngine();
List<DayEventLayout> layout({
required List<ScheduleItemModel> events,
required DayViewScale scale,
required double eventAreaLeft,
required double eventAreaWidth,
required DateTime viewDate,
double columnGap = DayTimelineMetrics.eventColumnGap,
}) {
if (events.isEmpty || eventAreaWidth <= 0) {
return const [];
}
final sorted =
events
.map((e) => _EventSpan.fromEvent(e, viewDate))
.expand((spans) => spans)
.where((span) => span.endMinutes > span.startMinutes)
.toList()
..sort((a, b) {
final byStart = a.startMinutes.compareTo(b.startMinutes);
if (byStart != 0) {
return byStart;
}
final byEnd = a.endMinutes.compareTo(b.endMinutes);
if (byEnd != 0) {
return byEnd;
}
return a.event.id.compareTo(b.event.id);
});
if (sorted.isEmpty) {
return const [];
}
final active = <_PlacedSpan>[];
final clusters = <List<_PlacedSpan>>[];
List<_PlacedSpan> currentCluster = [];
var clusterEnd = sorted.first.endMinutes;
for (final span in sorted) {
active.removeWhere((item) => item.endMinutes <= span.startMinutes);
if (currentCluster.isNotEmpty && span.startMinutes >= clusterEnd) {
clusters.add(currentCluster);
currentCluster = [];
clusterEnd = span.endMinutes;
} else if (span.endMinutes > clusterEnd) {
clusterEnd = span.endMinutes;
}
final usedColumns = active.map((item) => item.column).toSet();
var column = 0;
while (usedColumns.contains(column)) {
column++;
}
final placed = _PlacedSpan(
event: span.event,
startMinutes: span.startMinutes,
endMinutes: span.endMinutes,
column: column,
);
active.add(placed);
currentCluster.add(placed);
}
if (currentCluster.isNotEmpty) {
clusters.add(currentCluster);
}
final layouts = <DayEventLayout>[];
for (final cluster in clusters) {
final clusterColumnCount =
cluster.map((item) => item.column).reduce((a, b) => a > b ? a : b) +
1;
final totalGap = (clusterColumnCount - 1) * columnGap;
final columnWidth = clusterColumnCount > 0
? ((eventAreaWidth - totalGap) / clusterColumnCount).toDouble()
: eventAreaWidth;
for (final item in cluster) {
final top = scale.pixelsForMinutes(item.startMinutes);
final geometryHeight = scale.pixelsForMinutes(
item.endMinutes - item.startMinutes,
);
final visualHeight = geometryHeight < 1 ? 1.0 : geometryHeight;
final left = eventAreaLeft + item.column * (columnWidth + columnGap);
layouts.add(
DayEventLayout(
event: item.event,
startMinutes: item.startMinutes,
endMinutes: item.endMinutes,
column: item.column,
columnCount: clusterColumnCount,
top: top,
geometryHeight: geometryHeight,
visualHeight: visualHeight,
left: left,
width: columnWidth,
),
);
}
}
return layouts;
}
}
class _EventSpan {
final ScheduleItemModel event;
final int startMinutes;
final int endMinutes;
const _EventSpan({
required this.event,
required this.startMinutes,
required this.endMinutes,
});
static List<_EventSpan> fromEvent(
ScheduleItemModel event,
DateTime viewDate,
) {
final startAt = event.startAt;
final endAt = event.endAt;
final viewDateOnly = DateTime(viewDate.year, viewDate.month, viewDate.day);
final startDateOnly = DateTime(startAt.year, startAt.month, startAt.day);
final endDateOnly = endAt != null
? DateTime(endAt.year, endAt.month, endAt.day)
: startDateOnly;
final startMinOfDay = _minutesOfDay(startAt);
final endMinOfDay = endAt != null
? _minutesOfDay(endAt)
: startMinOfDay + 60;
if (endDateOnly.isAfter(startDateOnly)) {
if (viewDateOnly.isAtSameMomentAs(startDateOnly)) {
final clampedStart = DayTimelineMetrics.clampMinuteOfDay(startMinOfDay);
final clampedEnd = DayTimelineMetrics.minutesInDay;
if (clampedEnd > clampedStart) {
return [
_EventSpan(
event: event,
startMinutes: clampedStart,
endMinutes: clampedEnd,
),
];
}
} else if (viewDateOnly.isAtSameMomentAs(endDateOnly)) {
final clampedStart = 0;
final clampedEnd = DayTimelineMetrics.clampMinuteOfDay(endMinOfDay);
if (clampedEnd > clampedStart) {
return [
_EventSpan(
event: event,
startMinutes: clampedStart,
endMinutes: clampedEnd,
),
];
}
} else if (viewDateOnly.isAfter(startDateOnly) &&
viewDateOnly.isBefore(endDateOnly)) {
return [
_EventSpan(
event: event,
startMinutes: 0,
endMinutes: DayTimelineMetrics.minutesInDay,
),
];
}
return const [];
}
final clampedStart = DayTimelineMetrics.clampMinuteOfDay(startMinOfDay);
var clampedEnd = DayTimelineMetrics.clampMinuteOfDay(endMinOfDay);
if (clampedEnd <= clampedStart) {
clampedEnd = clampedStart + 1;
}
return [
_EventSpan(
event: event,
startMinutes: clampedStart,
endMinutes: clampedEnd,
),
];
}
}
class _PlacedSpan {
final ScheduleItemModel event;
final int startMinutes;
final int endMinutes;
final int column;
const _PlacedSpan({
required this.event,
required this.startMinutes,
required this.endMinutes,
required this.column,
});
}
int _minutesOfDay(DateTime dateTime) {
return dateTime.hour * 60 + dateTime.minute;
}
@@ -0,0 +1,29 @@
import 'day_view_scale.dart';
class DayTimelineMetrics {
static const int hoursInDay = 24;
static const int minutesInHour = 60;
static const int minutesInDay = hoursInDay * minutesInHour;
static const double timeLabelWidth = 44;
static const double timeLabelGap = 8;
static const double eventRightInset = 4;
static const double eventColumnGap = 4;
static double timelineHeight(DayViewScale scale) {
return scale.pixelsForMinutes(minutesInDay);
}
static double eventAreaLeft() {
return timeLabelWidth + timeLabelGap;
}
static double eventAreaWidth(double boardWidth) {
final width = boardWidth - eventAreaLeft() - eventRightInset;
return width > 0 ? width : 0;
}
static int clampMinuteOfDay(int minute) {
return minute.clamp(0, minutesInDay).toInt();
}
}
@@ -0,0 +1,38 @@
class DayViewScale {
static const double defaultHourHeight = 34.0;
static const double minHourHeight = 34.0;
static const double maxHourHeight = 68.0;
final double hourHeight;
const DayViewScale({required this.hourHeight});
factory DayViewScale.defaultScale() {
return const DayViewScale(hourHeight: minHourHeight);
}
DayViewScale copyWith({double? hourHeight}) {
return DayViewScale(
hourHeight: _clampHourHeight(hourHeight ?? this.hourHeight),
);
}
DayViewScale zoomByFactor(double factor) {
if (factor <= 0) {
return this;
}
return copyWith(hourHeight: hourHeight * factor);
}
double pixelsForMinutes(int minutes) {
return (minutes / 60) * hourHeight;
}
double minutesForPixels(double pixels) {
return (pixels / hourHeight) * 60;
}
static double _clampHourHeight(double value) {
return value.clamp(minHourHeight, maxHourHeight);
}
}
@@ -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),
);
}
}
@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../data/models/schedule_item_model.dart';
Color resolveEventColor({
required ScheduleStatus status,
required String? colorHex,
}) {
if (status == ScheduleStatus.archived) {
return AppColors.slate400;
}
if (colorHex == null || colorHex.isEmpty) {
return AppColors.blue600;
}
try {
return Color(int.parse(colorHex.replaceFirst('#', '0xFF')));
} catch (_) {
return AppColors.blue600;
}
}
@@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../../core/theme/design_tokens.dart';
enum DockTab { todo, calendar }
class BottomDock extends StatelessWidget {
final DockTab activeTab;
final VoidCallback? onTodoTap;
final VoidCallback? onCalendarTap;
final VoidCallback? onHomeTap;
const BottomDock({
super.key,
required this.activeTab,
this.onTodoTap,
this.onCalendarTap,
this.onHomeTap,
});
@override
Widget build(BuildContext context) {
return Container(
height: 72,
padding: const EdgeInsets.only(
left: AppSpacing.xl,
right: AppSpacing.xl,
top: AppSpacing.md,
bottom: AppSpacing.sm,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [_buildToggle(), _buildHomeBtn()],
),
);
}
Widget _buildToggle() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 4),
decoration: BoxDecoration(
color: AppColors.todoToggleBg,
borderRadius: BorderRadius.circular(AppRadius.xxl),
border: Border.all(color: AppColors.todoToggleBorder),
boxShadow: [
BoxShadow(
color: AppColors.slate200.withValues(alpha: 0.45),
blurRadius: AppRadius.sm,
offset: const Offset(0, AppSpacing.xs / 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildToggleItem(
icon: LucideIcons.listTodo,
isActive: activeTab == DockTab.todo,
onTap: onTodoTap,
),
const SizedBox(width: 4),
_buildToggleItem(
icon: LucideIcons.calendar,
isActive: activeTab == DockTab.calendar,
onTap: onCalendarTap,
),
],
),
);
}
Widget _buildToggleItem({
required IconData icon,
required bool isActive,
VoidCallback? onTap,
}) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.xl),
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
curve: Curves.easeOut,
width: 44,
height: 44,
decoration: BoxDecoration(
color: isActive ? AppColors.todoToggleActiveBg : Colors.transparent,
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(
color: isActive
? AppColors.todoToggleActiveBorder
: Colors.transparent,
),
),
child: Icon(
icon,
size: 20,
color: isActive ? AppColors.blue600 : AppColors.slate700,
),
),
),
);
}
Widget _buildHomeBtn() {
return Material(
color: Colors.transparent,
child: InkWell(
key: const ValueKey('bottom_dock_home_button'),
onTap: onHomeTap,
borderRadius: BorderRadius.circular(AppRadius.xl),
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: AppColors.todoToggleBg,
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: AppColors.todoToggleBorder),
boxShadow: [
BoxShadow(
color: AppColors.slate200.withValues(alpha: 0.42),
blurRadius: AppRadius.sm,
offset: const Offset(0, AppSpacing.xs / 2),
),
],
),
child: const Icon(
LucideIcons.home,
size: 20,
color: AppColors.slate700,
),
),
),
);
}
}
@@ -0,0 +1,231 @@
import 'package:flutter/material.dart' hide BackButton;
import 'package:social_app/core/l10n/l10n.dart';
import '../../../../app/di/injection.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../data/calendar_api.dart';
class CalendarShareDialog extends StatefulWidget {
final String eventId;
final String eventTitle;
final bool canInvite;
final bool canEdit;
const CalendarShareDialog({
super.key,
required this.eventId,
required this.eventTitle,
this.canInvite = false,
this.canEdit = false,
});
static Future<void> show(
BuildContext context,
String eventId,
String eventTitle, {
bool canInvite = false,
bool canEdit = false,
}) {
return showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => CalendarShareDialog(
eventId: eventId,
eventTitle: eventTitle,
canInvite: canInvite,
canEdit: canEdit,
),
);
}
@override
State<CalendarShareDialog> createState() => _CalendarShareDialogState();
}
class _CalendarShareDialogState extends State<CalendarShareDialog> {
final _phoneController = TextEditingController();
final bool _permissionView = true;
bool _permissionEdit = false;
bool _permissionInvite = false;
bool _isLoading = false;
@override
void dispose() {
_phoneController.dispose();
super.dispose();
}
Future<void> _handleShare() async {
final l10n = context.l10n;
final phone = _phoneController.text.trim();
if (phone.isEmpty) {
Toast.show(
context,
l10n.calendarSharePhoneRequired,
type: ToastType.error,
);
return;
}
setState(() => _isLoading = true);
try {
final api = sl<CalendarApi>();
await api.share(
widget.eventId,
phone: phone,
view: _permissionView,
edit: _permissionEdit,
invite: _permissionInvite,
);
if (mounted) {
Toast.show(
context,
l10n.calendarShareInviteSent,
type: ToastType.success,
);
Navigator.of(context).pop();
}
} catch (e) {
if (mounted) {
Toast.show(
context,
l10n.calendarShareInviteFailed,
type: ToastType.error,
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Container(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
decoration: BoxDecoration(
color: AppColors.background,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(AppRadius.lg),
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.calendarShareTitle,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
],
),
const SizedBox(height: AppSpacing.md),
Text(widget.eventTitle, style: const TextStyle(fontSize: 16)),
const SizedBox(height: AppSpacing.lg),
TextField(
controller: _phoneController,
decoration: InputDecoration(
labelText: l10n.calendarSharePhoneLabel,
hintText: l10n.calendarSharePhoneHint,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
),
),
keyboardType: TextInputType.phone,
),
const SizedBox(height: AppSpacing.lg),
Text(
l10n.calendarSharePermissionTitle,
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: AppSpacing.sm),
_buildPermissionSwitch(
l10n.calendarSharePermissionView,
l10n.calendarSharePermissionViewDesc,
true,
null,
),
_buildPermissionSwitch(
l10n.calendarSharePermissionEdit,
l10n.calendarSharePermissionEditDesc,
_permissionEdit,
widget.canEdit
? (v) => setState(() => _permissionEdit = v)
: null,
),
_buildPermissionSwitch(
l10n.calendarSharePermissionInvite,
l10n.calendarSharePermissionInviteDesc,
_permissionInvite,
widget.canInvite
? (v) => setState(() => _permissionInvite = v)
: null,
),
const SizedBox(height: AppSpacing.lg),
AppButton(
text: l10n.calendarShareSendInvite,
onPressed: _isLoading ? null : _handleShare,
isLoading: _isLoading,
),
],
),
),
),
);
}
Widget _buildPermissionSwitch(
String title,
String description,
bool value,
ValueChanged<bool>? onChanged,
) {
final enabled = onChanged != null;
return Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(color: enabled ? null : Colors.grey),
),
Text(
description,
style: TextStyle(
fontSize: 12,
color: enabled ? Colors.grey : Colors.grey.shade400,
),
),
],
),
),
Switch(value: value, onChanged: enabled ? onChanged : null),
],
),
);
}
}
@@ -0,0 +1,809 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:social_app/core/l10n/l10n.dart';
import '../../../../app/di/injection.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/app_selection_sheet.dart';
import '../../../../shared/widgets/app_sheet_input_field.dart';
import '../../../../shared/widgets/back_title_page_header.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import 'date_time_picker_sheet.dart';
import '../../data/models/schedule_item_model.dart';
import '../../data/services/calendar_service.dart';
class CreateEventSheet extends StatefulWidget {
final DateTime? initialDate;
final ScheduleItemModel? editingEvent;
final VoidCallback? onSaved;
final bool pageMode;
const CreateEventSheet({
super.key,
this.initialDate,
this.editingEvent,
this.onSaved,
this.pageMode = false,
});
static Future<void> 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<void> 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<CreateEventSheet> createState() => _CreateEventSheetState();
}
class _CreateEventSheetState extends State<CreateEventSheet>
with SingleTickerProviderStateMixin {
static const List<int?> _defaultReminderOptions = [
null,
0,
5,
10,
15,
30,
60,
120,
];
late TabController _tabController;
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
final _locationController = TextEditingController();
final _notesController = TextEditingController();
late DateTime _startDate;
late DateTime _startTime;
DateTime? _endDate;
DateTime? _endTime;
String _selectedColor = '#3B82F6';
int? _reminderMinutes = 15;
bool _saving = false;
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';
_reminderMinutes = _sanitizeReminderMinutes(
event.metadata?.reminderMinutes,
);
} else {
final now = DateTime.now();
final initial = widget.initialDate;
final rounded = _roundToNearestMinute(now, 5);
_startDate = initial != null
? DateTime(
initial.year,
initial.month,
initial.day,
rounded.hour,
rounded.minute,
)
: rounded;
_startTime = _startDate;
_endDate = _startDate;
_endTime = _startDate.add(const Duration(hours: 1));
}
}
@override
void dispose() {
_tabController.dispose();
_titleController.dispose();
_descriptionController.dispose();
_locationController.dispose();
_notesController.dispose();
super.dispose();
}
DateTime _roundToNearestMinute(DateTime dt, int interval) {
final totalMinutes = dt.hour * 60 + dt.minute;
final rounded = ((totalMinutes / interval).round() * interval);
final hours = rounded ~/ 60;
final minutes = rounded % 60;
final dayOffset = hours >= 24 ? 1 : 0;
final newHour = hours % 24;
return DateTime(dt.year, dt.month, dt.day + dayOffset, newHour, minutes);
}
@override
Widget build(BuildContext context) {
if (widget.pageMode) {
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => FocusScope.of(context).unfocus(),
child: Container(
color: AppColors.background,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildPageHeader(),
_buildTabBar(),
Expanded(child: _buildTabContent()),
],
),
),
);
}
return AnimatedPadding(
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Container(
height: MediaQuery.of(context).size.height * 0.85,
decoration: const BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: AppSpacing.sm),
Center(
child: Container(
width: AppSpacing.xl + AppSpacing.sm,
height: AppSpacing.xs,
decoration: BoxDecoration(
color: AppColors.slate200,
borderRadius: BorderRadius.circular(AppRadius.full),
),
),
),
const SizedBox(height: AppSpacing.sm),
_buildHeader(),
_buildTabBar(),
Expanded(child: _buildTabContent()),
],
),
),
);
}
Widget _buildPageHeader() {
return BackTitlePageHeader(
title: _isEditing
? context.l10n.calendarCreateEditTitle
: context.l10n.calendarCreateNewTitle,
onBack: () => Navigator.of(context).pop(),
trailing: ValueListenableBuilder<TextEditingValue>(
valueListenable: _titleController,
builder: (context, value, child) {
final enabled = value.text.trim().isNotEmpty && !_saving;
return SizedBox(
height: AppSpacing.xxl * 2,
child: TextButton(
onPressed: enabled ? _saveEvent : null,
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
minimumSize: const Size(AppSpacing.none, AppSpacing.none),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: _saving
? const AppLoadingIndicator(
variant: AppLoadingVariant.button,
size: 18,
trackColor: AppColors.blue200,
)
: Text(
context.l10n.commonSave,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: enabled ? AppColors.blue600 : AppColors.slate400,
),
),
),
);
},
),
);
}
Widget _buildHeader() {
return Container(
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
SizedBox(
width: AppSpacing.xxl * 2,
height: AppSpacing.xxl * 2,
child: TextButton(
onPressed: () => Navigator.pop(context),
style: TextButton.styleFrom(
padding: const EdgeInsets.all(AppSpacing.none),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.full),
),
),
child: const Icon(
LucideIcons.x,
size: AppSpacing.xxl,
color: AppColors.slate700,
),
),
),
Text(
_isEditing
? context.l10n.calendarCreateEditTitle
: context.l10n.calendarCreateNewTitle,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.slate900,
),
),
ValueListenableBuilder<TextEditingValue>(
valueListenable: _titleController,
builder: (context, value, child) {
final enabled = value.text.trim().isNotEmpty && !_saving;
return SizedBox(
height: AppSpacing.xxl * 2,
child: TextButton(
onPressed: enabled ? _saveEvent : null,
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
),
minimumSize: const Size(AppSpacing.none, AppSpacing.none),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: _saving
? const AppLoadingIndicator(
variant: AppLoadingVariant.button,
size: 18,
trackColor: AppColors.blue200,
)
: Text(
context.l10n.commonSave,
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: enabled
? 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: [
Tab(text: context.l10n.calendarCreateTabBasic),
Tab(text: context.l10n.calendarCreateTabAdvanced),
],
),
);
}
Widget _buildTabContent() {
return TabBarView(
controller: _tabController,
children: [_buildBasicTab(), _buildAdvancedTab()],
);
}
Widget _buildBasicTab() {
return SingleChildScrollView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTextField(
context.l10n.calendarCreateFieldTitle,
_titleController,
context.l10n.calendarCreateFieldTitleHint,
),
const SizedBox(height: 20),
_buildDateTimePicker(
context.l10n.calendarCreateFieldStart,
_startDate,
_startTime,
(date, time) {
setState(() {
_startDate = date;
_startTime = time;
if (_endDate != null && _endTime != null) {
final endDateTime = DateTime(
_endDate!.year,
_endDate!.month,
_endDate!.day,
_endTime!.hour,
_endTime!.minute,
);
final startDateTime = DateTime(
date.year,
date.month,
date.day,
time.hour,
time.minute,
);
if (endDateTime.isBefore(startDateTime) ||
endDateTime.isAtSameMomentAs(startDateTime)) {
_endDate = date;
_endTime = time.add(const Duration(hours: 1));
}
}
});
},
),
const SizedBox(height: 20),
_buildDateTimePicker(
context.l10n.calendarCreateFieldEnd,
_endDate ?? _startDate,
_endTime ?? _startTime,
(date, time) {
setState(() {
final startDateTime = DateTime(
_startDate.year,
_startDate.month,
_startDate.day,
_startTime.hour,
_startTime.minute,
);
final endDateTime = DateTime(
date.year,
date.month,
date.day,
time.hour,
time.minute,
);
if (endDateTime.isBefore(startDateTime) ||
endDateTime.isAtSameMomentAs(startDateTime)) {
_endDate = _startDate;
_endTime = _startTime.add(const Duration(hours: 1));
} else {
_endDate = date;
_endTime = time;
}
});
},
isOptional: true,
minTime: DateTime(
_startDate.year,
_startDate.month,
_startDate.day,
_startTime.hour,
_startTime.minute,
),
),
],
),
);
}
Widget _buildAdvancedTab() {
return SingleChildScrollView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTextField(
context.l10n.calendarCreateFieldDescription,
_descriptionController,
context.l10n.calendarCreateFieldDescriptionHint,
),
const SizedBox(height: 20),
_buildTextField(
context.l10n.calendarCreateFieldLocation,
_locationController,
context.l10n.calendarCreateFieldLocationHint,
),
const SizedBox(height: 20),
_buildReminderPicker(),
const SizedBox(height: 20),
_buildColorPicker(),
const SizedBox(height: 20),
_buildTextField(
context.l10n.calendarDetailNotes,
_notesController,
context.l10n.calendarCreateFieldNotesHint,
maxLines: 3,
),
],
),
);
}
Widget _buildTextField(
String label,
TextEditingController controller,
String hint, {
int maxLines = 1,
bool autofocus = false,
}) {
return AppSheetInputField(
controller: controller,
label: label,
hint: hint,
maxLines: maxLines,
autofocus: autofocus,
);
}
Widget _buildDateTimePicker(
String label,
DateTime date,
DateTime time,
Function(DateTime, DateTime) onChanged, {
bool isOptional = false,
DateTime? minTime,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isOptional ? context.l10n.calendarCreateOptionalField(label) : label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.slate700,
),
),
const SizedBox(height: 8),
InkWell(
onTap: () async {
final picked = await _pickDateTime(date, time, minTime: minTime);
if (picked == null) {
return;
}
onChanged(picked.$1, picked.$2);
},
borderRadius: BorderRadius.circular(AppRadius.md),
child: Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.slate50,
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(color: AppColors.borderSecondary),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(
LucideIcons.calendar,
size: 16,
color: AppColors.slate600,
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
_formatDateTimeLabel(date, time),
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: AppColors.slate900,
),
),
),
const Icon(
LucideIcons.chevronRight,
size: 16,
color: AppColors.slate400,
),
],
),
),
),
],
);
}
String _formatDateTimeLabel(DateTime date, DateTime time) {
return context.l10n.calendarCreateDateTimeLabel(
date.year,
date.month,
date.day,
time.hour.toString().padLeft(2, '0'),
time.minute.toString().padLeft(2, '0'),
);
}
Future<(DateTime, DateTime)?> _pickDateTime(
DateTime date,
DateTime time, {
DateTime? minTime,
}) async {
final result = await showModalBottomSheet<(DateTime, DateTime)>(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) => DateTimePickerSheet(
initialDate: date,
initialTime: time,
minTime: minTime,
),
);
return result;
}
Widget _buildColorPicker() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.calendarDetailColor,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.slate700,
),
),
const SizedBox(height: 8),
Row(
children: defaultColors.map((color) {
final colorHex =
'#${color.toARGB32().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(),
),
],
);
}
Widget _buildReminderPicker() {
String labelOf(int? value) {
if (value == null) {
return context.l10n.calendarCreateReminderNone;
}
if (value == 0) {
return context.l10n.calendarDetailReminderOnTime;
}
return context.l10n.calendarDetailReminderBeforeMinutes(value);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.calendarCreateReminderTime,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.slate700,
),
),
const SizedBox(height: 8),
InkWell(
onTap: () async {
final options = _buildReminderOptions();
final selected = await showAppSelectionSheet<int?>(
context,
title: context.l10n.calendarCreatePickReminderTime,
items: options
.map((v) => AppSelectionItem(value: v, label: labelOf(v)))
.toList(),
selectedValue: _reminderMinutes,
);
if (selected != null) {
setState(() {
_reminderMinutes = selected;
});
}
},
borderRadius: BorderRadius.circular(AppRadius.md),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.slate50,
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(color: AppColors.borderSecondary),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Text(
labelOf(_reminderMinutes),
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: AppColors.slate900,
),
),
),
const Icon(
LucideIcons.chevronRight,
size: 16,
color: AppColors.slate400,
),
],
),
),
),
],
);
}
int? _sanitizeReminderMinutes(int? minutes) {
return (minutes != null && minutes >= 0) ? minutes : null;
}
List<int?> _buildReminderOptions() {
final current = _sanitizeReminderMinutes(_reminderMinutes);
final nonNull = _defaultReminderOptions.whereType<int>().toSet();
if (current != null) {
nonNull.add(current);
}
final sorted = nonNull.toList()..sort();
return [null, ...sorted];
}
Future<void> _saveEvent() async {
if (_titleController.text.trim().isEmpty || _saving) return;
setState(() {
_saving = true;
});
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,
reminderMinutes: _reminderMinutes,
attachments: const [],
version: widget.editingEvent?.metadata?.version ?? 1,
);
final event = ScheduleItemModel(
id: _isEditing
? widget.editingEvent!.id
: 'evt_${DateTime.now().millisecondsSinceEpoch}',
ownerId: widget.editingEvent?.ownerId ?? '',
title: _titleController.text.trim(),
description: _descriptionController.text.trim().isNotEmpty
? _descriptionController.text.trim()
: null,
startAt: startAt,
endAt: endAt,
metadata: metadata,
);
try {
final service = sl<CalendarService>();
late final ScheduleItemModel saved;
if (_isEditing) {
saved = await service.updateEvent(event);
} else {
saved = await service.addEvent(event);
}
try {
final notificationService = sl<LocalNotificationService>();
await notificationService.upsertEventReminder(saved);
} catch (_) {
if (mounted) {
Toast.show(
context,
context.l10n.calendarCreateReminderPermissionFailed,
type: ToastType.warning,
);
}
}
widget.onSaved?.call();
if (mounted) {
Navigator.pop(context, true);
}
} catch (e) {
if (mounted) {
Toast.show(
context,
context.l10n.todoSaveFailed('$e'),
type: ToastType.error,
);
}
} finally {
if (mounted) {
setState(() {
_saving = false;
});
}
}
}
}
@@ -0,0 +1,388 @@
import 'package:flutter/cupertino.dart';
import 'package:social_app/core/l10n/l10n.dart';
import '../../../../core/theme/design_tokens.dart';
class DateTimePickerSheet extends StatefulWidget {
final DateTime initialDate;
final DateTime initialTime;
final DateTime? minTime;
const DateTimePickerSheet({
super.key,
required this.initialDate,
required this.initialTime,
this.minTime,
});
@override
State<DateTimePickerSheet> createState() => _DateTimePickerSheetState();
}
class _DateTimePickerSheetState extends State<DateTimePickerSheet> {
late int _selectedYear;
late int _selectedMonth;
late int _selectedDay;
late int _selectedHour;
late int _selectedMinute;
late FixedExtentScrollController _yearController;
late FixedExtentScrollController _monthController;
late FixedExtentScrollController _dayController;
late FixedExtentScrollController _hourController;
late FixedExtentScrollController _minuteController;
static final int _baseYear = DateTime.now().year;
static final List<int> _years = List.generate(21, (i) => _baseYear - 10 + i);
static final List<int> _months = List.generate(12, (i) => i + 1);
static final List<int> _allHours = List.generate(24, (i) => i);
static final List<int> _allMinutes = List.generate(60, (i) => i);
List<int> _days = [];
late List<int> _filteredHours;
late List<int> _filteredMinutes;
List<int> _getFilteredHours() {
if (widget.minTime == null) return _allHours;
final minDate = widget.minTime!;
if (_selectedYear > minDate.year ||
(_selectedYear == minDate.year && _selectedMonth > minDate.month) ||
(_selectedYear == minDate.year &&
_selectedMonth == minDate.month &&
_selectedDay > minDate.day)) {
return _allHours;
}
if (_selectedYear == minDate.year &&
_selectedMonth == minDate.month &&
_selectedDay == minDate.day) {
return _allHours.where((h) => h >= minDate.hour).toList();
}
return _allHours;
}
List<int> _getFilteredMinutes() {
if (widget.minTime == null) return _allMinutes;
final minDate = widget.minTime!;
if (_selectedYear > minDate.year ||
(_selectedYear == minDate.year && _selectedMonth > minDate.month) ||
(_selectedYear == minDate.year &&
_selectedMonth == minDate.month &&
_selectedDay > minDate.day)) {
return _allMinutes;
}
if (_selectedYear == minDate.year &&
_selectedMonth == minDate.month &&
_selectedDay == minDate.day &&
_selectedHour == minDate.hour) {
return _allMinutes.where((m) => m >= minDate.minute).toList();
}
return _allMinutes;
}
@override
void initState() {
super.initState();
_selectedYear = widget.initialDate.year;
_selectedMonth = widget.initialDate.month;
_selectedDay = widget.initialDate.day;
_selectedHour = widget.initialTime.hour;
_selectedMinute = widget.initialTime.minute;
_filteredHours = _getFilteredHours();
_filteredMinutes = _getFilteredMinutes();
_updateDays();
_yearController = FixedExtentScrollController(
initialItem: _years.indexOf(_selectedYear),
);
_monthController = FixedExtentScrollController(
initialItem: _selectedMonth - 1,
);
_dayController = FixedExtentScrollController(initialItem: _selectedDay - 1);
_hourController = FixedExtentScrollController(
initialItem: _filteredHours.indexOf(_selectedHour),
);
if (_filteredMinutes.isEmpty) {
_selectedMinute = 0;
} else if (!_filteredMinutes.contains(_selectedMinute)) {
_selectedMinute = _filteredMinutes.first;
}
_minuteController = FixedExtentScrollController(
initialItem: _filteredMinutes.indexOf(_selectedMinute),
);
}
void _updateDays() {
_days = List.generate(
DateTime(_selectedYear, _selectedMonth + 1, 0).day,
(i) => i + 1,
);
}
@override
void dispose() {
_yearController.dispose();
_monthController.dispose();
_dayController.dispose();
_hourController.dispose();
_minuteController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Container(
height: 420,
decoration: const BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
_buildHeader(),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
flex: 3,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildPickerLabel(l10n.calendarDateTimePickerDateLabel),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: _buildPicker(_years, _yearController, (v) {
setState(() {
_selectedYear = v;
_updateDays();
if (_selectedDay > _days.length) {
_selectedDay = _days.length;
_dayController.jumpToItem(_selectedDay - 1);
}
});
}, (v) => '$v'),
),
Text(
l10n.calendarDateTimePickerYearUnit,
style: TextStyle(
fontSize: 14,
color: AppColors.slate600,
),
),
Expanded(
child: _buildPicker(_months, _monthController, (
v,
) {
setState(() {
_selectedMonth = v;
_updateDays();
if (_selectedDay > _days.length) {
_selectedDay = _days.length;
_dayController.jumpToItem(_selectedDay - 1);
}
});
}, (v) => '$v'),
),
Text(
l10n.calendarDateTimePickerMonthUnit,
style: TextStyle(
fontSize: 14,
color: AppColors.slate600,
),
),
Expanded(
child: _buildPicker(
_days,
_dayController,
(v) => setState(() => _selectedDay = v),
(v) => '$v',
),
),
Text(
l10n.calendarDateTimePickerDayUnit,
style: TextStyle(
fontSize: 14,
color: AppColors.slate600,
),
),
],
),
),
],
),
),
Container(width: 1, height: 180, color: AppColors.border),
Expanded(
flex: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildPickerLabel(l10n.calendarDateTimePickerTimeLabel),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: _buildPicker(
_filteredHours,
_hourController,
(v) {
setState(() {
_selectedHour = v;
_filteredMinutes = _getFilteredMinutes();
if (_filteredMinutes.isEmpty) {
_selectedMinute = 0;
return;
}
if (_selectedMinute >
_filteredMinutes.last) {
_selectedMinute = _filteredMinutes.last;
_minuteController.jumpToItem(
_filteredMinutes.indexOf(
_selectedMinute,
),
);
}
});
},
(v) => v.toString().padLeft(2, '0'),
itemExtent: 50,
),
),
const Text(
' : ',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppColors.slate600,
),
),
Expanded(
child: _buildPicker(
_filteredMinutes,
_minuteController,
(v) => setState(() => _selectedMinute = v),
(v) => v.toString().padLeft(2, '0'),
itemExtent: 50,
),
),
],
),
),
],
),
),
],
),
),
],
),
);
}
Widget _buildHeader() {
final l10n = context.l10n;
return Container(
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: AppColors.border)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
GestureDetector(
onTap: () => Navigator.pop(context),
child: Text(
l10n.commonCancel,
style: const TextStyle(fontSize: 17, color: AppColors.slate600),
),
),
Text(
l10n.calendarDateTimePickerTitle,
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.slate900,
),
),
GestureDetector(
onTap: () {
Navigator.pop(context, (
DateTime(_selectedYear, _selectedMonth, _selectedDay),
DateTime(2000, 1, 1, _selectedHour, _selectedMinute),
));
},
child: Text(
l10n.commonConfirm,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.blue600,
),
),
),
],
),
);
}
Widget _buildPickerLabel(String label) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(
label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.slate700,
),
textAlign: TextAlign.center,
),
);
}
Widget _buildPicker(
List<int> items,
FixedExtentScrollController controller,
ValueChanged<int> onChanged,
String Function(int) formatter, {
double itemExtent = 40,
}) {
return CupertinoPicker(
scrollController: controller,
itemExtent: itemExtent,
magnification: 1.2,
squeeze: 0.8,
useMagnifier: true,
onSelectedItemChanged: (index) => onChanged(items[index]),
selectionOverlay: Container(
decoration: BoxDecoration(
border: Border.symmetric(
horizontal: BorderSide(
color: AppColors.blue100.withValues(alpha: 0.5),
width: 1,
),
),
),
),
children: List<Widget>.generate(items.length, (index) {
return Center(
child: Text(
formatter(items[index]),
style: const TextStyle(fontSize: 18, color: AppColors.slate900),
),
);
}),
);
}
}