From 4b9277253523e1be4e24e3f1824856e982675314 Mon Sep 17 00:00:00 2001 From: qzl Date: Mon, 16 Mar 2026 16:11:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E5=89=8D=E7=AB=AF=20?= =?UTF-8?q?UI=20=E7=BB=84=E4=BB=B6=E4=B8=8E=E4=BA=A4=E4=BA=92=E4=BD=93?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 优化日历、待办、消息等页面交互 - 更新 ChatBloc 与 UI Schema 渲染 - 优化联系人、首页、设置页面体验 --- apps/AGENTS.md | 10 + apps/lib/core/config/env.dart | 2 +- .../ui/screens/calendar_dayweek_screen.dart | 170 +++-- .../screens/calendar_event_detail_screen.dart | 3 +- .../ui/screens/calendar_month_screen.dart | 204 +++--- .../calendar/ui/widgets/bottom_dock.dart | 88 ++- .../ui/widgets/create_event_sheet.dart | 406 ++--------- .../chat/data/models/ag_ui_event.dart | 530 ++++++--------- .../chat/data/models/chat_list_item.dart | 6 +- .../chat/data/services/ag_ui_service.dart | 103 +-- .../chat/presentation/bloc/chat_bloc.dart | 442 +++++------- .../chat/ui/widgets/ui_schema_renderer.dart | 641 ++++++++++-------- .../contacts/ui/screens/contacts_screen.dart | 26 +- .../features/home/ui/screens/home_screen.dart | 150 ++-- .../screens/message_invite_list_screen.dart | 97 ++- .../ui/screens/edit_profile_screen.dart | 3 +- .../todo/ui/screens/todo_detail_screen.dart | 11 +- .../ui/screens/todo_quadrants_screen.dart | 479 ++++++++----- 18 files changed, 1591 insertions(+), 1780 deletions(-) diff --git a/apps/AGENTS.md b/apps/AGENTS.md index 90ff25e..992bdd7 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -62,6 +62,16 @@ Follow lightweight testing strategy - prioritize value over coverage: - **MUST NOT** create custom SnackBar/Dialog/Banner feedback components. - **MUST NOT** use raw `ScaffoldMessenger` for feedback messaging. +## 6.1) Loading Indicator System (MUST) + +- All loading spinners **MUST** use `AppLoadingIndicator` from `apps/lib/shared/widgets/app_loading_indicator.dart`. +- **MUST NOT** use raw `CircularProgressIndicator` directly in feature/page code. +- Use variants consistently: + - page/surface loading: `AppLoadingVariant.surface` + - inline small loading (list/search/section): `AppLoadingVariant.inline` + - button loading: `AppLoadingVariant.button` +- If visual semantics are missing, extend `AppLoadingIndicator` variant mapping first; do not create ad-hoc loading styles in feature files. + ## 7) Agent Chat (AG-UI Protocol) (MUST) Agent chat functionality **MUST** follow the AG-UI protocol. **Use the `ag-ui` skill** for protocol reference and implementation guidance. diff --git a/apps/lib/core/config/env.dart b/apps/lib/core/config/env.dart index 54d6a58..d9e4ae9 100644 --- a/apps/lib/core/config/env.dart +++ b/apps/lib/core/config/env.dart @@ -7,7 +7,7 @@ class Env { return backendUrl; } if (Platform.isAndroid) { - return 'http://192.168.1.25:5775'; + return 'http://10.0.2.2:5775'; } return 'http://localhost:5775'; } diff --git a/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart index 060f0ac..3466e23 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart @@ -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 late DateTime _selectedDate; late List _monthDates; final ScrollController _dayStripController = ScrollController(); - Key _eventsKey = UniqueKey(); List _events = const []; @override @@ -135,10 +135,7 @@ class _CalendarDayWeekScreenState extends State right: AppSpacing.lg, top: 2, ), - child: KeyedSubtree( - key: _eventsKey, - child: _buildTimelineBoard(), - ), + child: RepaintBoundary(child: _buildTimelineBoard()), ), ), ), @@ -223,13 +220,17 @@ class _CalendarDayWeekScreenState extends State } 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 ), child: Row( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, children: [ const Icon( LucideIcons.chevronLeft, @@ -248,12 +250,30 @@ class _CalendarDayWeekScreenState extends State 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( + 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 ), 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 ), 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 } 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 final dayNames = ['日', '一', '二', '三', '四', '五', '六']; return Column( + crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ Text( @@ -399,14 +427,26 @@ class _CalendarDayWeekScreenState extends State ), ), 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 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 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 width: 6, height: 6, decoration: BoxDecoration( - color: _parseColor(event.metadata?.color), + color: eventColor, shape: BoxShape.circle, ), ), @@ -542,7 +576,7 @@ class _CalendarDayWeekScreenState extends State style: TextStyle( fontSize: 11, fontWeight: FontWeight.w500, - color: _parseColor(event.metadata?.color), + color: eventColor, ), maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart index 4b886c5..334756a 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart @@ -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 { 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) { diff --git a/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart index 9a11452..5de4cbc 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart @@ -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 late final CalendarStateManager _calendarManager; late DateTime _currentMonth; late DateTime _selectedDate; - Key _eventsKey = UniqueKey(); final Map> _eventsByDay = {}; @override @@ -136,17 +136,26 @@ class _CalendarMonthScreenState extends State 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 ), ), 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 ); 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 }), ), const SizedBox(height: 10), - KeyedSubtree( - key: _eventsKey, - child: _buildWeekEvents(weekStart, startWeekday, daysInMonth), - ), + _buildWeekEvents(weekStart, startWeekday, daysInMonth), ], ), ); @@ -357,7 +361,8 @@ class _CalendarMonthScreenState extends State 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 ); }), 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 } 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}月')); + }), + ), + ), + ], ), ), ], ), - ), - ], - ), - ), + ); + }, + ); + }, ); } diff --git a/apps/lib/features/calendar/ui/widgets/bottom_dock.dart b/apps/lib/features/calendar/ui/widgets/bottom_dock.dart index 2503e09..ff7d301 100644 --- a/apps/lib/features/calendar/ui/widgets/bottom_dock.dart +++ b/apps/lib/features/calendar/ui/widgets/bottom_dock.dart @@ -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, + ), ), ), ); diff --git a/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart b/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart index b9a84c7..44d753b 100644 --- a/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart +++ b/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart @@ -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 String _selectedColor = '#3B82F6'; int? _reminderMinutes = 15; bool _saving = false; - List _attachments = const []; bool get _isEditing => widget.editingEvent != null; @@ -87,9 +90,6 @@ class _CreateEventSheetState extends State _endTime = event.endAt; _selectedColor = event.metadata?.color ?? '#3B82F6'; _reminderMinutes = event.metadata?.reminderMinutes ?? 15; - _attachments = List.from( - event.metadata?.attachments ?? const [], - ); } else { final now = widget.initialDate ?? _roundToNearestMinute(DateTime.now(), 5); @@ -122,18 +122,38 @@ class _CreateEventSheetState extends State @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 ValueListenableBuilder( 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 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 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 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 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.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 _showAddAttachmentDialog() async { - final nameController = TextEditingController(); - final urlController = TextEditingController(); - final noteController = TextEditingController(); - final contentController = TextEditingController(); - var type = 'document'; - try { - final created = await showModalBottomSheet( - 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 ? _notesController.text.trim() : null, reminderMinutes: _reminderMinutes, - attachments: _attachments, + attachments: const [], version: widget.editingEvent?.metadata?.version ?? 1, ); @@ -912,9 +646,7 @@ class _CreateEventSheetState extends State } } catch (e) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('保存失败: $e'))); + Toast.show(context, '保存失败: $e', type: ToastType.error); } } finally { if (mounted) { diff --git a/apps/lib/features/chat/data/models/ag_ui_event.dart b/apps/lib/features/chat/data/models/ag_ui_event.dart index dcf0aca..1d245d9 100644 --- a/apps/lib/features/chat/data/models/ag_ui_event.dart +++ b/apps/lib/features/chat/data/models/ag_ui_event.dart @@ -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 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 toJson() => _$AgUiEventToJson(this); } -@JsonSerializable(createFactory: false, createToJson: false) class UnknownAgUiEvent extends AgUiEvent { - final Map rawJson; - - UnknownAgUiEvent({required this.rawJson}) + const UnknownAgUiEvent({required this.rawJson}) : super(type: AgUiEventType.unknown); - factory UnknownAgUiEvent.fromJson(Map json) => - UnknownAgUiEvent(rawJson: json); - - @override - Map toJson() => rawJson; + final Map 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 json) => - _$RunStartedEventFromJson(json); - - @override - Map toJson() => _$RunStartedEventToJson(this); -} - -@JsonSerializable() -class RunFinishedEvent extends AgUiEvent { final String threadId; final String runId; + factory RunStartedEvent.fromJson(Map 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 json) => - _$RunFinishedEventFromJson(json); + final String threadId; + final String runId; - @override - Map toJson() => _$RunFinishedEventToJson(this); + factory RunFinishedEvent.fromJson(Map 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 json) => - _$RunErrorEventFromJson(json); + final String message; + final String? code; - @override - Map toJson() => _$RunErrorEventToJson(this); + factory RunErrorEvent.fromJson(Map 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 json) => - _$StepStartedEventFromJson(json); - - @override - Map toJson() => _$StepStartedEventToJson(this); -} - -@JsonSerializable() -class StepFinishedEvent extends AgUiEvent { final String stepName; + factory StepStartedEvent.fromJson(Map 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 json) => - _$StepFinishedEventFromJson(json); - - @override - Map 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 json) => - _$TextMessageStartEventFromJson(json); - - @override - Map 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 json) => - _$TextMessageContentEventFromJson(json); - - @override - Map 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? uiSchema; factory TextMessageEndEvent.fromJson(Map json) => - _$TextMessageEndEventFromJson(json); - - @override - Map 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 json) => - _$ToolCallStartEventFromJson(json); - - @override - Map 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 json) => - _$ToolCallArgsEventFromJson(json); + final String toolCallId; + final Map args; - @override - Map toJson() => _$ToolCallArgsEventToJson(this); + factory ToolCallArgsEvent.fromJson(Map 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 json) => - _$ToolCallEndEventFromJson(json); + final String toolCallId; - @override - Map toJson() => _$ToolCallEndEventToJson(this); + factory ToolCallEndEvent.fromJson(Map 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 get payload { - try { - final decoded = jsonDecode(content); - if (decoded is Map) { - return decoded; - } - } catch (_) {} - return {'content': content}; - } + final String messageId; + final String toolCallId; + final String toolName; + final String resultSummary; + final String status; + final Map? uiSchema; - Map get result { - final rawResult = payload['result']; - if (rawResult is Map) { - return rawResult; - } - return payload; - } - - UiCard? get ui { - final rawUi = payload['ui']; - if (rawUi is Map) { - return UiCard.fromJson(rawUi); - } - final rawResult = payload['result']; - if (rawResult is Map) { - final type = rawResult['type']; - final data = rawResult['data']; - if (type is String && data is Map) { - return UiCard.fromJson(rawResult); - } - } - return null; - } - - factory ToolCallResultEvent.fromJson(Map 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 toJson() => { - 'type': agUiEventTypeToWire(type), - 'messageId': messageId, - 'toolCallId': toolCallId, - 'content': content, - }; + factory ToolCallResultEvent.fromJson(Map 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 json) => - _$ToolCallErrorEventFromJson(json); - - @override - Map 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 snapshot; - - StateSnapshotEvent({required this.snapshot}) - : super(type: AgUiEventType.stateSnapshot); - - factory StateSnapshotEvent.fromJson(Map json) { - final rawSnapshot = json['snapshot']; - return StateSnapshotEvent( - snapshot: rawSnapshot is Map - ? rawSnapshot - : {}, - ); - } - - @override - Map toJson() => { - 'type': agUiEventTypeToWire(type), - 'snapshot': snapshot, - }; -} - -@JsonSerializable() -class MessagesSnapshotEvent extends AgUiEvent { - final List messages; - - MessagesSnapshotEvent({required this.messages}) - : super(type: AgUiEventType.messagesSnapshot); - - factory MessagesSnapshotEvent.fromJson(Map json) => - _$MessagesSnapshotEventFromJson(json); - - @override - Map 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>? 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 json) => - _$SnapshotMessageFromJson(json); + final String scope; + final String? threadId; + final String? day; + final bool hasMore; + final List messages; - Map toJson() => _$SnapshotMessageToJson(this); + factory HistorySnapshot.fromJson(Map json) { + final rawMessages = json['messages']; + final messages = rawMessages is List + ? rawMessages + .whereType>() + .map(HistoryMessage.fromJson) + .toList() + : const []; + 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? uiSchema; + + factory HistoryMessage.fromJson(Map 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? _asMap(Object? value) { + if (value is Map) { + return value; + } + if (value is Map) { + final result = {}; + for (final entry in value.entries) { + final key = entry.key; + if (key is String) { + result[key] = entry.value; + } + } + return result; + } + return null; } diff --git a/apps/lib/features/chat/data/models/chat_list_item.dart b/apps/lib/features/chat/data/models/chat_list_item.dart index efc70c9..189a0b1 100644 --- a/apps/lib/features/chat/data/models/chat_list_item.dart +++ b/apps/lib/features/chat/data/models/chat_list_item.dart @@ -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 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, }); diff --git a/apps/lib/features/chat/data/services/ag_ui_service.dart b/apps/lib/features/chat/data/services/ag_ui_service.dart index 7eb14e6..8a78a4d 100644 --- a/apps/lib/features/chat/data/services/ag_ui_service.dart +++ b/apps/lib/features/chat/data/services/ag_ui_service.dart @@ -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 _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 sendMessage(String content, {List? images}) async { final streamToken = ++_activeStreamToken; @@ -51,23 +45,19 @@ class AgUiService { await _streamEventsFromApi(threadId, streamToken: streamToken); } - Future loadHistory({DateTime? beforeDate}) async { + Future loadHistory({DateTime? beforeDate}) async { final path = _buildHistoryPath(beforeDate: beforeDate); final response = await _apiClient.get>(path); final payload = response.data; if (payload is! Map) { 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 fetchAttachmentPreview(String previewPath) async { @@ -105,60 +95,6 @@ class AgUiService { return transcript; } - Future approveToolCall({ - required String toolCallId, - required String toolName, - required Map 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': {}, - 'messages': [ - { - 'id': _nextId('tool_'), - 'role': 'tool', - 'toolCallId': toolCallId, - 'content': jsonEncode({ - 'toolName': toolName, - 'toolArgs': args, - 'nonce': nonce, - 'result': localResult, - }), - }, - ], - 'tools': _buildTools(), - 'context': >[], - 'forwardedProps': {}, - }; - final response = await _apiClient.post>( - '/api/v1/agent/runs/$threadId/resume', - data: runInput, - ); - final payload = response.data; - if (payload is Map) { - 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) { 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': >[], 'context': >[], 'forwardedProps': {}, }; @@ -343,26 +276,6 @@ class AgUiService { return attachments; } - List> _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 = []; if (_threadId != null && _threadId!.isNotEmpty) { diff --git a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart index 9d88f25..1b5a317 100644 --- a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart +++ b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart @@ -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 { - final AgUiService _service; - final Map _toolCallArgsBuffer = {}; - final Map _attachmentPreviewCache = {}; - final Map> _attachmentPreviewInflight = - >{}; - ChatBloc({AgUiService? service, required IApiClient apiClient}) : _service = service ?? AgUiService(apiClient: apiClient), super(const ChatState()) { _service.onEvent = _handleEvent; } + final AgUiService _service; + final Map _attachmentPreviewCache = {}; + final Map> _attachmentPreviewInflight = + >{}; + void _handleEvent(AgUiEvent event) { switch (event.type) { case AgUiEventType.runStarted: @@ -136,10 +133,6 @@ class ChatBloc extends Cubit { _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 { _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 { } } - 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.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.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 parsedArgs = {}; - if (argsBuffer.isNotEmpty) { - try { - parsedArgs = jsonDecode(argsBuffer) as Map; - } 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 _convertHistoryMessages(List messages) { + final converted = []; + for (final msg in messages) { + final sender = msg.role == 'user' ? MessageSender.user : MessageSender.ai; + final attachments = >[]; + 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) { - _handleMessagesSnapshot(MessagesSnapshotEvent(messages: const [])); - return; - } - final parsed = []; - for (final raw in rawMessages) { - if (raw is! Map) { - 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 _convertSnapshotMessages(List 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 items) { if (items.isEmpty) return null; - return items .map( (item) => DateTime( @@ -393,8 +348,8 @@ class ChatBloc extends Cubit { final attachments = (images ?? const []) .map( (image) => { - "path": image.path, - "mimeType": "image/*", + 'path': image.path, + 'mimeType': 'image/*', }, ) .toList(); @@ -434,7 +389,16 @@ class ChatBloc extends Cubit { 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 { 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 = { + 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 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 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 transcribeAudioFile(String filePath) { @@ -548,16 +481,17 @@ class ChatBloc extends Cubit { 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; } diff --git a/apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart b/apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart index 15e05bb..71abaa8 100644 --- a/apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart +++ b/apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart @@ -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? 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 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 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 node) { + final children = _asList( + node['children'], + ).whereType>().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 node) { + final children = _asList( + node['children'], + ).whereType>().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 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 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 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 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 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 - ? paginationRaw - : const {}; - 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) - Padding( - padding: EdgeInsets.only(bottom: AppSpacing.xs), + static Widget _renderKv(Map node) { + final items = _asList( + node['items'], + ).whereType>().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 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 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 _withGap(List widgets, double gap) { + if (widgets.isEmpty) { + return const []; } + final result = []; + 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? _asMap(Object? value) { + if (value is Map) { + return value; + } + if (value is Map) { + final result = {}; + for (final entry in value.entries) { + if (entry.key is String) { + result[entry.key as String] = entry.value; + } + } + return result; + } + return null; + } + + static List _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; } } diff --git a/apps/lib/features/contacts/ui/screens/contacts_screen.dart b/apps/lib/features/contacts/ui/screens/contacts_screen.dart index 170da2c..cd5a5ca 100644 --- a/apps/lib/features/contacts/ui/screens/contacts_screen.dart +++ b/apps/lib/features/contacts/ui/screens/contacts_screen.dart @@ -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 { 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 { 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 { 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 { ), ), 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( '发送', diff --git a/apps/lib/features/home/ui/screens/home_screen.dart b/apps/lib/features/home/ui/screens/home_screen.dart index 8598d8d..f67a82d 100644 --- a/apps/lib/features/home/ui/screens/home_screen.dart +++ b/apps/lib/features/home/ui/screens/home_screen.dart @@ -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 bool _isHoldToSpeakMode = false; bool _isTranscribing = false; bool _isCancelGestureActive = false; + bool _isSendingMessage = false; int _unreadCount = 0; final List _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(); @@ -124,7 +122,6 @@ class _HomeScreenState extends State @override void dispose() { - _messageController.removeListener(_onMessageChanged); _messageController.dispose(); _scrollController.dispose(); _listeningAnimationController.dispose(); @@ -132,27 +129,11 @@ class _HomeScreenState extends State 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( @@ -200,7 +181,9 @@ class _HomeScreenState extends State 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 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 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 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 ), 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 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( + 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 } void _onRightActionTap(BuildContext context, ChatState state) { - if (_isTranscribing || _isRecording) { + if (_isTranscribing || _isRecording || _isSendingMessage) { return; } final isWaitingAgent = @@ -699,7 +670,7 @@ class _HomeScreenState extends State _onStopGenerating(); return; } - if (_hasMessage) { + if (_messageController.text.trim().isNotEmpty) { _sendMessage(context); return; } @@ -764,6 +735,10 @@ class _HomeScreenState extends State } Future _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 FocusScope.of(context).unfocus(); _messageController.clear(); setState(() { + _isSendingMessage = true; _selectedImages.clear(); }); - await context.read().sendMessage(content, images: images); + try { + await context.read().sendMessage(content, images: images); + } finally { + if (mounted) { + setState(() { + _isSendingMessage = false; + }); + } + } WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { diff --git a/apps/lib/features/messages/ui/screens/message_invite_list_screen.dart b/apps/lib/features/messages/ui/screens/message_invite_list_screen.dart index 8de8cc9..48b4884 100644 --- a/apps/lib/features/messages/ui/screens/message_invite_list_screen.dart +++ b/apps/lib/features/messages/ui/screens/message_invite_list_screen.dart @@ -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 { } Future _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 = {}; + 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 { } } - Future> _enrichWithFriendDetails( + List _mapMessagesWithFriend( List messages, - ) async { - final futures = messages.map(_fetchFriendRequest); - final results = await Future.wait(futures); - - final enriched = []; - 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 _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 requestMap, + ) { + return messages.map((message) { + final friendRequest = message.friendshipId == null + ? null + : requestMap[message.friendshipId!]; + return MessageWithFriend(message: message, friendRequest: friendRequest); + }).toList(); } Future _handleMessageTap(MessageWithFriend item) async { @@ -148,8 +166,12 @@ class _MessageInviteListScreenState extends State { 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 { Expanded( child: _isLoading ? const Center( - child: CircularProgressIndicator( + child: AppLoadingIndicator( + size: 22, color: AppColors.blue500, + trackColor: AppColors.blue100, + withContainer: false, ), ) : _activeTabIndex == 0 diff --git a/apps/lib/features/settings/ui/screens/edit_profile_screen.dart b/apps/lib/features/settings/ui/screens/edit_profile_screen.dart index 0d74453..858aa5b 100644 --- a/apps/lib/features/settings/ui/screens/edit_profile_screen.dart +++ b/apps/lib/features/settings/ui/screens/edit_profile_screen.dart @@ -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 { _buildHeader(), Expanded( child: _isLoading - ? const Center(child: CircularProgressIndicator()) + ? const Center(child: AppLoadingIndicator(size: 22)) : SingleChildScrollView( padding: const EdgeInsets.all(20), child: Column( diff --git a/apps/lib/features/todo/ui/screens/todo_detail_screen.dart b/apps/lib/features/todo/ui/screens/todo_detail_screen.dart index e016fdd..55b8815 100644 --- a/apps/lib/features/todo/ui/screens/todo_detail_screen.dart +++ b/apps/lib/features/todo/ui/screens/todo_detail_screen.dart @@ -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 { 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 _selectedScheduleItems; + late final Future> _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>( + 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}')); diff --git a/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart b/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart index 75ab153..a02cb86 100644 --- a/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart +++ b/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart @@ -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 { List _todos = []; bool _isLoading = true; + bool _loadingTodosRequest = false; String? _error; @override @@ -31,6 +36,11 @@ class _TodoQuadrantsScreenState extends State { } Future _loadTodos() async { + if (_loadingTodosRequest) { + return; + } + _loadingTodosRequest = true; + setState(() { _isLoading = true; _error = null; @@ -38,15 +48,23 @@ class _TodoQuadrantsScreenState extends State { 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 { 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 { 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 { 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 { 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 _selectedScheduleItems = {}; + late final Future> _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>( + 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> _loadScheduleItems() async { final calendarApi = sl(); final now = DateTime.now();