feat(apps): 重构 UI 架构为 presentation 层并新增 l10n 国际化支持
This commit is contained in:
@@ -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),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user