feat(apps/calendar): 新增日历事件创建/编辑/分享功能

This commit is contained in:
zl-q
2026-03-19 00:51:51 +08:00
parent adccecd691
commit 14ccf2cb28
8 changed files with 959 additions and 654 deletions
@@ -1,4 +1,3 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../../core/di/injection.dart';
@@ -6,8 +5,10 @@ import '../../../../core/notifications/local_notification_service.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_loading_indicator.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';
@@ -15,12 +16,14 @@ 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(
@@ -135,6 +138,20 @@ class _CreateEventSheetState extends State<CreateEventSheet>
@override
Widget build(BuildContext context) {
if (widget.pageMode) {
return 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,
@@ -171,6 +188,44 @@ class _CreateEventSheetState extends State<CreateEventSheet>
);
}
Widget _buildPageHeader() {
return BackTitlePageHeader(
title: _isEditing ? '编辑日程' : '新建日程',
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(
'保存',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: enabled ? AppColors.blue600 : AppColors.slate400,
),
),
),
);
},
),
);
}
Widget _buildHeader() {
return Container(
height: 56,
@@ -473,7 +528,7 @@ class _CreateEventSheetState extends State<CreateEventSheet>
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) => _DateTimePickerSheet(
builder: (context) => DateTimePickerSheet(
initialDate: date,
initialTime: time,
minTime: minTime,
@@ -587,10 +642,7 @@ class _CreateEventSheetState extends State<CreateEventSheet>
}
int? _sanitizeReminderMinutes(int? minutes) {
if (minutes == null || minutes < 0) {
return null;
}
return minutes;
return (minutes != null && minutes >= 0) ? minutes : null;
}
List<int?> _buildReminderOptions() {
@@ -692,368 +744,3 @@ class _CreateEventSheetState extends State<CreateEventSheet>
}
}
}
class _DateTimePickerSheet extends StatefulWidget {
final DateTime initialDate;
final DateTime initialTime;
final DateTime? minTime;
const _DateTimePickerSheet({
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),
);
_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) {
return Container(
height: 420,
decoration: const BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
_buildHeader(),
Expanded(
child: Row(
children: [
Expanded(
flex: 3,
child: Column(
children: [
_buildPickerLabel('日期'),
Expanded(
child: Row(
children: [
Expanded(
child: _buildPicker(_years, _yearController, (v) {
setState(() {
_selectedYear = v;
_updateDays();
if (_selectedDay > _days.length) {
_selectedDay = _days.length;
_dayController.jumpToItem(_selectedDay - 1);
}
});
}, (v) => '$v'),
),
const Text(
'',
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'),
),
const Text(
'',
style: TextStyle(
fontSize: 14,
color: AppColors.slate600,
),
),
Expanded(
child: _buildPicker(
_days,
_dayController,
(v) => setState(() => _selectedDay = v),
(v) => '$v',
),
),
const Text(
'',
style: TextStyle(
fontSize: 14,
color: AppColors.slate600,
),
),
],
),
),
],
),
),
Container(width: 1, height: 180, color: AppColors.border),
Expanded(
flex: 2,
child: Column(
children: [
_buildPickerLabel('时间'),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: _buildPicker(
_filteredHours,
_hourController,
(v) {
setState(() {
_selectedHour = v;
_filteredMinutes = _getFilteredMinutes();
if (_selectedMinute >
_filteredMinutes.last) {
_selectedMinute =
_filteredMinutes.isNotEmpty
? _filteredMinutes.last
: 0;
_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() {
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,
children: [
GestureDetector(
onTap: () => Navigator.pop(context),
child: const Text(
'取消',
style: TextStyle(fontSize: 17, color: AppColors.slate600),
),
),
const Text(
'选择时间',
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: const Text(
'确定',
style: 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,
),
),
);
}
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),
),
);
}),
);
}
}
@@ -0,0 +1,377 @@
import 'package:flutter/cupertino.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),
);
_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) {
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('日期'),
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'),
),
const Text(
'',
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'),
),
const Text(
'',
style: TextStyle(
fontSize: 14,
color: AppColors.slate600,
),
),
Expanded(
child: _buildPicker(
_days,
_dayController,
(v) => setState(() => _selectedDay = v),
(v) => '$v',
),
),
const Text(
'',
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('时间'),
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() {
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: const Text(
'取消',
style: TextStyle(fontSize: 17, color: AppColors.slate600),
),
),
const Text(
'选择时间',
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: const Text(
'确定',
style: 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),
),
);
}),
);
}
}