feat: 优化前端 UI 组件与交互体验
- 优化日历、待办、消息等页面交互 - 更新 ChatBloc 与 UI Schema 渲染 - 优化联系人、首页、设置页面体验
This commit is contained in:
@@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../../../core/di/injection.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/app_pressable.dart';
|
||||
import '../calendar_state_manager.dart';
|
||||
import '../calendar_time_utils.dart';
|
||||
import '../widgets/bottom_dock.dart';
|
||||
@@ -42,7 +43,6 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
||||
late DateTime _selectedDate;
|
||||
late List<DateTime> _monthDates;
|
||||
final ScrollController _dayStripController = ScrollController();
|
||||
Key _eventsKey = UniqueKey();
|
||||
List<ScheduleItemModel> _events = const [];
|
||||
|
||||
@override
|
||||
@@ -135,10 +135,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
||||
right: AppSpacing.lg,
|
||||
top: 2,
|
||||
),
|
||||
child: KeyedSubtree(
|
||||
key: _eventsKey,
|
||||
child: _buildTimelineBoard(),
|
||||
),
|
||||
child: RepaintBoundary(child: _buildTimelineBoard()),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -223,13 +220,17 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
final monthLabel = '${_selectedDate.year}年${_selectedDate.month}月';
|
||||
|
||||
return SizedBox(
|
||||
height: 68,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 20, right: 20, top: 12, bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
GestureDetector(
|
||||
AppPressable(
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
onTap: () => context.go('/calendar/month'),
|
||||
child: Container(
|
||||
height: 36,
|
||||
@@ -241,6 +242,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
LucideIcons.chevronLeft,
|
||||
@@ -248,12 +250,30 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
||||
color: AppColors.slate700,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'${_selectedDate.year}年${_selectedDate.month}月',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.slate700,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -262,7 +282,8 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
||||
),
|
||||
const Spacer(),
|
||||
if (!isSameDay(_selectedDate, DateTime.now()))
|
||||
GestureDetector(
|
||||
AppPressable(
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
onTap: _goToToday,
|
||||
child: Container(
|
||||
height: 36,
|
||||
@@ -286,28 +307,24 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
||||
),
|
||||
if (!isSameDay(_selectedDate, DateTime.now()))
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
AppPressable(
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
onTap: () => CreateEventSheet.show(
|
||||
context,
|
||||
initialDate: _selectedDate,
|
||||
onSaved: () {
|
||||
setState(() {
|
||||
_eventsKey = UniqueKey();
|
||||
});
|
||||
_loadEvents();
|
||||
},
|
||||
onSaved: _loadEvents,
|
||||
),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blue600,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.plus,
|
||||
size: 20,
|
||||
color: Colors.white,
|
||||
color: AppColors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -318,35 +335,45 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
||||
}
|
||||
|
||||
Widget _buildWeekStrip() {
|
||||
final stripKey = ValueKey('${_selectedDate.year}-${_selectedDate.month}');
|
||||
|
||||
return SizedBox(
|
||||
height: 86,
|
||||
child: ListView.separated(
|
||||
controller: _dayStripController,
|
||||
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;
|
||||
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 GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedDate = date;
|
||||
});
|
||||
_calendarManager.setSelectedDate(date);
|
||||
_updateMonthDates();
|
||||
_scrollToSelectedDate(animate: true);
|
||||
_loadEvents();
|
||||
},
|
||||
child: SizedBox(
|
||||
width: _dayItemWidth,
|
||||
child: _buildDayItem(date, isSelected, isWeekend),
|
||||
),
|
||||
);
|
||||
},
|
||||
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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -389,6 +416,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
||||
final dayNames = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
@@ -399,14 +427,26 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${date.day}',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w600,
|
||||
color: isSelected
|
||||
? AppColors.blue600
|
||||
: (isWeekend ? AppColors.slate400 : AppColors.slate900),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -480,6 +520,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
||||
for (var i = 0; i < events.length; i++) {
|
||||
final event = events[i];
|
||||
final column = columns[i];
|
||||
final eventColor = _parseColor(event.metadata?.color);
|
||||
|
||||
final startMinutes = event.startAt.hour * 60 + event.startAt.minute;
|
||||
final endMinutes = event.endAt != null
|
||||
@@ -507,22 +548,15 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
final path = '/calendar/events/${event.id}';
|
||||
debugPrint('Navigating to: $path');
|
||||
context.push(path);
|
||||
context.push('/calendar/events/${event.id}');
|
||||
},
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(right: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: _parseColor(
|
||||
event.metadata?.color,
|
||||
).withValues(alpha: 0.2),
|
||||
color: eventColor.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: _parseColor(event.metadata?.color),
|
||||
width: 1,
|
||||
),
|
||||
border: Border.all(color: eventColor, width: 1),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -531,7 +565,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: _parseColor(event.metadata?.color),
|
||||
color: eventColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
@@ -542,7 +576,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: _parseColor(event.metadata?.color),
|
||||
color: eventColor,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/di/injection.dart';
|
||||
import '../../../../core/notifications/local_notification_service.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/app_loading_indicator.dart';
|
||||
import '../../../../shared/widgets/page_header.dart' as widgets;
|
||||
import '../../data/services/calendar_service.dart';
|
||||
import '../../data/models/schedule_item_model.dart';
|
||||
@@ -47,7 +48,7 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
if (_loading) {
|
||||
return const Scaffold(
|
||||
body: SafeArea(child: Center(child: CircularProgressIndicator())),
|
||||
body: SafeArea(child: Center(child: AppLoadingIndicator(size: 22))),
|
||||
);
|
||||
}
|
||||
if (_event == null) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../../../core/di/injection.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/app_pressable.dart';
|
||||
import '../calendar_state_manager.dart';
|
||||
import '../calendar_time_utils.dart';
|
||||
import '../widgets/bottom_dock.dart';
|
||||
@@ -25,7 +26,6 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
|
||||
late final CalendarStateManager _calendarManager;
|
||||
late DateTime _currentMonth;
|
||||
late DateTime _selectedDate;
|
||||
Key _eventsKey = UniqueKey();
|
||||
final Map<String, List<ScheduleItemModel>> _eventsByDay = {};
|
||||
|
||||
@override
|
||||
@@ -136,17 +136,26 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
|
||||
SizedBox(
|
||||
height: 56,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => _showMonthPicker(),
|
||||
AppPressable(
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
onTap: _showMonthPicker,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'${_currentMonth.month}月',
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.slate900,
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
switchInCurve: Curves.easeOut,
|
||||
switchOutCurve: Curves.easeOut,
|
||||
child: Text(
|
||||
'${_currentMonth.month}月',
|
||||
key: ValueKey(_currentMonth.month),
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.slate900,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
@@ -159,27 +168,23 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
AppPressable(
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
onTap: () => CreateEventSheet.show(
|
||||
context,
|
||||
onSaved: () {
|
||||
setState(() {
|
||||
_eventsKey = UniqueKey();
|
||||
});
|
||||
_loadMonthEvents();
|
||||
},
|
||||
onSaved: _loadMonthEvents,
|
||||
),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blue600,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.plus,
|
||||
size: 20,
|
||||
color: Colors.white,
|
||||
color: AppColors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -279,22 +284,24 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
|
||||
);
|
||||
final isSelected = isSameDay(_selectedDate, date);
|
||||
|
||||
return GestureDetector(
|
||||
return AppPressable(
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedDate = date;
|
||||
});
|
||||
_calendarManager.setSelectedDate(date);
|
||||
_calendarManager.setViewType(CalendarViewType.month);
|
||||
final ymd = formatYmd(date);
|
||||
context.push('/calendar/dayweek?date=$ymd');
|
||||
context.push('/calendar/dayweek?date=${formatYmd(date)}');
|
||||
},
|
||||
child: Container(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 140),
|
||||
curve: Curves.easeOut,
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? AppColors.blue100 : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
@@ -315,10 +322,7 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
KeyedSubtree(
|
||||
key: _eventsKey,
|
||||
child: _buildWeekEvents(weekStart, startWeekday, daysInMonth),
|
||||
),
|
||||
_buildWeekEvents(weekStart, startWeekday, daysInMonth),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -357,7 +361,8 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
|
||||
children: [
|
||||
...displayEvents.map((event) {
|
||||
final color = _parseColor(event.metadata?.color);
|
||||
return GestureDetector(
|
||||
return AppPressable(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
onTap: () {
|
||||
_calendarManager.setSelectedDate(date);
|
||||
context.push('/calendar/events/${event.id}');
|
||||
@@ -386,7 +391,8 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
|
||||
);
|
||||
}),
|
||||
if (remainingCount > 0)
|
||||
GestureDetector(
|
||||
AppPressable(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
onTap: () {
|
||||
_calendarManager.setSelectedDate(date);
|
||||
_calendarManager.setViewType(CalendarViewType.day);
|
||||
@@ -419,77 +425,95 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
|
||||
}
|
||||
|
||||
void _showMonthPicker() {
|
||||
var selectedYear = _currentMonth.year;
|
||||
var selectedMonth = _currentMonth.month;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => Container(
|
||||
height: 300,
|
||||
color: Colors.white,
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: Row(
|
||||
backgroundColor: AppColors.white,
|
||||
builder: (context) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setSheetState) {
|
||||
return SizedBox(
|
||||
height: 300,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: CupertinoPicker(
|
||||
itemExtent: 40,
|
||||
scrollController: FixedExtentScrollController(
|
||||
initialItem: _currentMonth.year - 2020,
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
onSelectedItemChanged: (index) {
|
||||
setState(() {
|
||||
_currentMonth = DateTime(
|
||||
2020 + index,
|
||||
_currentMonth.month,
|
||||
1,
|
||||
);
|
||||
});
|
||||
_loadMonthEvents();
|
||||
},
|
||||
children: List.generate(20, (index) {
|
||||
return Center(child: Text('${2020 + index}年'));
|
||||
}),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
setState(() {
|
||||
_currentMonth = DateTime(
|
||||
selectedYear,
|
||||
selectedMonth,
|
||||
1,
|
||||
);
|
||||
_selectedDate = DateTime(
|
||||
selectedYear,
|
||||
selectedMonth,
|
||||
1,
|
||||
);
|
||||
});
|
||||
_calendarManager.setSelectedDate(_selectedDate);
|
||||
_loadMonthEvents();
|
||||
},
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: CupertinoPicker(
|
||||
itemExtent: 40,
|
||||
scrollController: FixedExtentScrollController(
|
||||
initialItem: _currentMonth.month - 1,
|
||||
),
|
||||
onSelectedItemChanged: (index) {
|
||||
setState(() {
|
||||
_currentMonth = DateTime(
|
||||
_currentMonth.year,
|
||||
index + 1,
|
||||
1,
|
||||
);
|
||||
});
|
||||
_loadMonthEvents();
|
||||
},
|
||||
children: List.generate(12, (index) {
|
||||
return Center(child: Text('${index + 1}月'));
|
||||
}),
|
||||
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('${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('${index + 1}月'));
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ class BottomDock extends StatelessWidget {
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [_buildToggle(), _buildHomeBtn()],
|
||||
),
|
||||
);
|
||||
@@ -42,9 +43,17 @@ class BottomDock extends StatelessWidget {
|
||||
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,
|
||||
@@ -67,44 +76,61 @@ class BottomDock extends StatelessWidget {
|
||||
required bool isActive,
|
||||
VoidCallback? onTap,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
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,
|
||||
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,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: isActive ? AppColors.blue600 : AppColors.slate700,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHomeBtn() {
|
||||
return GestureDetector(
|
||||
onTap: onHomeTap,
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.todoToggleBg,
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
border: Border.all(color: AppColors.todoToggleBorder),
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.home,
|
||||
size: 20,
|
||||
color: AppColors.slate700,
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -4,6 +4,10 @@ import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../../../core/di/injection.dart';
|
||||
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/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
import '../../data/models/schedule_item_model.dart';
|
||||
import '../../data/services/calendar_service.dart';
|
||||
|
||||
@@ -66,7 +70,6 @@ class _CreateEventSheetState extends State<CreateEventSheet>
|
||||
String _selectedColor = '#3B82F6';
|
||||
int? _reminderMinutes = 15;
|
||||
bool _saving = false;
|
||||
List<Attachment> _attachments = const [];
|
||||
|
||||
bool get _isEditing => widget.editingEvent != null;
|
||||
|
||||
@@ -87,9 +90,6 @@ class _CreateEventSheetState extends State<CreateEventSheet>
|
||||
_endTime = event.endAt;
|
||||
_selectedColor = event.metadata?.color ?? '#3B82F6';
|
||||
_reminderMinutes = event.metadata?.reminderMinutes ?? 15;
|
||||
_attachments = List<Attachment>.from(
|
||||
event.metadata?.attachments ?? const [],
|
||||
);
|
||||
} else {
|
||||
final now =
|
||||
widget.initialDate ?? _roundToNearestMinute(DateTime.now(), 5);
|
||||
@@ -122,18 +122,38 @@ class _CreateEventSheetState extends State<CreateEventSheet>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: MediaQuery.of(context).size.height * 0.85,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
return AnimatedPadding(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeOut,
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildHeader(),
|
||||
_buildTabBar(),
|
||||
Expanded(child: _buildTabContent()),
|
||||
],
|
||||
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()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -174,7 +194,7 @@ class _CreateEventSheetState extends State<CreateEventSheet>
|
||||
ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: _titleController,
|
||||
builder: (context, value, child) {
|
||||
final enabled = value.text.trim().isNotEmpty;
|
||||
final enabled = value.text.trim().isNotEmpty && !_saving;
|
||||
return SizedBox(
|
||||
height: AppSpacing.xxl * 2,
|
||||
child: TextButton(
|
||||
@@ -186,14 +206,22 @@ class _CreateEventSheetState extends State<CreateEventSheet>
|
||||
minimumSize: const Size(AppSpacing.none, AppSpacing.none),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: Text(
|
||||
'保存',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: enabled ? AppColors.blue600 : AppColors.slate400,
|
||||
),
|
||||
),
|
||||
child: _saving
|
||||
? const AppLoadingIndicator(
|
||||
variant: AppLoadingVariant.button,
|
||||
size: 18,
|
||||
trackColor: AppColors.blue200,
|
||||
)
|
||||
: Text(
|
||||
'保存',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: enabled
|
||||
? AppColors.blue600
|
||||
: AppColors.slate400,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -230,11 +258,17 @@ class _CreateEventSheetState extends State<CreateEventSheet>
|
||||
|
||||
Widget _buildBasicTab() {
|
||||
return SingleChildScrollView(
|
||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildTextField('标题', _titleController, '请输入日程标题'),
|
||||
_buildTextField(
|
||||
'标题',
|
||||
_titleController,
|
||||
'请输入日程标题',
|
||||
autofocus: !_isEditing,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildDateTimePicker('开始', _startDate, _startTime, (date, time) {
|
||||
setState(() {
|
||||
@@ -310,6 +344,7 @@ class _CreateEventSheetState extends State<CreateEventSheet>
|
||||
|
||||
Widget _buildAdvancedTab() {
|
||||
return SingleChildScrollView(
|
||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -322,326 +357,25 @@ class _CreateEventSheetState extends State<CreateEventSheet>
|
||||
const SizedBox(height: 20),
|
||||
_buildColorPicker(),
|
||||
const SizedBox(height: 20),
|
||||
_buildAttachmentsSection(),
|
||||
const SizedBox(height: 20),
|
||||
_buildTextField('备注', _notesController, '请输入备注', maxLines: 3),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAttachmentsSection() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'附件',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.slate700,
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: _showAddAttachmentDialog,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.xs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blue50,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
border: Border.all(color: AppColors.borderQuaternary),
|
||||
),
|
||||
child: const Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(LucideIcons.plus, size: 14, color: AppColors.blue600),
|
||||
SizedBox(width: AppSpacing.xs),
|
||||
Text(
|
||||
'添加附件',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.blue600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
if (_attachments.isEmpty)
|
||||
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: const Text(
|
||||
'暂无附件,点击右上角添加',
|
||||
style: TextStyle(color: AppColors.slate500, fontSize: 13),
|
||||
),
|
||||
),
|
||||
..._attachments.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final item = entry.value;
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(top: AppSpacing.sm),
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
border: Border.all(color: AppColors.messageCardBorder),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.slate800,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.sm,
|
||||
vertical: AppSpacing.xs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceInfo,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
child: Text(
|
||||
item.type,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.blue600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
final next = List<Attachment>.from(_attachments);
|
||||
next.removeAt(index);
|
||||
_attachments = next;
|
||||
});
|
||||
},
|
||||
child: const Icon(
|
||||
LucideIcons.trash,
|
||||
size: 16,
|
||||
color: AppColors.red500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if ((item.url ?? '').isNotEmpty) ...[
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
'链接: ${item.url}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.slate500,
|
||||
),
|
||||
),
|
||||
],
|
||||
if ((item.note ?? '').isNotEmpty) ...[
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
'备注: ${item.note}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.slate500,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showAddAttachmentDialog() async {
|
||||
final nameController = TextEditingController();
|
||||
final urlController = TextEditingController();
|
||||
final noteController = TextEditingController();
|
||||
final contentController = TextEditingController();
|
||||
var type = 'document';
|
||||
try {
|
||||
final created = await showModalBottomSheet<Attachment>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (sheetContext) => StatefulBuilder(
|
||||
builder: (sheetContext, setSheetState) {
|
||||
return Container(
|
||||
padding: EdgeInsets.only(
|
||||
left: AppSpacing.lg,
|
||||
right: AppSpacing.lg,
|
||||
top: AppSpacing.lg,
|
||||
bottom:
|
||||
MediaQuery.of(sheetContext).viewInsets.bottom +
|
||||
AppSpacing.lg,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(AppRadius.xxl),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'添加附件',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.slate900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_buildTextField('名称', nameController, '例如:会议纪要.pdf'),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_buildTextField('链接', urlController, 'https://...'),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_buildTextField('备注', noteController, '备注信息'),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_buildTextField('内容', contentController, '提醒内容', maxLines: 2),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Wrap(
|
||||
spacing: AppSpacing.sm,
|
||||
children: ['document', 'reminder'].map((item) {
|
||||
final selected = item == type;
|
||||
return ChoiceChip(
|
||||
label: Text(item),
|
||||
selected: selected,
|
||||
onSelected: (_) {
|
||||
setSheetState(() {
|
||||
type = item;
|
||||
});
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.pop(sheetContext),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
final name = nameController.text.trim();
|
||||
if (name.isEmpty) {
|
||||
return;
|
||||
}
|
||||
Navigator.pop(
|
||||
sheetContext,
|
||||
Attachment(
|
||||
name: name,
|
||||
url: urlController.text.trim().isEmpty
|
||||
? null
|
||||
: urlController.text.trim(),
|
||||
note: noteController.text.trim().isEmpty
|
||||
? null
|
||||
: noteController.text.trim(),
|
||||
content: contentController.text.trim().isEmpty
|
||||
? null
|
||||
: contentController.text.trim(),
|
||||
type: type,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('确认添加'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
if (created != null && mounted) {
|
||||
setState(() {
|
||||
_attachments = [..._attachments, created];
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
nameController.dispose();
|
||||
urlController.dispose();
|
||||
noteController.dispose();
|
||||
contentController.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildTextField(
|
||||
String label,
|
||||
TextEditingController controller,
|
||||
String hint, {
|
||||
int maxLines = 1,
|
||||
bool autofocus = false,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.slate700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: controller,
|
||||
maxLines: maxLines,
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: const TextStyle(color: AppColors.slate400),
|
||||
filled: true,
|
||||
fillColor: const Color(0xFFF1F5F9),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
return AppSheetInputField(
|
||||
controller: controller,
|
||||
label: label,
|
||||
hint: hint,
|
||||
maxLines: maxLines,
|
||||
autofocus: autofocus,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -873,7 +607,7 @@ class _CreateEventSheetState extends State<CreateEventSheet>
|
||||
? _notesController.text.trim()
|
||||
: null,
|
||||
reminderMinutes: _reminderMinutes,
|
||||
attachments: _attachments,
|
||||
attachments: const [],
|
||||
version: widget.editingEvent?.metadata?.version ?? 1,
|
||||
);
|
||||
|
||||
@@ -912,9 +646,7 @@ class _CreateEventSheetState extends State<CreateEventSheet>
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('保存失败: $e')));
|
||||
Toast.show(context, '保存失败: $e', type: ToastType.error);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
|
||||
@@ -1,26 +1,15 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'tool_result.dart';
|
||||
|
||||
part 'ag_ui_event.g.dart';
|
||||
|
||||
class AgUiEventTypeWire {
|
||||
static const runStarted = 'RUN_STARTED';
|
||||
static const runFinished = 'RUN_FINISHED';
|
||||
static const runError = 'RUN_ERROR';
|
||||
static const stepStarted = 'STEP_STARTED';
|
||||
static const stepFinished = 'STEP_FINISHED';
|
||||
static const textMessageStart = 'TEXT_MESSAGE_START';
|
||||
static const textMessageContent = 'TEXT_MESSAGE_CONTENT';
|
||||
static const textMessageEnd = 'TEXT_MESSAGE_END';
|
||||
static const toolCallStart = 'TOOL_CALL_START';
|
||||
static const toolCallArgs = 'TOOL_CALL_ARGS';
|
||||
static const toolCallEnd = 'TOOL_CALL_END';
|
||||
static const toolCallResult = 'TOOL_CALL_RESULT';
|
||||
static const toolCallError = 'TOOL_CALL_ERROR';
|
||||
static const stateSnapshot = 'STATE_SNAPSHOT';
|
||||
static const messagesSnapshot = 'MESSAGES_SNAPSHOT';
|
||||
}
|
||||
|
||||
enum AgUiEventType {
|
||||
@@ -29,55 +18,41 @@ enum AgUiEventType {
|
||||
runError,
|
||||
stepStarted,
|
||||
stepFinished,
|
||||
textMessageStart,
|
||||
textMessageContent,
|
||||
textMessageEnd,
|
||||
toolCallStart,
|
||||
toolCallArgs,
|
||||
toolCallEnd,
|
||||
toolCallResult,
|
||||
toolCallError,
|
||||
stateSnapshot,
|
||||
messagesSnapshot,
|
||||
unknown,
|
||||
}
|
||||
|
||||
// wire 类型到枚举的映射
|
||||
const _wireToTypeMap = {
|
||||
AgUiEventTypeWire.runStarted: AgUiEventType.runStarted,
|
||||
AgUiEventTypeWire.runFinished: AgUiEventType.runFinished,
|
||||
AgUiEventTypeWire.runError: AgUiEventType.runError,
|
||||
AgUiEventTypeWire.stepStarted: AgUiEventType.stepStarted,
|
||||
AgUiEventTypeWire.stepFinished: AgUiEventType.stepFinished,
|
||||
AgUiEventTypeWire.textMessageStart: AgUiEventType.textMessageStart,
|
||||
AgUiEventTypeWire.textMessageContent: AgUiEventType.textMessageContent,
|
||||
AgUiEventTypeWire.textMessageEnd: AgUiEventType.textMessageEnd,
|
||||
AgUiEventTypeWire.toolCallStart: AgUiEventType.toolCallStart,
|
||||
AgUiEventTypeWire.toolCallArgs: AgUiEventType.toolCallArgs,
|
||||
AgUiEventTypeWire.toolCallEnd: AgUiEventType.toolCallEnd,
|
||||
AgUiEventTypeWire.toolCallResult: AgUiEventType.toolCallResult,
|
||||
AgUiEventTypeWire.toolCallError: AgUiEventType.toolCallError,
|
||||
AgUiEventTypeWire.stateSnapshot: AgUiEventType.stateSnapshot,
|
||||
AgUiEventTypeWire.messagesSnapshot: AgUiEventType.messagesSnapshot,
|
||||
};
|
||||
|
||||
// 枚举到 wire 类型的映射
|
||||
const _typeToWireMap = {
|
||||
AgUiEventType.runStarted: AgUiEventTypeWire.runStarted,
|
||||
AgUiEventType.runFinished: AgUiEventTypeWire.runFinished,
|
||||
AgUiEventType.runError: AgUiEventTypeWire.runError,
|
||||
AgUiEventType.stepStarted: AgUiEventTypeWire.stepStarted,
|
||||
AgUiEventType.stepFinished: AgUiEventTypeWire.stepFinished,
|
||||
AgUiEventType.textMessageStart: AgUiEventTypeWire.textMessageStart,
|
||||
AgUiEventType.textMessageContent: AgUiEventTypeWire.textMessageContent,
|
||||
AgUiEventType.textMessageEnd: AgUiEventTypeWire.textMessageEnd,
|
||||
AgUiEventType.toolCallStart: AgUiEventTypeWire.toolCallStart,
|
||||
AgUiEventType.toolCallArgs: AgUiEventTypeWire.toolCallArgs,
|
||||
AgUiEventType.toolCallEnd: AgUiEventTypeWire.toolCallEnd,
|
||||
AgUiEventType.toolCallResult: AgUiEventTypeWire.toolCallResult,
|
||||
AgUiEventType.toolCallError: AgUiEventTypeWire.toolCallError,
|
||||
AgUiEventType.stateSnapshot: AgUiEventTypeWire.stateSnapshot,
|
||||
AgUiEventType.messagesSnapshot: AgUiEventTypeWire.messagesSnapshot,
|
||||
AgUiEventType.unknown: '',
|
||||
};
|
||||
|
||||
@@ -86,383 +61,310 @@ AgUiEventType agUiEventTypeFromWire(String wire) =>
|
||||
|
||||
String agUiEventTypeToWire(AgUiEventType type) => _typeToWireMap[type] ?? '';
|
||||
|
||||
// 类型到工厂函数的映射,用于简化 fromJson
|
||||
final _typeToFactory = {
|
||||
AgUiEventType.runStarted: RunStartedEvent.fromJson,
|
||||
AgUiEventType.runFinished: RunFinishedEvent.fromJson,
|
||||
AgUiEventType.runError: RunErrorEvent.fromJson,
|
||||
AgUiEventType.stepStarted: StepStartedEvent.fromJson,
|
||||
AgUiEventType.stepFinished: StepFinishedEvent.fromJson,
|
||||
AgUiEventType.textMessageStart: TextMessageStartEvent.fromJson,
|
||||
AgUiEventType.textMessageContent: TextMessageContentEvent.fromJson,
|
||||
AgUiEventType.textMessageEnd: TextMessageEndEvent.fromJson,
|
||||
AgUiEventType.toolCallStart: ToolCallStartEvent.fromJson,
|
||||
AgUiEventType.toolCallArgs: ToolCallArgsEvent.fromJson,
|
||||
AgUiEventType.toolCallEnd: ToolCallEndEvent.fromJson,
|
||||
AgUiEventType.toolCallResult: ToolCallResultEvent.fromJson,
|
||||
AgUiEventType.toolCallError: ToolCallErrorEvent.fromJson,
|
||||
AgUiEventType.stateSnapshot: StateSnapshotEvent.fromJson,
|
||||
AgUiEventType.messagesSnapshot: MessagesSnapshotEvent.fromJson,
|
||||
AgUiEventType.unknown: UnknownAgUiEvent.fromJson,
|
||||
};
|
||||
abstract class AgUiEvent {
|
||||
const AgUiEvent({required this.type});
|
||||
|
||||
@JsonSerializable(createFactory: false)
|
||||
class AgUiEvent {
|
||||
final AgUiEventType type;
|
||||
|
||||
AgUiEvent({required this.type});
|
||||
|
||||
factory AgUiEvent.fromJson(Map<String, dynamic> json) {
|
||||
final typeStr = json['type'] as String? ?? '';
|
||||
final type = agUiEventTypeFromWire(typeStr);
|
||||
return _typeToFactory[type]?.call(json) ?? UnknownAgUiEvent.fromJson(json);
|
||||
final wireType = json['type'];
|
||||
final type = wireType is String
|
||||
? agUiEventTypeFromWire(wireType)
|
||||
: AgUiEventType.unknown;
|
||||
return switch (type) {
|
||||
AgUiEventType.runStarted => RunStartedEvent.fromJson(json),
|
||||
AgUiEventType.runFinished => RunFinishedEvent.fromJson(json),
|
||||
AgUiEventType.runError => RunErrorEvent.fromJson(json),
|
||||
AgUiEventType.stepStarted => StepStartedEvent.fromJson(json),
|
||||
AgUiEventType.stepFinished => StepFinishedEvent.fromJson(json),
|
||||
AgUiEventType.textMessageEnd => TextMessageEndEvent.fromJson(json),
|
||||
AgUiEventType.toolCallStart => ToolCallStartEvent.fromJson(json),
|
||||
AgUiEventType.toolCallArgs => ToolCallArgsEvent.fromJson(json),
|
||||
AgUiEventType.toolCallEnd => ToolCallEndEvent.fromJson(json),
|
||||
AgUiEventType.toolCallResult => ToolCallResultEvent.fromJson(json),
|
||||
AgUiEventType.toolCallError => ToolCallErrorEvent.fromJson(json),
|
||||
AgUiEventType.unknown => UnknownAgUiEvent(rawJson: json),
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => _$AgUiEventToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable(createFactory: false, createToJson: false)
|
||||
class UnknownAgUiEvent extends AgUiEvent {
|
||||
final Map<String, dynamic> rawJson;
|
||||
|
||||
UnknownAgUiEvent({required this.rawJson})
|
||||
const UnknownAgUiEvent({required this.rawJson})
|
||||
: super(type: AgUiEventType.unknown);
|
||||
|
||||
factory UnknownAgUiEvent.fromJson(Map<String, dynamic> json) =>
|
||||
UnknownAgUiEvent(rawJson: json);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => rawJson;
|
||||
final Map<String, dynamic> rawJson;
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class RunStartedEvent extends AgUiEvent {
|
||||
final String threadId;
|
||||
final String runId;
|
||||
|
||||
RunStartedEvent({required this.threadId, required this.runId})
|
||||
: super(type: AgUiEventType.runStarted);
|
||||
|
||||
factory RunStartedEvent.fromJson(Map<String, dynamic> json) =>
|
||||
_$RunStartedEventFromJson(json);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$RunStartedEventToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class RunFinishedEvent extends AgUiEvent {
|
||||
final String threadId;
|
||||
final String runId;
|
||||
|
||||
factory RunStartedEvent.fromJson(Map<String, dynamic> json) =>
|
||||
RunStartedEvent(
|
||||
threadId: _asString(json['threadId']),
|
||||
runId: _asString(json['runId']),
|
||||
);
|
||||
}
|
||||
|
||||
class RunFinishedEvent extends AgUiEvent {
|
||||
RunFinishedEvent({required this.threadId, required this.runId})
|
||||
: super(type: AgUiEventType.runFinished);
|
||||
|
||||
factory RunFinishedEvent.fromJson(Map<String, dynamic> json) =>
|
||||
_$RunFinishedEventFromJson(json);
|
||||
final String threadId;
|
||||
final String runId;
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$RunFinishedEventToJson(this);
|
||||
factory RunFinishedEvent.fromJson(Map<String, dynamic> json) =>
|
||||
RunFinishedEvent(
|
||||
threadId: _asString(json['threadId']),
|
||||
runId: _asString(json['runId']),
|
||||
);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class RunErrorEvent extends AgUiEvent {
|
||||
final String message;
|
||||
final String? code;
|
||||
|
||||
RunErrorEvent({required this.message, this.code})
|
||||
: super(type: AgUiEventType.runError);
|
||||
|
||||
factory RunErrorEvent.fromJson(Map<String, dynamic> json) =>
|
||||
_$RunErrorEventFromJson(json);
|
||||
final String message;
|
||||
final String? code;
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$RunErrorEventToJson(this);
|
||||
factory RunErrorEvent.fromJson(Map<String, dynamic> json) => RunErrorEvent(
|
||||
message: _asString(json['message'], fallback: 'Unknown error'),
|
||||
code: json['code'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class StepStartedEvent extends AgUiEvent {
|
||||
final String stepName;
|
||||
|
||||
StepStartedEvent({required this.stepName})
|
||||
: super(type: AgUiEventType.stepStarted);
|
||||
|
||||
factory StepStartedEvent.fromJson(Map<String, dynamic> json) =>
|
||||
_$StepStartedEventFromJson(json);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$StepStartedEventToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class StepFinishedEvent extends AgUiEvent {
|
||||
final String stepName;
|
||||
|
||||
factory StepStartedEvent.fromJson(Map<String, dynamic> json) =>
|
||||
StepStartedEvent(stepName: _asString(json['stepName']));
|
||||
}
|
||||
|
||||
class StepFinishedEvent extends AgUiEvent {
|
||||
StepFinishedEvent({required this.stepName})
|
||||
: super(type: AgUiEventType.stepFinished);
|
||||
|
||||
final String stepName;
|
||||
|
||||
factory StepFinishedEvent.fromJson(Map<String, dynamic> json) =>
|
||||
_$StepFinishedEventFromJson(json);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$StepFinishedEventToJson(this);
|
||||
StepFinishedEvent(stepName: _asString(json['stepName']));
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class TextMessageStartEvent extends AgUiEvent {
|
||||
final String messageId;
|
||||
final String role;
|
||||
|
||||
TextMessageStartEvent({required this.messageId, required this.role})
|
||||
: super(type: AgUiEventType.textMessageStart);
|
||||
|
||||
factory TextMessageStartEvent.fromJson(Map<String, dynamic> json) =>
|
||||
_$TextMessageStartEventFromJson(json);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$TextMessageStartEventToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class TextMessageContentEvent extends AgUiEvent {
|
||||
final String messageId;
|
||||
final String delta;
|
||||
|
||||
TextMessageContentEvent({required this.messageId, required this.delta})
|
||||
: super(type: AgUiEventType.textMessageContent);
|
||||
|
||||
factory TextMessageContentEvent.fromJson(Map<String, dynamic> json) =>
|
||||
_$TextMessageContentEventFromJson(json);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$TextMessageContentEventToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class TextMessageEndEvent extends AgUiEvent {
|
||||
final String messageId;
|
||||
TextMessageEndEvent({
|
||||
required this.messageId,
|
||||
required this.answer,
|
||||
required this.role,
|
||||
required this.status,
|
||||
required this.uiSchema,
|
||||
}) : super(type: AgUiEventType.textMessageEnd);
|
||||
|
||||
TextMessageEndEvent({required this.messageId})
|
||||
: super(type: AgUiEventType.textMessageEnd);
|
||||
final String messageId;
|
||||
final String answer;
|
||||
final String role;
|
||||
final String status;
|
||||
final Map<String, dynamic>? uiSchema;
|
||||
|
||||
factory TextMessageEndEvent.fromJson(Map<String, dynamic> json) =>
|
||||
_$TextMessageEndEventFromJson(json);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$TextMessageEndEventToJson(this);
|
||||
TextMessageEndEvent(
|
||||
messageId: _asString(json['messageId']),
|
||||
answer: _asString(json['answer']),
|
||||
role: _asString(json['role'], fallback: 'assistant'),
|
||||
status: _asString(json['status'], fallback: 'success'),
|
||||
uiSchema: _asMap(json['ui_schema']) ?? _asMap(json['uiSchema']),
|
||||
);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class ToolCallStartEvent extends AgUiEvent {
|
||||
ToolCallStartEvent({required this.toolCallId, required this.toolCallName})
|
||||
: super(type: AgUiEventType.toolCallStart);
|
||||
|
||||
final String toolCallId;
|
||||
final String toolCallName;
|
||||
final String? parentMessageId;
|
||||
|
||||
ToolCallStartEvent({
|
||||
required this.toolCallId,
|
||||
required this.toolCallName,
|
||||
this.parentMessageId,
|
||||
}) : super(type: AgUiEventType.toolCallStart);
|
||||
|
||||
factory ToolCallStartEvent.fromJson(Map<String, dynamic> json) =>
|
||||
_$ToolCallStartEventFromJson(json);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$ToolCallStartEventToJson(this);
|
||||
ToolCallStartEvent(
|
||||
toolCallId: _asString(json['toolCallId']),
|
||||
toolCallName: _asString(json['toolCallName']),
|
||||
);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class ToolCallArgsEvent extends AgUiEvent {
|
||||
final String toolCallId;
|
||||
final String delta;
|
||||
|
||||
ToolCallArgsEvent({required this.toolCallId, required this.delta})
|
||||
ToolCallArgsEvent({required this.toolCallId, required this.args})
|
||||
: super(type: AgUiEventType.toolCallArgs);
|
||||
|
||||
factory ToolCallArgsEvent.fromJson(Map<String, dynamic> json) =>
|
||||
_$ToolCallArgsEventFromJson(json);
|
||||
final String toolCallId;
|
||||
final Map<String, dynamic> args;
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$ToolCallArgsEventToJson(this);
|
||||
factory ToolCallArgsEvent.fromJson(Map<String, dynamic> json) =>
|
||||
ToolCallArgsEvent(
|
||||
toolCallId: _asString(json['toolCallId']),
|
||||
args: _asMap(json['args']) ?? const {},
|
||||
);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class ToolCallEndEvent extends AgUiEvent {
|
||||
final String toolCallId;
|
||||
|
||||
ToolCallEndEvent({required this.toolCallId})
|
||||
: super(type: AgUiEventType.toolCallEnd);
|
||||
|
||||
factory ToolCallEndEvent.fromJson(Map<String, dynamic> json) =>
|
||||
_$ToolCallEndEventFromJson(json);
|
||||
final String toolCallId;
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$ToolCallEndEventToJson(this);
|
||||
factory ToolCallEndEvent.fromJson(Map<String, dynamic> json) =>
|
||||
ToolCallEndEvent(toolCallId: _asString(json['toolCallId']));
|
||||
}
|
||||
|
||||
@JsonSerializable(createFactory: false, createToJson: false)
|
||||
class ToolCallResultEvent extends AgUiEvent {
|
||||
final String messageId;
|
||||
final String toolCallId;
|
||||
final String content;
|
||||
|
||||
ToolCallResultEvent({
|
||||
required this.messageId,
|
||||
required this.toolCallId,
|
||||
required this.content,
|
||||
required this.toolName,
|
||||
required this.resultSummary,
|
||||
required this.status,
|
||||
required this.uiSchema,
|
||||
}) : super(type: AgUiEventType.toolCallResult);
|
||||
|
||||
Map<String, dynamic> get payload {
|
||||
try {
|
||||
final decoded = jsonDecode(content);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
return decoded;
|
||||
}
|
||||
} catch (_) {}
|
||||
return {'content': content};
|
||||
}
|
||||
final String messageId;
|
||||
final String toolCallId;
|
||||
final String toolName;
|
||||
final String resultSummary;
|
||||
final String status;
|
||||
final Map<String, dynamic>? uiSchema;
|
||||
|
||||
Map<String, dynamic> get result {
|
||||
final rawResult = payload['result'];
|
||||
if (rawResult is Map<String, dynamic>) {
|
||||
return rawResult;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
UiCard? get ui {
|
||||
final rawUi = payload['ui'];
|
||||
if (rawUi is Map<String, dynamic>) {
|
||||
return UiCard.fromJson(rawUi);
|
||||
}
|
||||
final rawResult = payload['result'];
|
||||
if (rawResult is Map<String, dynamic>) {
|
||||
final type = rawResult['type'];
|
||||
final data = rawResult['data'];
|
||||
if (type is String && data is Map<String, dynamic>) {
|
||||
return UiCard.fromJson(rawResult);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
factory ToolCallResultEvent.fromJson(Map<String, dynamic> json) {
|
||||
final rawContent = json['content'];
|
||||
final hasStructuredFields =
|
||||
json['ui'] != null || json['result'] != null || json['error'] != null;
|
||||
final content = switch (rawContent) {
|
||||
String value when value.trim().startsWith('{') => value,
|
||||
String value when value.trim().startsWith('[') => value,
|
||||
String value when hasStructuredFields => jsonEncode({
|
||||
'toolName': json['toolName'],
|
||||
'result': json['result'],
|
||||
'error': json['error'],
|
||||
'ui': json['ui'],
|
||||
'content': value,
|
||||
}),
|
||||
String value => value,
|
||||
_ => jsonEncode({
|
||||
'toolName': json['toolName'],
|
||||
'result': json['result'],
|
||||
'error': json['error'],
|
||||
'ui': json['ui'],
|
||||
'content': json['content'],
|
||||
}),
|
||||
};
|
||||
final toolCallId =
|
||||
json['toolCallId'] as String? ?? json['callId'] as String? ?? '';
|
||||
final messageId = json['messageId'] as String? ?? 'tool-result-$toolCallId';
|
||||
return ToolCallResultEvent(
|
||||
messageId: messageId,
|
||||
toolCallId: toolCallId,
|
||||
content: content,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {
|
||||
'type': agUiEventTypeToWire(type),
|
||||
'messageId': messageId,
|
||||
'toolCallId': toolCallId,
|
||||
'content': content,
|
||||
};
|
||||
factory ToolCallResultEvent.fromJson(Map<String, dynamic> json) =>
|
||||
ToolCallResultEvent(
|
||||
messageId: _asString(
|
||||
json['messageId'],
|
||||
fallback: 'tool-${_asString(json['tool_call_id'])}',
|
||||
),
|
||||
toolCallId: _asString(json['tool_call_id'] ?? json['toolCallId']),
|
||||
toolName: _asString(json['tool_name'] ?? json['toolName']),
|
||||
resultSummary: _asString(
|
||||
json['result_summary'] ?? json['resultSummary'],
|
||||
),
|
||||
status: _asString(json['status'], fallback: 'success'),
|
||||
uiSchema: _asMap(json['ui_schema']) ?? _asMap(json['uiSchema']),
|
||||
);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class ToolCallErrorEvent extends AgUiEvent {
|
||||
ToolCallErrorEvent({required this.toolCallId, required this.error, this.code})
|
||||
: super(type: AgUiEventType.toolCallError);
|
||||
|
||||
final String toolCallId;
|
||||
final String error;
|
||||
final String? code;
|
||||
|
||||
ToolCallErrorEvent({required this.toolCallId, required this.error, this.code})
|
||||
: super(type: AgUiEventType.toolCallError);
|
||||
|
||||
factory ToolCallErrorEvent.fromJson(Map<String, dynamic> json) =>
|
||||
_$ToolCallErrorEventFromJson(json);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$ToolCallErrorEventToJson(this);
|
||||
ToolCallErrorEvent(
|
||||
toolCallId: _asString(json['toolCallId']),
|
||||
error: _asString(json['error'], fallback: 'Tool call failed'),
|
||||
code: json['code'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@JsonSerializable(createFactory: false, createToJson: false)
|
||||
class StateSnapshotEvent extends AgUiEvent {
|
||||
final Map<String, dynamic> snapshot;
|
||||
|
||||
StateSnapshotEvent({required this.snapshot})
|
||||
: super(type: AgUiEventType.stateSnapshot);
|
||||
|
||||
factory StateSnapshotEvent.fromJson(Map<String, dynamic> json) {
|
||||
final rawSnapshot = json['snapshot'];
|
||||
return StateSnapshotEvent(
|
||||
snapshot: rawSnapshot is Map<String, dynamic>
|
||||
? rawSnapshot
|
||||
: <String, dynamic>{},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {
|
||||
'type': agUiEventTypeToWire(type),
|
||||
'snapshot': snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class MessagesSnapshotEvent extends AgUiEvent {
|
||||
final List<SnapshotMessage> messages;
|
||||
|
||||
MessagesSnapshotEvent({required this.messages})
|
||||
: super(type: AgUiEventType.messagesSnapshot);
|
||||
|
||||
factory MessagesSnapshotEvent.fromJson(Map<String, dynamic> json) =>
|
||||
_$MessagesSnapshotEventFromJson(json);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$MessagesSnapshotEventToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class SnapshotMessage {
|
||||
final String id;
|
||||
final String role;
|
||||
final String? content;
|
||||
final String? toolCallId;
|
||||
final UiCard? ui;
|
||||
final DateTime? timestamp;
|
||||
final List<Map<String, dynamic>>? attachments;
|
||||
|
||||
SnapshotMessage({
|
||||
required this.id,
|
||||
required this.role,
|
||||
this.content,
|
||||
this.toolCallId,
|
||||
this.ui,
|
||||
this.timestamp,
|
||||
this.attachments,
|
||||
class HistorySnapshot {
|
||||
const HistorySnapshot({
|
||||
required this.scope,
|
||||
required this.threadId,
|
||||
required this.day,
|
||||
required this.hasMore,
|
||||
required this.messages,
|
||||
});
|
||||
|
||||
factory SnapshotMessage.fromJson(Map<String, dynamic> json) =>
|
||||
_$SnapshotMessageFromJson(json);
|
||||
final String scope;
|
||||
final String? threadId;
|
||||
final String? day;
|
||||
final bool hasMore;
|
||||
final List<HistoryMessage> messages;
|
||||
|
||||
Map<String, dynamic> toJson() => _$SnapshotMessageToJson(this);
|
||||
factory HistorySnapshot.fromJson(Map<String, dynamic> json) {
|
||||
final rawMessages = json['messages'];
|
||||
final messages = rawMessages is List
|
||||
? rawMessages
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(HistoryMessage.fromJson)
|
||||
.toList()
|
||||
: const <HistoryMessage>[];
|
||||
return HistorySnapshot(
|
||||
scope: _asString(json['scope'], fallback: 'history_day'),
|
||||
threadId: json['threadId'] as String?,
|
||||
day: json['day'] as String?,
|
||||
hasMore: json['hasMore'] == true,
|
||||
messages: messages,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HistoryMessage {
|
||||
const HistoryMessage({
|
||||
required this.id,
|
||||
required this.seq,
|
||||
required this.role,
|
||||
required this.content,
|
||||
required this.timestamp,
|
||||
this.url,
|
||||
this.uiSchema,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final int seq;
|
||||
final String role;
|
||||
final String content;
|
||||
final DateTime timestamp;
|
||||
final String? url;
|
||||
final Map<String, dynamic>? uiSchema;
|
||||
|
||||
factory HistoryMessage.fromJson(Map<String, dynamic> json) => HistoryMessage(
|
||||
id: _asString(json['id']),
|
||||
seq: _asInt(json['seq']),
|
||||
role: _asString(json['role']),
|
||||
content: _asString(json['content']),
|
||||
timestamp:
|
||||
DateTime.tryParse(_asString(json['timestamp'])) ?? DateTime.now(),
|
||||
url: json['url'] as String?,
|
||||
uiSchema: _asMap(json['ui_schema']) ?? _asMap(json['uiSchema']),
|
||||
);
|
||||
}
|
||||
|
||||
String _asString(Object? value, {String fallback = ''}) {
|
||||
if (value is String) {
|
||||
return value;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
int _asInt(Object? value) {
|
||||
if (value is int) {
|
||||
return value;
|
||||
}
|
||||
if (value is double) {
|
||||
return value.toInt();
|
||||
}
|
||||
if (value is String) {
|
||||
return int.tryParse(value) ?? 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _asMap(Object? value) {
|
||||
if (value is Map<String, dynamic>) {
|
||||
return value;
|
||||
}
|
||||
if (value is Map) {
|
||||
final result = <String, dynamic>{};
|
||||
for (final entry in value.entries) {
|
||||
final key = entry.key;
|
||||
if (key is String) {
|
||||
result[key] = entry.value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'tool_result.dart';
|
||||
|
||||
enum ChatItemType { message, toolCall, toolResult }
|
||||
|
||||
enum MessageSender { user, ai }
|
||||
@@ -105,7 +103,7 @@ class ToolResultItem extends ChatListItem {
|
||||
@override
|
||||
final String id;
|
||||
final String callId;
|
||||
final UiCard uiCard;
|
||||
final Map<String, dynamic> uiSchema;
|
||||
@override
|
||||
final DateTime timestamp;
|
||||
@override
|
||||
@@ -114,7 +112,7 @@ class ToolResultItem extends ChatListItem {
|
||||
ToolResultItem({
|
||||
required this.id,
|
||||
required this.callId,
|
||||
required this.uiCard,
|
||||
required this.uiSchema,
|
||||
required this.timestamp,
|
||||
required this.sender,
|
||||
});
|
||||
|
||||
@@ -7,20 +7,15 @@ import 'package:dio/dio.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:social_app/core/api/i_api_client.dart';
|
||||
|
||||
import '../ai/ai_decision_engine.dart';
|
||||
import '../models/ag_ui_event.dart';
|
||||
import '../tools/tool_registry.dart';
|
||||
|
||||
typedef EventCallback = void Function(AgUiEvent event);
|
||||
|
||||
const _runIdPrefix = 'run_';
|
||||
const _messageIdPrefix = 'msg_';
|
||||
const _toolCallIdPrefix = 'tc_';
|
||||
|
||||
class AgUiService {
|
||||
final IApiClient _apiClient;
|
||||
EventCallback onEvent;
|
||||
final AiDecisionEngine _decisionEngine;
|
||||
final Map<String, String> _lastEventIdByThread = {};
|
||||
int _activeStreamToken = 0;
|
||||
|
||||
@@ -29,8 +24,7 @@ class AgUiService {
|
||||
|
||||
AgUiService({EventCallback? onEvent, required IApiClient apiClient})
|
||||
: onEvent = onEvent ?? ((_) {}),
|
||||
_apiClient = apiClient,
|
||||
_decisionEngine = AiDecisionEngine();
|
||||
_apiClient = apiClient;
|
||||
|
||||
Future<void> sendMessage(String content, {List<XFile>? images}) async {
|
||||
final streamToken = ++_activeStreamToken;
|
||||
@@ -51,23 +45,19 @@ class AgUiService {
|
||||
await _streamEventsFromApi(threadId, streamToken: streamToken);
|
||||
}
|
||||
|
||||
Future<void> loadHistory({DateTime? beforeDate}) async {
|
||||
Future<HistorySnapshot> loadHistory({DateTime? beforeDate}) async {
|
||||
final path = _buildHistoryPath(beforeDate: beforeDate);
|
||||
final response = await _apiClient.get<Map<String, dynamic>>(path);
|
||||
final payload = response.data;
|
||||
if (payload is! Map<String, dynamic>) {
|
||||
throw StateError('Invalid /agent/history response');
|
||||
}
|
||||
final event = AgUiEvent.fromJson(payload);
|
||||
if (event is StateSnapshotEvent) {
|
||||
final snapshot = event.snapshot;
|
||||
final threadIdFromSnapshot = snapshot['threadId'] as String?;
|
||||
if (threadIdFromSnapshot != null && threadIdFromSnapshot.isNotEmpty) {
|
||||
_threadId = threadIdFromSnapshot;
|
||||
}
|
||||
_hasMoreHistory = snapshot['hasMore'] == true;
|
||||
final snapshot = HistorySnapshot.fromJson(payload);
|
||||
if (snapshot.threadId != null && snapshot.threadId!.isNotEmpty) {
|
||||
_threadId = snapshot.threadId;
|
||||
}
|
||||
onEvent(event);
|
||||
_hasMoreHistory = snapshot.hasMore;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
Future<Uint8List> fetchAttachmentPreview(String previewPath) async {
|
||||
@@ -105,60 +95,6 @@ class AgUiService {
|
||||
return transcript;
|
||||
}
|
||||
|
||||
Future<void> approveToolCall({
|
||||
required String toolCallId,
|
||||
required String toolName,
|
||||
required Map<String, dynamic> args,
|
||||
}) async {
|
||||
final streamToken = ++_activeStreamToken;
|
||||
final threadId = _threadId;
|
||||
if (threadId == null || threadId.isEmpty) {
|
||||
throw StateError('Missing threadId for resume');
|
||||
}
|
||||
ToolRegistry.initialize();
|
||||
final nonce = args['__nonce'];
|
||||
if (nonce is! String || nonce.isEmpty) {
|
||||
throw StateError('Missing tool nonce for resume');
|
||||
}
|
||||
final localResult = await ToolRegistry.execute(toolName, args);
|
||||
if (localResult['ok'] != true) {
|
||||
throw StateError('Frontend tool execution failed');
|
||||
}
|
||||
final runInput = {
|
||||
'threadId': threadId,
|
||||
'runId': _nextId(_runIdPrefix),
|
||||
'state': <String, dynamic>{},
|
||||
'messages': [
|
||||
{
|
||||
'id': _nextId('tool_'),
|
||||
'role': 'tool',
|
||||
'toolCallId': toolCallId,
|
||||
'content': jsonEncode({
|
||||
'toolName': toolName,
|
||||
'toolArgs': args,
|
||||
'nonce': nonce,
|
||||
'result': localResult,
|
||||
}),
|
||||
},
|
||||
],
|
||||
'tools': _buildTools(),
|
||||
'context': <Map<String, dynamic>>[],
|
||||
'forwardedProps': <String, dynamic>{},
|
||||
};
|
||||
final response = await _apiClient.post<Map<String, dynamic>>(
|
||||
'/api/v1/agent/runs/$threadId/resume',
|
||||
data: runInput,
|
||||
);
|
||||
final payload = response.data;
|
||||
if (payload is Map<String, dynamic>) {
|
||||
final responseThreadId = payload['threadId'];
|
||||
if (responseThreadId is String && responseThreadId.isNotEmpty) {
|
||||
_threadId = responseThreadId;
|
||||
}
|
||||
}
|
||||
await _streamEventsFromApi(threadId, streamToken: streamToken);
|
||||
}
|
||||
|
||||
bool hasEarlierHistory(DateTime fromDate) {
|
||||
// 历史是否还有更多由后端 history snapshot 的 hasMore 驱动。
|
||||
// 参数保留是为了兼容 ChatBloc 现有调用签名。
|
||||
@@ -199,9 +135,6 @@ class AgUiService {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
final event = AgUiEvent.fromJson(decoded);
|
||||
if (event is StateSnapshotEvent) {
|
||||
_hasMoreHistory = event.snapshot['hasMore'] == true;
|
||||
}
|
||||
onEvent(event);
|
||||
}
|
||||
} catch (_) {
|
||||
@@ -285,7 +218,7 @@ class AgUiService {
|
||||
'messages': [
|
||||
{'id': _nextId('user_'), 'role': 'user', 'content': messageContent},
|
||||
],
|
||||
'tools': _buildTools(),
|
||||
'tools': <Map<String, dynamic>>[],
|
||||
'context': <Map<String, dynamic>>[],
|
||||
'forwardedProps': <String, dynamic>{},
|
||||
};
|
||||
@@ -343,26 +276,6 @@ class AgUiService {
|
||||
return attachments;
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _buildTools() {
|
||||
return [
|
||||
{
|
||||
'name': 'front.navigate_to_route',
|
||||
'description': 'Navigate user to a route in the mobile app.',
|
||||
'parameters': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'target': {'type': 'string', 'description': 'Route path target'},
|
||||
'replace': {
|
||||
'type': 'boolean',
|
||||
'description': 'Use replace navigation',
|
||||
},
|
||||
},
|
||||
'required': ['target'],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
String _buildHistoryPath({DateTime? beforeDate}) {
|
||||
final query = <String>[];
|
||||
if (_threadId != null && _threadId!.isNotEmpty) {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:social_app/core/api/i_api_client.dart';
|
||||
import 'package:social_app/core/di/injection.dart';
|
||||
|
||||
import '../../data/models/ag_ui_event.dart';
|
||||
import '../../data/models/chat_list_item.dart';
|
||||
@@ -84,18 +82,17 @@ class ChatState {
|
||||
}
|
||||
|
||||
class ChatBloc extends Cubit<ChatState> {
|
||||
final AgUiService _service;
|
||||
final Map<String, String> _toolCallArgsBuffer = {};
|
||||
final Map<String, Uint8List> _attachmentPreviewCache = <String, Uint8List>{};
|
||||
final Map<String, Future<Uint8List?>> _attachmentPreviewInflight =
|
||||
<String, Future<Uint8List?>>{};
|
||||
|
||||
ChatBloc({AgUiService? service, required IApiClient apiClient})
|
||||
: _service = service ?? AgUiService(apiClient: apiClient),
|
||||
super(const ChatState()) {
|
||||
_service.onEvent = _handleEvent;
|
||||
}
|
||||
|
||||
final AgUiService _service;
|
||||
final Map<String, Uint8List> _attachmentPreviewCache = <String, Uint8List>{};
|
||||
final Map<String, Future<Uint8List?>> _attachmentPreviewInflight =
|
||||
<String, Future<Uint8List?>>{};
|
||||
|
||||
void _handleEvent(AgUiEvent event) {
|
||||
switch (event.type) {
|
||||
case AgUiEventType.runStarted:
|
||||
@@ -136,10 +133,6 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
_handleStepStarted(event as StepStartedEvent);
|
||||
case AgUiEventType.stepFinished:
|
||||
_handleStepFinished(event as StepFinishedEvent);
|
||||
case AgUiEventType.textMessageStart:
|
||||
_handleTextMessageStart(event as TextMessageStartEvent);
|
||||
case AgUiEventType.textMessageContent:
|
||||
_handleTextMessageContent(event as TextMessageContentEvent);
|
||||
case AgUiEventType.textMessageEnd:
|
||||
_handleTextMessageEnd(event as TextMessageEndEvent);
|
||||
case AgUiEventType.toolCallStart:
|
||||
@@ -152,10 +145,6 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
_handleToolCallResult(event as ToolCallResultEvent);
|
||||
case AgUiEventType.toolCallError:
|
||||
_handleToolCallError(event as ToolCallErrorEvent);
|
||||
case AgUiEventType.stateSnapshot:
|
||||
_handleStateSnapshot(event as StateSnapshotEvent);
|
||||
case AgUiEventType.messagesSnapshot:
|
||||
_handleMessagesSnapshot(event as MessagesSnapshotEvent);
|
||||
case AgUiEventType.unknown:
|
||||
break;
|
||||
}
|
||||
@@ -171,213 +160,179 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTextMessageStart(TextMessageStartEvent startEvent) {
|
||||
final newMessage = TextMessageItem(
|
||||
id: startEvent.messageId,
|
||||
content: '',
|
||||
timestamp: DateTime.now(),
|
||||
sender: MessageSender.ai,
|
||||
isStreaming: true,
|
||||
void _handleTextMessageEnd(TextMessageEndEvent event) {
|
||||
final timestamp = DateTime.now();
|
||||
final items = List<ChatListItem>.from(state.items);
|
||||
|
||||
final messageIndex = items.indexWhere(
|
||||
(item) => item.id == event.messageId && item is TextMessageItem,
|
||||
);
|
||||
|
||||
if (messageIndex >= 0) {
|
||||
final existing = items[messageIndex] as TextMessageItem;
|
||||
items[messageIndex] = existing.copyWith(
|
||||
content: event.answer,
|
||||
isStreaming: false,
|
||||
);
|
||||
} else {
|
||||
items.add(
|
||||
TextMessageItem(
|
||||
id: event.messageId,
|
||||
content: event.answer,
|
||||
timestamp: timestamp,
|
||||
sender: MessageSender.ai,
|
||||
isStreaming: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final uiSchema = event.uiSchema;
|
||||
if (uiSchema != null) {
|
||||
final uiItemId = '${event.messageId}-ui';
|
||||
final existingUiIndex = items.indexWhere((item) => item.id == uiItemId);
|
||||
final uiItem = ToolResultItem(
|
||||
id: uiItemId,
|
||||
callId: event.messageId,
|
||||
uiSchema: uiSchema,
|
||||
timestamp: timestamp,
|
||||
sender: MessageSender.ai,
|
||||
);
|
||||
if (existingUiIndex >= 0) {
|
||||
items[existingUiIndex] = uiItem;
|
||||
} else {
|
||||
items.add(uiItem);
|
||||
}
|
||||
}
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
items: [...state.items, newMessage],
|
||||
currentMessageId: startEvent.messageId,
|
||||
isWaitingFirstToken: false,
|
||||
isStreaming: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleTextMessageContent(TextMessageContentEvent contentEvent) {
|
||||
final updatedItems = state.items.map((item) {
|
||||
if (item.id == contentEvent.messageId && item is TextMessageItem) {
|
||||
return item.copyWith(content: item.content + contentEvent.delta);
|
||||
}
|
||||
return item;
|
||||
}).toList();
|
||||
emit(state.copyWith(items: updatedItems));
|
||||
}
|
||||
|
||||
void _handleTextMessageEnd(TextMessageEndEvent endEvent) {
|
||||
final updatedItems = state.items.map((item) {
|
||||
if (item.id == endEvent.messageId && item is TextMessageItem) {
|
||||
return item.copyWith(isStreaming: false);
|
||||
}
|
||||
return item;
|
||||
}).toList();
|
||||
emit(
|
||||
state.copyWith(
|
||||
items: updatedItems,
|
||||
items: items,
|
||||
currentMessageId: null,
|
||||
isWaitingFirstToken: false,
|
||||
isStreaming: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleToolCallStart(ToolCallStartEvent startEvent) {
|
||||
_toolCallArgsBuffer[startEvent.toolCallId] = '';
|
||||
final newToolCall = ToolCallItem(
|
||||
id: startEvent.toolCallId,
|
||||
callId: startEvent.toolCallId,
|
||||
toolName: startEvent.toolCallName,
|
||||
args: {},
|
||||
status: ToolCallStatus.pending,
|
||||
timestamp: DateTime.now(),
|
||||
sender: MessageSender.ai,
|
||||
);
|
||||
emit(state.copyWith(items: [...state.items, newToolCall]));
|
||||
void _handleToolCallStart(ToolCallStartEvent event) {
|
||||
final items = List<ChatListItem>.from(state.items)
|
||||
..add(
|
||||
ToolCallItem(
|
||||
id: event.toolCallId,
|
||||
callId: event.toolCallId,
|
||||
toolName: event.toolCallName,
|
||||
args: const {},
|
||||
status: ToolCallStatus.pending,
|
||||
timestamp: DateTime.now(),
|
||||
sender: MessageSender.ai,
|
||||
),
|
||||
);
|
||||
emit(state.copyWith(items: items));
|
||||
}
|
||||
|
||||
void _handleToolCallArgs(ToolCallArgsEvent argsEvent) {
|
||||
_toolCallArgsBuffer[argsEvent.toolCallId] =
|
||||
(_toolCallArgsBuffer[argsEvent.toolCallId] ?? '') + argsEvent.delta;
|
||||
}
|
||||
|
||||
void _handleToolCallEnd(ToolCallEndEvent endEvent) {
|
||||
final argsBuffer = _toolCallArgsBuffer[endEvent.toolCallId] ?? '';
|
||||
Map<String, dynamic> parsedArgs = {};
|
||||
if (argsBuffer.isNotEmpty) {
|
||||
try {
|
||||
parsedArgs = jsonDecode(argsBuffer) as Map<String, dynamic>;
|
||||
} catch (_) {}
|
||||
}
|
||||
_toolCallArgsBuffer.remove(endEvent.toolCallId);
|
||||
final updatedItems = state.items.map((item) {
|
||||
if (item.id == endEvent.toolCallId && item is ToolCallItem) {
|
||||
final nextStatus = item.toolName == 'front.navigate_to_route'
|
||||
? ToolCallStatus.pending
|
||||
: ToolCallStatus.executing;
|
||||
return item.copyWith(args: parsedArgs, status: nextStatus);
|
||||
void _handleToolCallArgs(ToolCallArgsEvent event) {
|
||||
final items = state.items.map((item) {
|
||||
if (item is ToolCallItem && item.id == event.toolCallId) {
|
||||
return item.copyWith(args: event.args);
|
||||
}
|
||||
return item;
|
||||
}).toList();
|
||||
emit(state.copyWith(items: updatedItems));
|
||||
emit(state.copyWith(items: items));
|
||||
}
|
||||
|
||||
void _handleToolCallResult(ToolCallResultEvent resultEvent) {
|
||||
final filteredItems = state.items.where((item) {
|
||||
if (item.id == resultEvent.toolCallId && item is ToolCallItem) {
|
||||
return false;
|
||||
void _handleToolCallEnd(ToolCallEndEvent event) {
|
||||
final items = state.items.map((item) {
|
||||
if (item is ToolCallItem && item.id == event.toolCallId) {
|
||||
return item.copyWith(status: ToolCallStatus.executing);
|
||||
}
|
||||
return true;
|
||||
return item;
|
||||
}).toList();
|
||||
final uiCard = resultEvent.ui;
|
||||
if (uiCard == null) {
|
||||
emit(state.copyWith(items: filteredItems));
|
||||
return;
|
||||
}
|
||||
final resultItem = ToolResultItem(
|
||||
id: resultEvent.messageId,
|
||||
callId: resultEvent.toolCallId,
|
||||
uiCard: uiCard,
|
||||
timestamp: DateTime.now(),
|
||||
sender: MessageSender.ai,
|
||||
);
|
||||
emit(state.copyWith(items: [...filteredItems, resultItem]));
|
||||
emit(state.copyWith(items: items));
|
||||
}
|
||||
|
||||
void _handleToolCallError(ToolCallErrorEvent errorEvent) {
|
||||
_toolCallArgsBuffer.remove(errorEvent.toolCallId);
|
||||
final updatedItems = state.items.map((item) {
|
||||
if (item.id == errorEvent.toolCallId && item is ToolCallItem) {
|
||||
void _handleToolCallResult(ToolCallResultEvent event) {
|
||||
final timestamp = DateTime.now();
|
||||
final items = state.items.where((item) {
|
||||
return !(item is ToolCallItem && item.id == event.toolCallId);
|
||||
}).toList();
|
||||
|
||||
if (event.uiSchema != null) {
|
||||
_upsertById(
|
||||
items,
|
||||
ToolResultItem(
|
||||
id: event.messageId,
|
||||
callId: event.toolCallId,
|
||||
uiSchema: event.uiSchema!,
|
||||
timestamp: timestamp,
|
||||
sender: MessageSender.ai,
|
||||
),
|
||||
);
|
||||
} else if (event.resultSummary.isNotEmpty) {
|
||||
_upsertById(
|
||||
items,
|
||||
TextMessageItem(
|
||||
id: event.messageId,
|
||||
content: event.resultSummary,
|
||||
timestamp: timestamp,
|
||||
sender: MessageSender.ai,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
emit(state.copyWith(items: items));
|
||||
}
|
||||
|
||||
void _handleToolCallError(ToolCallErrorEvent event) {
|
||||
final items = state.items.map((item) {
|
||||
if (item is ToolCallItem && item.id == event.toolCallId) {
|
||||
return item.copyWith(
|
||||
status: ToolCallStatus.error,
|
||||
errorMessage: errorEvent.error,
|
||||
errorMessage: event.error,
|
||||
);
|
||||
}
|
||||
return item;
|
||||
}).toList();
|
||||
emit(state.copyWith(items: updatedItems));
|
||||
emit(state.copyWith(items: items));
|
||||
}
|
||||
|
||||
void _handleMessagesSnapshot(MessagesSnapshotEvent snapshotEvent) {
|
||||
final newItems = _convertSnapshotMessages(snapshotEvent.messages);
|
||||
final allItems = [...newItems, ...state.items];
|
||||
|
||||
// Determine oldest date and history availability
|
||||
DateTime? newOldestDate = state.oldestLoadedDate;
|
||||
bool newHasEarlierHistory = false;
|
||||
|
||||
if (newItems.isNotEmpty) {
|
||||
newOldestDate = _extractDateFromItems(newItems);
|
||||
if (newOldestDate != null) {
|
||||
newHasEarlierHistory = _service.hasEarlierHistory(newOldestDate);
|
||||
List<ChatListItem> _convertHistoryMessages(List<HistoryMessage> messages) {
|
||||
final converted = <ChatListItem>[];
|
||||
for (final msg in messages) {
|
||||
final sender = msg.role == 'user' ? MessageSender.user : MessageSender.ai;
|
||||
final attachments = <Map<String, dynamic>>[];
|
||||
if (msg.url != null && msg.url!.isNotEmpty) {
|
||||
attachments.add({'url': msg.url!, 'mimeType': 'image/*'});
|
||||
}
|
||||
} else if (newOldestDate != null) {
|
||||
newHasEarlierHistory = _service.hasEarlierHistory(newOldestDate);
|
||||
}
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
items: allItems,
|
||||
oldestLoadedDate: newOldestDate,
|
||||
hasEarlierHistory: newHasEarlierHistory,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleStateSnapshot(StateSnapshotEvent stateSnapshotEvent) {
|
||||
final snapshot = stateSnapshotEvent.snapshot;
|
||||
if (snapshot['scope'] != 'history_day') {
|
||||
return;
|
||||
}
|
||||
final rawMessages = snapshot['messages'];
|
||||
if (rawMessages is! List<dynamic>) {
|
||||
_handleMessagesSnapshot(MessagesSnapshotEvent(messages: const []));
|
||||
return;
|
||||
}
|
||||
final parsed = <SnapshotMessage>[];
|
||||
for (final raw in rawMessages) {
|
||||
if (raw is! Map<String, dynamic>) {
|
||||
continue;
|
||||
if (msg.content.isNotEmpty || sender == MessageSender.user) {
|
||||
converted.add(
|
||||
TextMessageItem(
|
||||
id: msg.id,
|
||||
content: msg.content,
|
||||
timestamp: msg.timestamp,
|
||||
sender: sender,
|
||||
attachments: attachments,
|
||||
),
|
||||
);
|
||||
}
|
||||
parsed.add(SnapshotMessage.fromJson(raw));
|
||||
}
|
||||
_handleMessagesSnapshot(MessagesSnapshotEvent(messages: parsed));
|
||||
}
|
||||
|
||||
List<ChatListItem> _convertSnapshotMessages(List<SnapshotMessage> messages) {
|
||||
return messages.map((msg) {
|
||||
final timestamp = msg.timestamp ?? DateTime.now();
|
||||
switch (msg.role) {
|
||||
case 'user':
|
||||
return TextMessageItem(
|
||||
id: msg.id,
|
||||
content: msg.content ?? '',
|
||||
timestamp: timestamp,
|
||||
sender: MessageSender.user,
|
||||
attachments: msg.attachments ?? const [],
|
||||
);
|
||||
case 'assistant':
|
||||
return TextMessageItem(
|
||||
id: msg.id,
|
||||
content: msg.content ?? '',
|
||||
timestamp: timestamp,
|
||||
if (msg.uiSchema != null) {
|
||||
converted.add(
|
||||
ToolResultItem(
|
||||
id: '${msg.id}-ui',
|
||||
callId: msg.id,
|
||||
uiSchema: msg.uiSchema!,
|
||||
timestamp: msg.timestamp,
|
||||
sender: MessageSender.ai,
|
||||
);
|
||||
case 'tool' when msg.ui != null:
|
||||
return ToolResultItem(
|
||||
id: msg.id,
|
||||
callId: msg.toolCallId ?? '',
|
||||
uiCard: msg.ui!,
|
||||
timestamp: timestamp,
|
||||
sender: MessageSender.ai,
|
||||
);
|
||||
default:
|
||||
return TextMessageItem(
|
||||
id: msg.id,
|
||||
content: msg.content ?? '',
|
||||
timestamp: timestamp,
|
||||
sender: MessageSender.ai,
|
||||
);
|
||||
),
|
||||
);
|
||||
}
|
||||
}).toList();
|
||||
}
|
||||
return converted;
|
||||
}
|
||||
|
||||
DateTime? _extractDateFromItems(List<ChatListItem> items) {
|
||||
if (items.isEmpty) return null;
|
||||
|
||||
return items
|
||||
.map(
|
||||
(item) => DateTime(
|
||||
@@ -393,8 +348,8 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
final attachments = (images ?? const <XFile>[])
|
||||
.map(
|
||||
(image) => <String, dynamic>{
|
||||
"path": image.path,
|
||||
"mimeType": "image/*",
|
||||
'path': image.path,
|
||||
'mimeType': 'image/*',
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
@@ -434,7 +389,16 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
if (state.isLoadingHistory) return;
|
||||
emit(state.copyWith(isLoadingHistory: true));
|
||||
try {
|
||||
await _service.loadHistory();
|
||||
final snapshot = await _service.loadHistory();
|
||||
final newItems = _convertHistoryMessages(snapshot.messages);
|
||||
final oldestDate = _extractDateFromItems(newItems);
|
||||
emit(
|
||||
state.copyWith(
|
||||
items: newItems,
|
||||
oldestLoadedDate: oldestDate,
|
||||
hasEarlierHistory: snapshot.hasMore,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
emit(state.copyWith(isLoadingHistory: false));
|
||||
}
|
||||
@@ -445,69 +409,38 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
if (state.oldestLoadedDate == null) return;
|
||||
emit(state.copyWith(isLoadingHistory: true));
|
||||
try {
|
||||
await _service.loadHistory(beforeDate: state.oldestLoadedDate);
|
||||
final snapshot = await _service.loadHistory(
|
||||
beforeDate: state.oldestLoadedDate,
|
||||
);
|
||||
final newItems = _convertHistoryMessages(snapshot.messages);
|
||||
final mergedById = <String, ChatListItem>{
|
||||
for (final item in state.items) item.id: item,
|
||||
};
|
||||
for (final item in newItems) {
|
||||
mergedById[item.id] = item;
|
||||
}
|
||||
final merged = mergedById.values.toList()
|
||||
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
||||
final oldestDate = _extractDateFromItems(merged);
|
||||
emit(
|
||||
state.copyWith(
|
||||
items: merged,
|
||||
oldestLoadedDate: oldestDate,
|
||||
hasEarlierHistory: snapshot.hasMore,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
emit(state.copyWith(isLoadingHistory: false));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> approveToolCall(String toolCallId) async {
|
||||
ToolCallItem? target;
|
||||
for (final item in state.items) {
|
||||
if (item is ToolCallItem && item.callId == toolCallId) {
|
||||
target = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (target == null) {
|
||||
void _upsertById(List<ChatListItem> items, ChatListItem nextItem) {
|
||||
final index = items.indexWhere((item) => item.id == nextItem.id);
|
||||
if (index >= 0) {
|
||||
items[index] = nextItem;
|
||||
return;
|
||||
}
|
||||
final updatedItems = state.items.map((item) {
|
||||
if (item is ToolCallItem && item.callId == toolCallId) {
|
||||
return item.copyWith(
|
||||
status: ToolCallStatus.executing,
|
||||
errorMessage: null,
|
||||
);
|
||||
}
|
||||
return item;
|
||||
}).toList();
|
||||
emit(
|
||||
state.copyWith(
|
||||
items: updatedItems,
|
||||
isSending: false,
|
||||
isWaitingFirstToken: true,
|
||||
isStreaming: false,
|
||||
isCancelling: false,
|
||||
error: null,
|
||||
),
|
||||
);
|
||||
try {
|
||||
await _service.approveToolCall(
|
||||
toolCallId: target.callId,
|
||||
toolName: target.toolName,
|
||||
args: target.args,
|
||||
);
|
||||
} catch (error) {
|
||||
final failedItems = state.items.map((item) {
|
||||
if (item is ToolCallItem && item.callId == toolCallId) {
|
||||
return item.copyWith(
|
||||
status: ToolCallStatus.error,
|
||||
errorMessage: error.toString(),
|
||||
);
|
||||
}
|
||||
return item;
|
||||
}).toList();
|
||||
emit(
|
||||
state.copyWith(
|
||||
items: failedItems,
|
||||
isSending: false,
|
||||
isWaitingFirstToken: false,
|
||||
isStreaming: false,
|
||||
isCancelling: false,
|
||||
error: error.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
items.add(nextItem);
|
||||
}
|
||||
|
||||
Future<String> transcribeAudioFile(String filePath) {
|
||||
@@ -548,16 +481,17 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
if (pending != null) {
|
||||
return pending;
|
||||
}
|
||||
final future = _service
|
||||
.fetchAttachmentPreview(previewPath)
|
||||
.then((bytes) {
|
||||
_attachmentPreviewCache[previewPath] = bytes;
|
||||
return bytes;
|
||||
})
|
||||
.catchError((_) => null)
|
||||
.whenComplete(() {
|
||||
_attachmentPreviewInflight.remove(previewPath);
|
||||
});
|
||||
final future = (() async {
|
||||
try {
|
||||
final bytes = await _service.fetchAttachmentPreview(previewPath);
|
||||
_attachmentPreviewCache[previewPath] = bytes;
|
||||
return bytes;
|
||||
} catch (_) {
|
||||
return null;
|
||||
} finally {
|
||||
_attachmentPreviewInflight.remove(previewPath);
|
||||
}
|
||||
})();
|
||||
_attachmentPreviewInflight[previewPath] = future;
|
||||
return future;
|
||||
}
|
||||
|
||||
@@ -1,339 +1,398 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:social_app/core/theme/design_tokens.dart';
|
||||
import '../../data/models/tool_result.dart';
|
||||
|
||||
/// 卡片类型常量
|
||||
const _calendarCardType = 'calendar_card.v1';
|
||||
const _calendarListType = 'calendar_event_list.v1';
|
||||
const _calendarOperationType = 'calendar_operation.v1';
|
||||
const _errorCardType = 'error_card.v1';
|
||||
const _aiGeneratedSource = 'ai_generated';
|
||||
const _agentGeneratedSource = 'agent_generated';
|
||||
const _primaryActionType = 'primary';
|
||||
import 'package:social_app/core/theme/design_tokens.dart';
|
||||
import 'package:social_app/shared/widgets/toast/toast.dart';
|
||||
import 'package:social_app/shared/widgets/toast/toast_type.dart';
|
||||
|
||||
class UiSchemaRenderer {
|
||||
static Widget render(UiCard card) {
|
||||
return switch (card.cardType) {
|
||||
_calendarCardType => _renderCalendarCard(card),
|
||||
_calendarListType => _renderCalendarList(card),
|
||||
_calendarOperationType => _renderCalendarOperation(card),
|
||||
_errorCardType => _renderErrorCard(card),
|
||||
_ => _renderUnknownCard(card),
|
||||
static Widget renderSchema(Map<String, dynamic>? schema) {
|
||||
if (schema == null || schema.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final root = _asMap(schema['root']);
|
||||
if (root == null) {
|
||||
return _fallback('无效 UI Schema');
|
||||
}
|
||||
return _renderLayoutNode(root);
|
||||
}
|
||||
|
||||
static Widget _renderLayoutNode(Map<String, dynamic> node) {
|
||||
final type = _asString(node['type']);
|
||||
return switch (type) {
|
||||
'stack' => _renderStack(node),
|
||||
'grid' => _renderGrid(node),
|
||||
_ => _fallback('不支持的布局节点: $type'),
|
||||
};
|
||||
}
|
||||
|
||||
static Widget _renderCalendarCard(UiCard card) {
|
||||
final data = CalendarCardData.fromJson(card.data);
|
||||
final color = data.color != null
|
||||
? Color(int.parse(data.color!.replaceFirst('#', '0xFF')))
|
||||
: AppColors.blue500;
|
||||
final isAiGenerated =
|
||||
data.sourceType == _aiGeneratedSource ||
|
||||
data.sourceType == _agentGeneratedSource;
|
||||
static Widget _renderNode(Map<String, dynamic> node) {
|
||||
final type = _asString(node['type']);
|
||||
if (node['visible'] == false) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return switch (type) {
|
||||
'text' => _renderText(node),
|
||||
'icon' => _renderIcon(node),
|
||||
'badge' => _renderBadge(node),
|
||||
'button' => _renderButton(node),
|
||||
'kv' => _renderKv(node),
|
||||
'divider' => _renderDivider(node),
|
||||
'stack' => _renderStack(node),
|
||||
'grid' => _renderGrid(node),
|
||||
_ => _fallback('未知节点: $type'),
|
||||
};
|
||||
}
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.messageCardBg,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(color: AppColors.messageCardBorder),
|
||||
),
|
||||
child: Column(
|
||||
static Widget _renderStack(Map<String, dynamic> node) {
|
||||
final children = _asList(
|
||||
node['children'],
|
||||
).whereType<Map<String, dynamic>>().map(_renderNode).toList();
|
||||
final gap = _asDouble(node['gap'], fallback: AppSpacing.sm);
|
||||
final direction = _asString(node['direction'], fallback: 'vertical');
|
||||
|
||||
Widget content;
|
||||
if (direction == 'horizontal') {
|
||||
content = Wrap(
|
||||
direction: Axis.horizontal,
|
||||
spacing: gap,
|
||||
runSpacing: gap,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: children,
|
||||
);
|
||||
} else {
|
||||
content = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: AppSpacing.sm,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(AppRadius.lg),
|
||||
topRight: Radius.circular(AppRadius.lg),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.all(AppSpacing.lg),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (isAiGenerated) ...[
|
||||
_buildAiTag(),
|
||||
SizedBox(height: AppSpacing.sm),
|
||||
],
|
||||
Text(
|
||||
_formatTime(data.startAt, data.endAt),
|
||||
style: TextStyle(fontSize: 12, color: AppColors.slate500),
|
||||
),
|
||||
SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
data.title,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.slate900,
|
||||
),
|
||||
),
|
||||
if (data.description != null) ...[
|
||||
SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
data.description!,
|
||||
style: TextStyle(fontSize: 14, color: AppColors.slate600),
|
||||
),
|
||||
],
|
||||
if (data.location != null) ...[
|
||||
SizedBox(height: AppSpacing.sm),
|
||||
_buildLocation(data.location!),
|
||||
],
|
||||
if (card.actions != null && card.actions!.isNotEmpty) ...[
|
||||
SizedBox(height: AppSpacing.md),
|
||||
_buildActions(card.actions!),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
children: _withGap(children, gap),
|
||||
);
|
||||
}
|
||||
return _wrapSurface(node, content);
|
||||
}
|
||||
|
||||
static Widget _renderGrid(Map<String, dynamic> node) {
|
||||
final children = _asList(
|
||||
node['children'],
|
||||
).whereType<Map<String, dynamic>>().map(_renderNode).toList();
|
||||
final columns = _asInt(node['columns'], fallback: 2).clamp(1, 3);
|
||||
final gap = _asDouble(node['gap'], fallback: AppSpacing.sm);
|
||||
final tiles = List.generate(children.length, (index) => children[index]);
|
||||
return _wrapSurface(
|
||||
node,
|
||||
GridView.count(
|
||||
crossAxisCount: columns,
|
||||
crossAxisSpacing: gap,
|
||||
mainAxisSpacing: gap,
|
||||
childAspectRatio: 1.6,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
children: tiles,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _buildAiTag() {
|
||||
static Widget _renderText(Map<String, dynamic> node) {
|
||||
final role = _asString(node['role'], fallback: 'body');
|
||||
final status = _asString(node['status']);
|
||||
final style = switch (role) {
|
||||
'title' => const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.slate900,
|
||||
height: 1.25,
|
||||
),
|
||||
'subtitle' => const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.slate800,
|
||||
),
|
||||
'caption' => const TextStyle(fontSize: 12, color: AppColors.slate500),
|
||||
'code' => const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.slate700,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
_ => const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.slate700,
|
||||
height: 1.45,
|
||||
),
|
||||
};
|
||||
return Text(
|
||||
_asString(node['content']),
|
||||
maxLines: _asIntOrNull(node['maxLines']),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: style.copyWith(color: _statusTextColor(status, style.color)),
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _renderIcon(Map<String, dynamic> node) {
|
||||
final value = _asString(node['value']);
|
||||
if (_asString(node['source']) == 'emoji' && value.isNotEmpty) {
|
||||
return Text(value, style: const TextStyle(fontSize: 20));
|
||||
}
|
||||
return Icon(Icons.bubble_chart_rounded, color: _statusTextColor('', null));
|
||||
}
|
||||
|
||||
static Widget _renderBadge(Map<String, dynamic> node) {
|
||||
final status = _asString(node['status']);
|
||||
final fg =
|
||||
_statusTextColor(status, AppColors.slate700) ?? AppColors.slate700;
|
||||
final bg = _statusBackground(status);
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.sm,
|
||||
vertical: AppSpacing.xs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.messageTagBg,
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
child: Text(
|
||||
'AI生成',
|
||||
style: TextStyle(fontSize: 10, color: AppColors.blue600),
|
||||
_asString(node['label']),
|
||||
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: fg),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _buildLocation(String location) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(Icons.location_on_outlined, size: 16, color: AppColors.slate500),
|
||||
SizedBox(width: AppSpacing.xs),
|
||||
Text(
|
||||
location,
|
||||
style: TextStyle(fontSize: 12, color: AppColors.slate500),
|
||||
),
|
||||
],
|
||||
static Widget _renderButton(Map<String, dynamic> node) {
|
||||
final style = _asString(node['style'], fallback: 'secondary');
|
||||
final action = _asMap(node['action']);
|
||||
final disabled = node['disabled'] == true;
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
return ElevatedButton(
|
||||
onPressed: disabled
|
||||
? null
|
||||
: () {
|
||||
final actionType = _asString(action?['type']);
|
||||
if (actionType == 'copy') {
|
||||
Toast.show(context, '已复制', type: ToastType.success);
|
||||
} else {
|
||||
Toast.show(context, '该操作暂未接入', type: ToastType.info);
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.sm,
|
||||
),
|
||||
backgroundColor: style == 'primary'
|
||||
? AppColors.blue600
|
||||
: AppColors.homeComposerAccent,
|
||||
foregroundColor: style == 'primary'
|
||||
? AppColors.white
|
||||
: AppColors.slate700,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_asString(node['label'], fallback: '操作'),
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _buildActions(List<CardAction> actions) {
|
||||
return Wrap(
|
||||
spacing: AppSpacing.sm,
|
||||
children: actions.map((action) => _buildActionButton(action)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _buildActionButton(CardAction action) {
|
||||
final isPrimary = action.type == _primaryActionType;
|
||||
return GestureDetector(
|
||||
onTap: () => _handleAction(action),
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isPrimary ? AppColors.blue500 : AppColors.messageBtnWrap,
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
border: Border.all(
|
||||
color: isPrimary ? AppColors.blue500 : AppColors.messageBtnBorder,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
action.label,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: isPrimary ? AppColors.white : AppColors.slate600,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _renderCalendarList(UiCard card) {
|
||||
final rawItems = card.data['items'];
|
||||
final items = rawItems is List ? rawItems : const [];
|
||||
final paginationRaw = card.data['pagination'];
|
||||
final pagination = paginationRaw is Map<String, dynamic>
|
||||
? paginationRaw
|
||||
: const <String, dynamic>{};
|
||||
final page = pagination['page'];
|
||||
final total = pagination['total'];
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.all(AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.messageCardBg,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(color: AppColors.messageCardBorder),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'日程列表',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.slate900,
|
||||
),
|
||||
),
|
||||
if (page != null || total != null) ...[
|
||||
SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
'第${page ?? '-'}页 · 共${total ?? '-'}条',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.slate500),
|
||||
),
|
||||
],
|
||||
SizedBox(height: AppSpacing.sm),
|
||||
if (items.isEmpty)
|
||||
Text(
|
||||
'暂无日程',
|
||||
style: TextStyle(fontSize: 14, color: AppColors.slate500),
|
||||
),
|
||||
for (final item in items)
|
||||
if (item is Map<String, dynamic>)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: AppSpacing.xs),
|
||||
static Widget _renderKv(Map<String, dynamic> node) {
|
||||
final items = _asList(
|
||||
node['items'],
|
||||
).whereType<Map<String, dynamic>>().toList();
|
||||
if (items.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: _withGap(
|
||||
items.map((item) {
|
||||
final label = _asString(
|
||||
item['label'],
|
||||
fallback: _asString(item['key']),
|
||||
);
|
||||
final value = item['value']?.toString() ?? '-';
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Text(
|
||||
item['title']?.toString() ?? '未命名日程',
|
||||
style: TextStyle(fontSize: 14, color: AppColors.slate700),
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.slate500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.slate800,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
AppSpacing.xs,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _renderCalendarOperation(UiCard card) {
|
||||
final ok = card.data['ok'] == true;
|
||||
final operation = card.data['operation']?.toString() ?? 'operation';
|
||||
final message = card.data['message']?.toString() ?? (ok ? '操作成功' : '操作失败');
|
||||
static Widget _renderDivider(Map<String, dynamic> node) {
|
||||
final inset = _asDouble(node['inset'], fallback: 0);
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: inset),
|
||||
child: const Divider(height: 1, color: AppColors.slate200),
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _wrapSurface(Map<String, dynamic> node, Widget child) {
|
||||
final appearance = _asString(node['appearance'], fallback: 'plain');
|
||||
final status = _asString(node['status']);
|
||||
if (appearance == 'plain') {
|
||||
return child;
|
||||
}
|
||||
final bg = switch (appearance) {
|
||||
'section' => AppColors.homeComposerInner,
|
||||
_ => _statusBackground(status),
|
||||
};
|
||||
final borderColor = switch (status) {
|
||||
'success' => AppColors.feedbackSuccessBorder,
|
||||
'warning' => AppColors.feedbackWarningBorder,
|
||||
'error' => AppColors.feedbackErrorBorder,
|
||||
_ => AppColors.homeConversationBorder,
|
||||
};
|
||||
return Container(
|
||||
padding: EdgeInsets.all(AppSpacing.lg),
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: ok ? AppColors.messageCardBg : AppColors.warningBackground,
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(
|
||||
color: ok ? AppColors.messageCardBorder : AppColors.red400,
|
||||
border: Border.all(color: borderColor),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.blue100.withValues(alpha: 0.35),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _fallback(String text) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.feedbackWarningSurface,
|
||||
border: Border.all(color: AppColors.feedbackWarningBorder),
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.feedbackWarningText,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'日程$operation结果',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ok ? AppColors.slate900 : AppColors.red600,
|
||||
),
|
||||
),
|
||||
SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
message,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: ok ? AppColors.slate600 : AppColors.red600,
|
||||
),
|
||||
),
|
||||
if (card.actions != null && card.actions!.isNotEmpty) ...[
|
||||
SizedBox(height: AppSpacing.md),
|
||||
_buildActions(card.actions!),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _renderErrorCard(UiCard card) {
|
||||
final message = card.data['message'] as String? ?? '发生错误';
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.all(AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.warningBackground,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(color: AppColors.red400),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 20, color: AppColors.red600),
|
||||
SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: TextStyle(fontSize: 14, color: AppColors.red600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _renderUnknownCard(UiCard card) {
|
||||
return Container(
|
||||
padding: EdgeInsets.all(AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.messageCardBg,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'未知卡片类型: ${card.cardType}',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.slate600,
|
||||
),
|
||||
),
|
||||
SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
card.data.toString(),
|
||||
style: TextStyle(fontSize: 12, color: AppColors.slate500),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _formatTime(String startAt, String? endAt) {
|
||||
try {
|
||||
final start = DateTime.parse(startAt);
|
||||
final buffer = StringBuffer();
|
||||
|
||||
buffer.write('${start.month}月${start.day}日 ');
|
||||
buffer.write(
|
||||
'${start.hour.toString().padLeft(2, '0')}:${start.minute.toString().padLeft(2, '0')}',
|
||||
);
|
||||
|
||||
if (endAt != null) {
|
||||
final end = DateTime.parse(endAt);
|
||||
buffer.write(
|
||||
' - ${end.hour.toString().padLeft(2, '0')}:${end.minute.toString().padLeft(2, '0')}',
|
||||
);
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
} catch (e) {
|
||||
return startAt;
|
||||
static List<Widget> _withGap(List<Widget> widgets, double gap) {
|
||||
if (widgets.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
final result = <Widget>[];
|
||||
for (var i = 0; i < widgets.length; i++) {
|
||||
if (i > 0) {
|
||||
result.add(SizedBox(height: gap));
|
||||
}
|
||||
result.add(widgets[i]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static void _handleAction(CardAction action) {
|
||||
// TODO: 实现 action 处理
|
||||
static Color _statusBackground(String status) {
|
||||
return switch (status) {
|
||||
'success' => AppColors.feedbackSuccessSurface,
|
||||
'warning' => AppColors.feedbackWarningSurface,
|
||||
'error' => AppColors.feedbackErrorSurface,
|
||||
'pending' => AppColors.feedbackInfoSurface,
|
||||
_ => AppColors.homeConversationSurface,
|
||||
};
|
||||
}
|
||||
|
||||
static Color? _statusTextColor(String status, Color? fallback) {
|
||||
return switch (status) {
|
||||
'success' => AppColors.feedbackSuccessText,
|
||||
'warning' => AppColors.feedbackWarningText,
|
||||
'error' => AppColors.feedbackErrorText,
|
||||
'pending' => AppColors.feedbackInfoText,
|
||||
_ => fallback,
|
||||
};
|
||||
}
|
||||
|
||||
static Map<String, dynamic>? _asMap(Object? value) {
|
||||
if (value is Map<String, dynamic>) {
|
||||
return value;
|
||||
}
|
||||
if (value is Map) {
|
||||
final result = <String, dynamic>{};
|
||||
for (final entry in value.entries) {
|
||||
if (entry.key is String) {
|
||||
result[entry.key as String] = entry.value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<dynamic> _asList(Object? value) {
|
||||
return value is List ? value : const [];
|
||||
}
|
||||
|
||||
static String _asString(Object? value, {String fallback = ''}) {
|
||||
return value is String ? value : fallback;
|
||||
}
|
||||
|
||||
static int _asInt(Object? value, {int fallback = 0}) {
|
||||
if (value is int) {
|
||||
return value;
|
||||
}
|
||||
if (value is double) {
|
||||
return value.toInt();
|
||||
}
|
||||
if (value is String) {
|
||||
return int.tryParse(value) ?? fallback;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
static int? _asIntOrNull(Object? value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
return _asInt(value);
|
||||
}
|
||||
|
||||
static double _asDouble(Object? value, {double fallback = 0}) {
|
||||
if (value is double) {
|
||||
return value;
|
||||
}
|
||||
if (value is int) {
|
||||
return value.toDouble();
|
||||
}
|
||||
if (value is String) {
|
||||
return double.tryParse(value) ?? fallback;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/di/injection.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/app_loading_indicator.dart';
|
||||
import '../../../../shared/widgets/toast/index.dart';
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/page_header.dart' as widgets;
|
||||
@@ -289,7 +290,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(20),
|
||||
child: CircularProgressIndicator(),
|
||||
child: AppLoadingIndicator(size: 22),
|
||||
),
|
||||
)
|
||||
else if (_friends.isEmpty)
|
||||
@@ -367,7 +368,13 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||
child: _isSearching
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(10),
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
child: AppLoadingIndicator(
|
||||
size: 16,
|
||||
strokeWidth: 2,
|
||||
color: AppColors.blue500,
|
||||
trackColor: AppColors.blue100,
|
||||
withContainer: false,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.search, size: 16, color: AppColors.blue500),
|
||||
),
|
||||
@@ -399,7 +406,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||
if (_isSearching)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
child: const Center(child: AppLoadingIndicator(size: 22)),
|
||||
)
|
||||
else if (_searchResults.isEmpty)
|
||||
Container(
|
||||
@@ -773,13 +780,12 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||
),
|
||||
),
|
||||
child: _sendingRequestUserId == userId
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.white,
|
||||
),
|
||||
? const AppLoadingIndicator(
|
||||
size: 16,
|
||||
strokeWidth: 2,
|
||||
color: AppColors.white,
|
||||
trackColor: AppColors.blue300,
|
||||
withContainer: false,
|
||||
)
|
||||
: const Text(
|
||||
'发送',
|
||||
|
||||
@@ -11,10 +11,10 @@ import '../../../../core/di/injection.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../../../chat/data/models/chat_list_item.dart';
|
||||
import '../../../chat/presentation/bloc/chat_bloc.dart';
|
||||
import '../../../chat/data/tools/route_navigation_tool.dart';
|
||||
import '../../../messages/data/inbox_api.dart';
|
||||
import '../../data/voice_recorder.dart';
|
||||
import '../../../chat/ui/widgets/ui_schema_renderer.dart';
|
||||
import '../../../../shared/widgets/app_loading_indicator.dart';
|
||||
import '../../../../shared/widgets/message_composer.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
@@ -85,15 +85,13 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
bool _isHoldToSpeakMode = false;
|
||||
bool _isTranscribing = false;
|
||||
bool _isCancelGestureActive = false;
|
||||
bool _isSendingMessage = false;
|
||||
int _unreadCount = 0;
|
||||
final List<XFile> _selectedImages = [];
|
||||
|
||||
bool get _hasMessage => _messageController.text.trim().isNotEmpty;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_messageController.addListener(_onMessageChanged);
|
||||
_chatBloc = widget.chatBloc ?? ChatBloc(apiClient: sl());
|
||||
_voiceRecorder = widget.voiceRecorder ?? RecordVoiceRecorder();
|
||||
_inboxApi = sl<InboxApi>();
|
||||
@@ -124,7 +122,6 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_messageController.removeListener(_onMessageChanged);
|
||||
_messageController.dispose();
|
||||
_scrollController.dispose();
|
||||
_listeningAnimationController.dispose();
|
||||
@@ -132,27 +129,11 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
if (widget.chatBloc == null) {
|
||||
_chatBloc.close();
|
||||
}
|
||||
RouteNavigationTool.instance.clearNavigator();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onMessageChanged() {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
RouteNavigationTool.instance.bindNavigator((target, {replace = false}) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (replace) {
|
||||
context.go(target);
|
||||
} else {
|
||||
context.push(target);
|
||||
}
|
||||
});
|
||||
|
||||
return BlocProvider.value(
|
||||
value: _chatBloc,
|
||||
child: BlocConsumer<ChatBloc, ChatState>(
|
||||
@@ -200,7 +181,9 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
state.isWaitingFirstToken || state.isStreaming || state.isCancelling;
|
||||
|
||||
if (state.isLoadingHistory && state.items.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
return const Center(
|
||||
child: AppLoadingIndicator(variant: AppLoadingVariant.surface),
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
@@ -294,9 +277,12 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
SizedBox(
|
||||
width: _transcribingSpinnerSize,
|
||||
height: _transcribingSpinnerSize,
|
||||
child: CircularProgressIndicator(
|
||||
child: const AppLoadingIndicator(
|
||||
variant: AppLoadingVariant.inline,
|
||||
size: _transcribingSpinnerSize,
|
||||
strokeWidth: _transcribingStrokeWidth,
|
||||
color: AppColors.blue600,
|
||||
trackColor: AppColors.blue100,
|
||||
),
|
||||
),
|
||||
SizedBox(width: AppSpacing.sm),
|
||||
@@ -341,13 +327,12 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
alignment: Alignment.center,
|
||||
child: isLoading
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1.5,
|
||||
color: AppColors.slate400,
|
||||
),
|
||||
? const AppLoadingIndicator(
|
||||
variant: AppLoadingVariant.inline,
|
||||
size: 14,
|
||||
strokeWidth: 1.5,
|
||||
color: AppColors.slate400,
|
||||
trackColor: AppColors.slate200,
|
||||
)
|
||||
: const Text(
|
||||
'查看历史',
|
||||
@@ -481,12 +466,10 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return const Center(
|
||||
child: SizedBox(
|
||||
width: _transcribingSpinnerSize,
|
||||
height: _transcribingSpinnerSize,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: _transcribingStrokeWidth,
|
||||
),
|
||||
child: AppLoadingIndicator(
|
||||
variant: AppLoadingVariant.inline,
|
||||
size: _transcribingSpinnerSize,
|
||||
strokeWidth: _transcribingStrokeWidth,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -550,31 +533,13 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Text(statusText, style: TextStyle(fontSize: 12, color: statusColor)),
|
||||
if (item.toolName == 'front.navigate_to_route' &&
|
||||
item.status == ToolCallStatus.pending) ...[
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
GestureDetector(
|
||||
onTap: () => _chatBloc.approveToolCall(item.callId),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blue600,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Text(
|
||||
'同意',
|
||||
style: TextStyle(fontSize: 11, color: AppColors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildToolResultItem(ToolResultItem item) {
|
||||
return UiSchemaRenderer.render(item.uiCard);
|
||||
return UiSchemaRenderer.renderSchema(item.uiSchema);
|
||||
}
|
||||
|
||||
Widget _buildBottomInputStack(BuildContext context, ChatState state) {
|
||||
@@ -611,31 +576,37 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
Widget _buildInputContainer(BuildContext context, ChatState state) {
|
||||
final isWaitingAgent =
|
||||
state.isWaitingFirstToken || state.isStreaming || state.isCancelling;
|
||||
return Container(
|
||||
padding: EdgeInsets.zero,
|
||||
child: MessageComposer(
|
||||
mode: _isHoldToSpeakMode
|
||||
? MessageComposerMode.holdToSpeak
|
||||
: MessageComposerMode.text,
|
||||
process: _composerProcess,
|
||||
hasMessage: _hasMessage,
|
||||
isWaitingAgent: isWaitingAgent,
|
||||
iconSize: _iconSize,
|
||||
composerMinHeight: _inputMinHeight,
|
||||
onTapPlus: _isRecording
|
||||
? () => _stopRecording(autoSendAfterTranscribe: false)
|
||||
: () => _showBottomSheet(context),
|
||||
onTapRightAction: () => _onRightActionTap(context, state),
|
||||
onHoldToSpeakStart: _onHoldToSpeakStart,
|
||||
onHoldToSpeakEnd: _onHoldToSpeakEnd,
|
||||
onHoldToSpeakMoveUpdate: _onHoldToSpeakMoveUpdate,
|
||||
onHoldToSpeakCancel: _onHoldToSpeakCancel,
|
||||
textInputChild: _buildTextInputContent(context),
|
||||
recordingAnimation: const SizedBox.shrink(),
|
||||
recordingText: _isCancelGestureActive ? '松手取消' : '松手发送',
|
||||
recordingHintText: _isCancelGestureActive ? '松开取消' : '松开发送,上滑取消',
|
||||
showRecordingInlineFeedback: false,
|
||||
),
|
||||
return ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: _messageController,
|
||||
builder: (context, value, child) {
|
||||
final hasMessage = value.text.trim().isNotEmpty;
|
||||
return Container(
|
||||
padding: EdgeInsets.zero,
|
||||
child: MessageComposer(
|
||||
mode: _isHoldToSpeakMode
|
||||
? MessageComposerMode.holdToSpeak
|
||||
: MessageComposerMode.text,
|
||||
process: _composerProcess,
|
||||
hasMessage: hasMessage,
|
||||
isWaitingAgent: isWaitingAgent,
|
||||
iconSize: _iconSize,
|
||||
composerMinHeight: _inputMinHeight,
|
||||
onTapPlus: _isRecording
|
||||
? () => _stopRecording(autoSendAfterTranscribe: false)
|
||||
: () => _showBottomSheet(context),
|
||||
onTapRightAction: () => _onRightActionTap(context, state),
|
||||
onHoldToSpeakStart: _onHoldToSpeakStart,
|
||||
onHoldToSpeakEnd: _onHoldToSpeakEnd,
|
||||
onHoldToSpeakMoveUpdate: _onHoldToSpeakMoveUpdate,
|
||||
onHoldToSpeakCancel: _onHoldToSpeakCancel,
|
||||
textInputChild: _buildTextInputContent(context),
|
||||
recordingAnimation: const SizedBox.shrink(),
|
||||
recordingText: _isCancelGestureActive ? '松手取消' : '松手发送',
|
||||
recordingHintText: _isCancelGestureActive ? '松开取消' : '松开发送,上滑取消',
|
||||
showRecordingInlineFeedback: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -690,7 +661,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
}
|
||||
|
||||
void _onRightActionTap(BuildContext context, ChatState state) {
|
||||
if (_isTranscribing || _isRecording) {
|
||||
if (_isTranscribing || _isRecording || _isSendingMessage) {
|
||||
return;
|
||||
}
|
||||
final isWaitingAgent =
|
||||
@@ -699,7 +670,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
_onStopGenerating();
|
||||
return;
|
||||
}
|
||||
if (_hasMessage) {
|
||||
if (_messageController.text.trim().isNotEmpty) {
|
||||
_sendMessage(context);
|
||||
return;
|
||||
}
|
||||
@@ -764,6 +735,10 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
}
|
||||
|
||||
Future<void> _sendMessage(BuildContext context) async {
|
||||
if (_isSendingMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
final content = _messageController.text.trim();
|
||||
if (content.isEmpty && _selectedImages.isEmpty) return;
|
||||
|
||||
@@ -772,10 +747,19 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
FocusScope.of(context).unfocus();
|
||||
_messageController.clear();
|
||||
setState(() {
|
||||
_isSendingMessage = true;
|
||||
_selectedImages.clear();
|
||||
});
|
||||
|
||||
await context.read<ChatBloc>().sendMessage(content, images: images);
|
||||
try {
|
||||
await context.read<ChatBloc>().sendMessage(content, images: images);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSendingMessage = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
|
||||
@@ -3,11 +3,14 @@ import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/di/injection.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/app_loading_indicator.dart';
|
||||
import '../../../../shared/widgets/page_header.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
import '../../../calendar/data/calendar_api.dart';
|
||||
import '../../../calendar/data/models/schedule_item_model.dart';
|
||||
import '../../../friends/data/friends_api.dart';
|
||||
import '../../../users/data/models/user_response.dart';
|
||||
import '../../../users/data/users_api.dart';
|
||||
import '../../data/inbox_api.dart';
|
||||
import '../../ui/widgets/message_action_sheet.dart';
|
||||
@@ -49,15 +52,50 @@ class _MessageInviteListScreenState extends State<MessageInviteListScreen> {
|
||||
}
|
||||
|
||||
Future<void> _loadMessages() async {
|
||||
if (_isLoading) {
|
||||
return;
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = true);
|
||||
}
|
||||
try {
|
||||
final unreadRaw = await _inboxApi.getMessages(isRead: false);
|
||||
final readRaw = await _inboxApi.getMessages(isRead: true);
|
||||
final results = await Future.wait([
|
||||
_inboxApi.getMessages(isRead: false),
|
||||
_inboxApi.getMessages(isRead: true),
|
||||
]);
|
||||
final unreadRaw = results[0];
|
||||
final readRaw = results[1];
|
||||
|
||||
final unread = await _enrichWithFriendDetails(unreadRaw);
|
||||
final read = await _enrichWithFriendDetails(readRaw);
|
||||
final allMessages = [...unreadRaw, ...readRaw];
|
||||
final friendshipIds = allMessages
|
||||
.where(
|
||||
(m) =>
|
||||
m.messageType == InboxMessageType.friendRequest &&
|
||||
m.friendshipId != null,
|
||||
)
|
||||
.map((m) => m.friendshipId!)
|
||||
.toSet()
|
||||
.toList();
|
||||
|
||||
final requestMap = <String, FriendRequestResponse?>{};
|
||||
if (friendshipIds.isNotEmpty) {
|
||||
final fetched = await Future.wait(
|
||||
friendshipIds.map((id) async {
|
||||
try {
|
||||
final req = await _friendsApi.getRequestById(id);
|
||||
return (id, req as FriendRequestResponse?);
|
||||
} catch (_) {
|
||||
return (id, null as FriendRequestResponse?);
|
||||
}
|
||||
}),
|
||||
);
|
||||
for (final pair in fetched) {
|
||||
requestMap[pair.$1] = pair.$2;
|
||||
}
|
||||
}
|
||||
|
||||
final unread = _mapMessagesWithFriend(unreadRaw, requestMap);
|
||||
final read = _mapMessagesWithFriend(readRaw, requestMap);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
@@ -72,36 +110,16 @@ class _MessageInviteListScreenState extends State<MessageInviteListScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<MessageWithFriend>> _enrichWithFriendDetails(
|
||||
List<MessageWithFriend> _mapMessagesWithFriend(
|
||||
List<InboxMessageResponse> messages,
|
||||
) async {
|
||||
final futures = messages.map(_fetchFriendRequest);
|
||||
final results = await Future.wait(futures);
|
||||
|
||||
final enriched = <MessageWithFriend>[];
|
||||
for (int i = 0; i < messages.length; i++) {
|
||||
final message = messages[i];
|
||||
final friendRequest = results[i];
|
||||
|
||||
enriched.add(
|
||||
MessageWithFriend(message: message, friendRequest: friendRequest),
|
||||
);
|
||||
}
|
||||
return enriched;
|
||||
}
|
||||
|
||||
Future<FriendRequestResponse?> _fetchFriendRequest(
|
||||
InboxMessageResponse message,
|
||||
) async {
|
||||
if (message.messageType != InboxMessageType.friendRequest ||
|
||||
message.friendshipId == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return await _friendsApi.getRequestById(message.friendshipId!);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
Map<String, FriendRequestResponse?> requestMap,
|
||||
) {
|
||||
return messages.map((message) {
|
||||
final friendRequest = message.friendshipId == null
|
||||
? null
|
||||
: requestMap[message.friendshipId!];
|
||||
return MessageWithFriend(message: message, friendRequest: friendRequest);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Future<void> _handleMessageTap(MessageWithFriend item) async {
|
||||
@@ -148,8 +166,12 @@ class _MessageInviteListScreenState extends State<MessageInviteListScreen> {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final calendar = await _calendarApi.getById(message.scheduleItemId!);
|
||||
final sender = await _usersApi.getById(message.senderId!);
|
||||
final result = await Future.wait([
|
||||
_calendarApi.getById(message.scheduleItemId!),
|
||||
_usersApi.getById(message.senderId!),
|
||||
]);
|
||||
final calendar = result[0] as ScheduleItemModel;
|
||||
final sender = result[1] as UserResponse;
|
||||
return (calendar.title, sender.username);
|
||||
} catch (e) {
|
||||
return null;
|
||||
@@ -320,8 +342,11 @@ class _MessageInviteListScreenState extends State<MessageInviteListScreen> {
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(
|
||||
child: AppLoadingIndicator(
|
||||
size: 22,
|
||||
color: AppColors.blue500,
|
||||
trackColor: AppColors.blue100,
|
||||
withContainer: false,
|
||||
),
|
||||
)
|
||||
: _activeTabIndex == 0
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../../../../core/di/injection.dart';
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/app_loading_indicator.dart';
|
||||
import '../../../../shared/widgets/page_header.dart' as widgets;
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
@@ -125,7 +126,7 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
|
||||
_buildHeader(),
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
? const Center(child: AppLoadingIndicator(size: 22))
|
||||
: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../../../core/di/injection.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/app_loading_indicator.dart';
|
||||
import '../../../../shared/widgets/page_header.dart' as widgets;
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
@@ -130,7 +131,7 @@ class _TodoDetailScreenState extends State<TodoDetailScreen> {
|
||||
|
||||
Widget _buildContent() {
|
||||
if (_isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
return const Center(child: AppLoadingIndicator(size: 22));
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
@@ -428,6 +429,7 @@ class _EditTodoSheetState extends State<_EditTodoSheet> {
|
||||
late TextEditingController _descriptionController;
|
||||
late int _priority;
|
||||
late Set<String> _selectedScheduleItems;
|
||||
late final Future<List<_ScheduleItemSimple>> _scheduleItemsFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -438,6 +440,7 @@ class _EditTodoSheetState extends State<_EditTodoSheet> {
|
||||
);
|
||||
_priority = widget.todo.priority;
|
||||
_selectedScheduleItems = widget.todo.scheduleItems.map((e) => e.id).toSet();
|
||||
_scheduleItemsFuture = _loadScheduleItems();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -534,11 +537,11 @@ class _EditTodoSheetState extends State<_EditTodoSheet> {
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: FutureBuilder(
|
||||
future: _loadScheduleItems(),
|
||||
child: FutureBuilder<List<_ScheduleItemSimple>>(
|
||||
future: _scheduleItemsFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
return const Center(child: AppLoadingIndicator(size: 22));
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return Center(child: Text('加载失败: ${snapshot.error}'));
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../../../core/di/injection.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/app_loading_indicator.dart';
|
||||
import '../../../../shared/widgets/app_pressable.dart';
|
||||
import '../../../../shared/widgets/app_sheet_input_field.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
import '../../../calendar/data/calendar_api.dart';
|
||||
@@ -22,6 +26,7 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
|
||||
|
||||
List<TodoResponse> _todos = [];
|
||||
bool _isLoading = true;
|
||||
bool _loadingTodosRequest = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
@@ -31,6 +36,11 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
|
||||
}
|
||||
|
||||
Future<void> _loadTodos() async {
|
||||
if (_loadingTodosRequest) {
|
||||
return;
|
||||
}
|
||||
_loadingTodosRequest = true;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
@@ -38,15 +48,23 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
|
||||
|
||||
try {
|
||||
final todos = await _todoApi.getTodos(status: 'pending');
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_todos = todos;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
} finally {
|
||||
_loadingTodosRequest = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,11 +128,6 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.todoBg,
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _addTodo,
|
||||
backgroundColor: AppColors.blue600,
|
||||
child: const Icon(Icons.add, color: Colors.white),
|
||||
),
|
||||
body: PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
@@ -142,6 +155,7 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
|
||||
padding: const EdgeInsets.only(left: 16, right: 16, top: 14, bottom: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'待办事项',
|
||||
@@ -152,9 +166,54 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
|
||||
color: AppColors.slate900,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _loadTodos,
|
||||
icon: const Icon(Icons.refresh, color: AppColors.slate600),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
AppPressable(
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
onTap: _loadTodos,
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.messageBtnWrap,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
border: Border.all(color: AppColors.messageBtnBorder),
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.refreshCcw,
|
||||
size: 18,
|
||||
color: AppColors.slate600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
AppPressable(
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
onTap: _addTodo,
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blue600,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.blue300.withValues(alpha: 0.28),
|
||||
blurRadius: AppRadius.lg,
|
||||
offset: const Offset(0, AppSpacing.xs),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.plus,
|
||||
size: 18,
|
||||
color: AppColors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -164,7 +223,7 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
|
||||
|
||||
Widget _buildContent() {
|
||||
if (_isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
return const Center(child: AppLoadingIndicator(size: 22));
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
@@ -438,6 +497,13 @@ class _AddTodoSheetState extends State<_AddTodoSheet> {
|
||||
final _descriptionController = TextEditingController();
|
||||
int _priority = 1;
|
||||
final Set<String> _selectedScheduleItems = {};
|
||||
late final Future<List<_ScheduleItemSimple>> _scheduleItemsFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scheduleItemsFuture = _loadScheduleItems();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -448,165 +514,258 @@ class _AddTodoSheetState extends State<_AddTodoSheet> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: MediaQuery.of(context).size.height * 0.85,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'添加待办',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
TextField(
|
||||
controller: _titleController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '标题',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
autofocus: true,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _descriptionController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '描述(可选)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 2,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'优先级',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
_PriorityChip(
|
||||
label: '重要紧急',
|
||||
selected: _priority == 1,
|
||||
color: AppColors.g1Border,
|
||||
onTap: () => setState(() => _priority = 1),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_PriorityChip(
|
||||
label: '紧急不重要',
|
||||
selected: _priority == 3,
|
||||
color: AppColors.g2Border,
|
||||
onTap: () => setState(() => _priority = 3),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_PriorityChip(
|
||||
label: '重要不紧急',
|
||||
selected: _priority == 2,
|
||||
color: AppColors.g3Border,
|
||||
onTap: () => setState(() => _priority = 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
||||
|
||||
return AnimatedPadding(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeOut,
|
||||
padding: EdgeInsets.only(bottom: bottomInset),
|
||||
child: Container(
|
||||
height: MediaQuery.of(context).size.height * 0.85,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(AppRadius.xxl),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'关联日历事件',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Center(
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.slate200,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: FutureBuilder(
|
||||
future: _loadScheduleItems(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return Center(child: Text('加载失败: ${snapshot.error}'));
|
||||
}
|
||||
final items = snapshot.data ?? [];
|
||||
if (items.isEmpty) {
|
||||
return const Center(child: Text('暂无日历事件'));
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
final isSelected = _selectedScheduleItems.contains(item.id);
|
||||
return CheckboxListTile(
|
||||
title: Text(item.title),
|
||||
subtitle: Text(_formatDate(item.startAt)),
|
||||
value: isSelected,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
if (value == true) {
|
||||
_selectedScheduleItems.add(item.id);
|
||||
} else {
|
||||
_selectedScheduleItems.remove(item.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: AppButton(
|
||||
text: '添加',
|
||||
onPressed: () {
|
||||
if (_titleController.text.trim().isEmpty) {
|
||||
Toast.show(context, '请输入标题', type: ToastType.warning);
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop({
|
||||
'title': _titleController.text.trim(),
|
||||
'description': _descriptionController.text.trim().isEmpty
|
||||
? null
|
||||
: _descriptionController.text.trim(),
|
||||
'priority': _priority,
|
||||
'schedule_item_ids': _selectedScheduleItems.toList(),
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: AppSpacing.xl),
|
||||
child: Text(
|
||||
'添加待办',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.slate900,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
keyboardDismissBehavior:
|
||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xl),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildInputField(
|
||||
controller: _titleController,
|
||||
label: '标题',
|
||||
hint: '输入待办标题',
|
||||
autofocus: true,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_buildInputField(
|
||||
controller: _descriptionController,
|
||||
label: '描述(可选)',
|
||||
hint: '补充细节或备注',
|
||||
maxLines: 2,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
const Text(
|
||||
'优先级',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.slate700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Wrap(
|
||||
spacing: AppSpacing.sm,
|
||||
runSpacing: AppSpacing.sm,
|
||||
children: [
|
||||
_PriorityChip(
|
||||
label: '重要紧急',
|
||||
selected: _priority == 1,
|
||||
color: AppColors.g1Border,
|
||||
onTap: () => setState(() => _priority = 1),
|
||||
),
|
||||
_PriorityChip(
|
||||
label: '紧急不重要',
|
||||
selected: _priority == 3,
|
||||
color: AppColors.g2Border,
|
||||
onTap: () => setState(() => _priority = 3),
|
||||
),
|
||||
_PriorityChip(
|
||||
label: '重要不紧急',
|
||||
selected: _priority == 2,
|
||||
color: AppColors.g3Border,
|
||||
onTap: () => setState(() => _priority = 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
const Text(
|
||||
'关联日历事件',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.slate700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxHeight: 260),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.slate50,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(color: AppColors.borderSecondary),
|
||||
),
|
||||
child: FutureBuilder<List<_ScheduleItemSimple>>(
|
||||
future: _scheduleItemsFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState ==
|
||||
ConnectionState.waiting) {
|
||||
return _buildScheduleSkeleton();
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
child: Text(
|
||||
'加载失败: ${snapshot.error}',
|
||||
style: const TextStyle(color: AppColors.red500),
|
||||
),
|
||||
);
|
||||
}
|
||||
final items = snapshot.data ?? const [];
|
||||
if (items.isEmpty) {
|
||||
return const SizedBox(
|
||||
height: 120,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'暂无日历事件',
|
||||
style: TextStyle(color: AppColors.slate500),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.sm,
|
||||
vertical: AppSpacing.xs,
|
||||
),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
final isSelected = _selectedScheduleItems
|
||||
.contains(item.id);
|
||||
return CheckboxListTile(
|
||||
dense: true,
|
||||
value: isSelected,
|
||||
title: Text(item.title),
|
||||
subtitle: Text(_formatDate(item.startAt)),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
if (value == true) {
|
||||
_selectedScheduleItems.add(item.id);
|
||||
} else {
|
||||
_selectedScheduleItems.remove(item.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.lg,
|
||||
AppSpacing.sm,
|
||||
AppSpacing.lg,
|
||||
AppSpacing.lg,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: AppButton(
|
||||
text: '添加',
|
||||
onPressed: () {
|
||||
if (_titleController.text.trim().isEmpty) {
|
||||
Toast.show(context, '请输入标题', type: ToastType.warning);
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop({
|
||||
'title': _titleController.text.trim(),
|
||||
'description': _descriptionController.text.trim().isEmpty
|
||||
? null
|
||||
: _descriptionController.text.trim(),
|
||||
'priority': _priority,
|
||||
'schedule_item_ids': _selectedScheduleItems.toList(),
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInputField({
|
||||
required TextEditingController controller,
|
||||
required String label,
|
||||
required String hint,
|
||||
int maxLines = 1,
|
||||
bool autofocus = false,
|
||||
}) {
|
||||
return AppSheetInputField(
|
||||
controller: controller,
|
||||
label: label,
|
||||
hint: hint,
|
||||
maxLines: maxLines,
|
||||
autofocus: autofocus,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScheduleSkeleton() {
|
||||
return ListView.separated(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.md,
|
||||
),
|
||||
itemCount: 4,
|
||||
separatorBuilder: (context, index) =>
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
itemBuilder: (context, index) {
|
||||
return Container(
|
||||
height: AppSpacing.xxl + AppSpacing.lg,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
border: Border.all(color: AppColors.borderSecondary),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<_ScheduleItemSimple>> _loadScheduleItems() async {
|
||||
final calendarApi = sl<CalendarApi>();
|
||||
final now = DateTime.now();
|
||||
|
||||
Reference in New Issue
Block a user