import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:image_picker/image_picker.dart'; import 'package:miaoji_zhang/features/add/screenshot_parse_sheet.dart'; import 'package:miaoji_zhang/features/ai_mode/pages/ai_welcome_page.dart'; import 'package:miaoji_zhang/shared/api/api_client.dart'; import 'package:miaoji_zhang/shared/api/business_api.dart'; import 'package:miaoji_zhang/shared/api/config_api.dart'; import 'package:miaoji_zhang/shared/services/screenshot_channel.dart'; import 'package:miaoji_zhang/shared/services/session_store.dart'; import 'package:miaoji_zhang/shared/services/transaction_events.dart'; import 'package:miaoji_zhang/shared/theme/app_theme.dart'; import 'package:miaoji_zhang/shared/widgets/app_icons.dart'; import 'package:miaoji_zhang/shared/widgets/ai_access_gate.dart'; import 'package:miaoji_zhang/shared/widgets/app_controls.dart'; import 'package:miaoji_zhang/shared/widgets/async_error_view.dart'; import 'package:miaoji_zhang/shared/widgets/category_icon.dart'; import 'package:miaoji_zhang/shared/services/shanghai_time.dart'; const stickerStyle = { 'salary': (AppIcons.money, AppTheme.primary), 'forgive': (AppIcons.smile, AppTheme.orange), 'fire': (AppIcons.fire, AppTheme.red), 'empty_wallet': (AppIcons.wallet, AppTheme.text3), 'angry': (AppIcons.close, AppTheme.red), 'happy': (AppIcons.check, AppTheme.primary), 'treat': (AppIcons.gift, AppTheme.orange), 'shopping_joy': (AppIcons.cart, AppTheme.primaryDeep), }; const stickerLabels = { 'salary': '发工资啦', 'forgive': '求原谅', 'fire': '剁手警告', 'empty_wallet': '钱包空空', 'angry': '生气', 'happy': '开心', 'treat': '犒劳自己', 'shopping_joy': '购物狂喜', }; enum _ComposerPanel { none, stickers, voice } class ChatPageController { Future Function()? _clearContext; Future clearContext() => _clearContext?.call() ?? Future.value(); } class ChatPage extends StatefulWidget { final bool aiMode; final ChatPageController? controller; const ChatPage({super.key, this.aiMode = false, this.controller}); @override State createState() => _ChatPageState(); } class _ChatPageState extends State { final _msgs = []; final _ctrl = TextEditingController(); final _sc = ScrollController(); final _picker = ImagePicker(); bool _sending = false; bool _voiceHolding = false; bool _voiceProcessing = false; bool _voiceCancel = false; bool _stickersLoading = false; _ComposerPanel _composerPanel = _ComposerPanel.none; bool get _voiceMode => _composerPanel == _ComposerPanel.voice; bool get _stkOpen => _composerPanel == _ComposerPanel.stickers; bool _loaded = false; bool _userNearBottom = true; String? _loadError; String _voiceText = ''; double _voiceRms = 0; int _speechSession = 0; OverlayEntry? _voiceOverlay; final _inputFocus = FocusNode(); List _stk = []; static const _q = ['刚买了杯奶茶 18 块', '打车花了 45', '这个月花最多的是什么']; @override void initState() { super.initState(); widget.controller?._clearContext = clearContext; _ctrl.addListener(_onInputChanged); _sc.addListener(_trackScrollPosition); _inputFocus.addListener(_onInputFocusChanged); _hist(); _stkLoad(); } @override void dispose() { if (widget.controller?._clearContext == clearContext) { widget.controller?._clearContext = null; } _speechSession++; _voiceOverlay?.remove(); _ctrl.removeListener(_onInputChanged); _inputFocus.removeListener(_onInputFocusChanged); _ctrl.dispose(); _inputFocus.dispose(); _sc.removeListener(_trackScrollPosition); _sc.dispose(); super.dispose(); } @override void didUpdateWidget(covariant ChatPage oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.controller?._clearContext == clearContext) { oldWidget.controller?._clearContext = null; } widget.controller?._clearContext = clearContext; } void _trackScrollPosition() { if (!_sc.hasClients) return; _userNearBottom = _sc.position.maxScrollExtent - _sc.position.pixels <= 80; } void _onInputChanged() { if (mounted) setState(() {}); } void _onInputFocusChanged() { if (_inputFocus.hasFocus && _stkOpen && mounted) { setState(() => _composerPanel = _ComposerPanel.none); } } void _closeComposerPanel() { if (mounted) setState(() => _composerPanel = _ComposerPanel.none); } Future clearContext() async { if (_sending || _voiceHolding || _voiceProcessing) return; _inputFocus.unfocus(); _closeComposerPanel(); final confirmed = await showModalBottomSheet( context: context, useSafeArea: true, backgroundColor: Colors.transparent, builder: (sheetContext) => Material( color: context.jz.card, borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), clipBehavior: Clip.antiAlias, child: Padding( padding: const EdgeInsets.fromLTRB(20, 10, 20, 18), child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: 38, height: 4, decoration: BoxDecoration( color: context.jz.line, borderRadius: BorderRadius.circular(2), ), ), SizedBox(height: 22), Container( width: 52, height: 52, decoration: BoxDecoration( color: context.jz.aiBackground, shape: BoxShape.circle, ), child: Icon( Icons.restart_alt_rounded, color: AppTheme.ai, size: 27, ), ), SizedBox(height: 13), Text( '开始新会话?', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w800), ), SizedBox(height: 7), Text( 'AI 将不再使用当前聊天作为上下文,历史账单和聊天记录都不会被删除。', textAlign: TextAlign.center, style: TextStyle( fontSize: 12.5, height: 1.5, color: context.jz.text2, ), ), SizedBox(height: 22), Row( children: [ Expanded( child: Material( color: context.jz.background, borderRadius: BorderRadius.circular(13), child: InkWell( onTap: () => Navigator.pop(sheetContext, false), borderRadius: BorderRadius.circular(13), child: SizedBox( height: 46, child: Center( child: Text( '继续当前会话', style: TextStyle(fontWeight: FontWeight.w700), ), ), ), ), ), ), SizedBox(width: 10), Expanded( child: Material( color: AppTheme.ai, borderRadius: BorderRadius.circular(13), child: InkWell( onTap: () => Navigator.pop(sheetContext, true), borderRadius: BorderRadius.circular(13), child: SizedBox( height: 46, child: Center( child: Text( '开始新会话', style: TextStyle( color: Colors.white, fontWeight: FontWeight.w800, ), ), ), ), ), ), ), ], ), ], ), ), ), ); if (confirmed != true) return; try { await ChatApi.clearContext(); if (!mounted) return; setState(() => _msgs.clear()); ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('已清理上下文,开始新会话'))); } catch (e) { if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(apiErrorMessage(e)))); } } Future _hist() async { if (mounted) { setState(() { _loaded = false; _loadError = null; }); } try { final l = await ChatApi.history(); if (mounted) { setState(() { _msgs ..clear() ..addAll(l); _loaded = true; }); _scroll(initial: true); } } catch (error) { if (mounted) { setState(() { _loaded = true; _loadError = apiErrorMessage(error); }); } } } Future _stkLoad() async { if (_stickersLoading) return; _stickersLoading = true; try { final l = await StickerApi.list(); if (mounted) setState(() => _stk = l); } finally { _stickersLoading = false; } } Future _send([String? preset, String type = 'text']) async { final text = (preset ?? _ctrl.text).trim(); if (text.isEmpty || _sending) return; final now = ShanghaiTime.now; final userId = -now.millisecondsSinceEpoch; final userMsg = ChatMsg.fromJson({ 'id': userId, 'role': 'user', 'type': type, 'content': text, 'transaction': null, 'createdAt': now.toIso8601String(), }); final assistantId = userId - 1; final pendingAssistant = ChatMsg.fromJson({ 'id': assistantId, 'role': 'assistant', 'type': 'text', 'content': '', 'transaction': null, 'createdAt': now.toIso8601String(), }); setState(() { _ctrl.clear(); _sending = true; _composerPanel = _ComposerPanel.none; _msgs.add(userMsg); if (type == 'text') _msgs.add(pendingAssistant); }); _scroll(); if (type == 'text') { await ChatApi.sendStream( text, onToken: (fullText) { if (!mounted) return; setState(() { final idx = _msgs.indexWhere((m) => m.id == assistantId); if (idx >= 0) { _msgs[idx] = ChatMsg.fromJson({ 'id': assistantId, 'role': 'assistant', 'type': 'text', 'content': fullText, 'transaction': null, 'createdAt': ShanghaiTime.now.toIso8601String(), }); } }); _scroll(streaming: true); }, onDone: (msgs) { if (!mounted) return; if (msgs.any((message) => message.type == 'bill_card')) { TransactionEvents.notifyChanged(); } setState(() { _msgs.removeWhere((m) => m.id == assistantId); _msgs.addAll(msgs); _sending = false; }); _scroll(); }, onError: (err) { if (!mounted) return; setState(() { _msgs.removeWhere((m) => m.id == assistantId); _sending = false; }); ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('发送失败:$err'))); }, ); return; } try { final r = await ChatApi.send(text, type: type); if (!mounted) return; setState(() { _msgs.removeWhere((m) => m.id == userId); _msgs.addAll(r); _sending = false; }); if (r.any((message) => message.type == 'bill_card')) { TransactionEvents.notifyChanged(); } } catch (_) { if (mounted) { setState(() => _sending = false); ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('发送失败'))); } } _scroll(); } Future _undo(TxItem tx) async { try { await TxApi.delete(tx.id); TransactionEvents.notifyChanged(); if (mounted) { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('已撤销'))); setState(() {}); } } catch (_) {} } void _toggleVoiceMode() { if (SessionStore.instance.shouldUseLocalOnly) { _showVoiceError('聊天语音输入需要登录并连接网络'); return; } if (!PublicConfigApi.voiceEnabled) return; if (_voiceHolding || _voiceProcessing) return; final openingVoice = !_voiceMode; setState( () => _composerPanel = openingVoice ? _ComposerPanel.voice : _ComposerPanel.none, ); if (openingVoice) { _inputFocus.unfocus(); SystemChannels.textInput.invokeMethod('TextInput.hide'); } else { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _inputFocus.requestFocus(); }); } } Future _startVoiceHold() async { if (!_voiceMode || _voiceHolding || _voiceProcessing) return; final session = ++_speechSession; setState(() { _voiceHolding = true; _voiceProcessing = false; _voiceCancel = false; _voiceText = ''; _voiceRms = 0; }); _showVoiceOverlay(); try { await ScreenshotChannel.startSpeechRecognition(_handleSpeechEvent); } catch (e) { if (!mounted || session != _speechSession) return; _finishVoiceUi(); _showVoiceError(e.toString()); } } void _handleSpeechEvent(SpeechEvent event) { if (!mounted) return; if (event.text != null && event.text!.trim().isNotEmpty) { _voiceText = event.text!.trim(); } _voiceRms = event.rms; if (event.type == 'processing') { _voiceHolding = false; _voiceProcessing = true; } else if (event.type == 'final') { final recognized = _voiceText; _finishVoiceUi(); if (recognized.isNotEmpty) { _ctrl ..text = recognized ..selection = TextSelection.collapsed(offset: recognized.length); setState(() => _composerPanel = _ComposerPanel.none); _inputFocus.requestFocus(); } else { _showVoiceError('未识别到语音,请重试'); } return; } else if (event.type == 'error') { final message = event.text?.trim(); _finishVoiceUi(); _showVoiceError( message == null || message.isEmpty ? '语音识别失败,请重试' : message, ); return; } else if (event.type == 'cancelled') { _finishVoiceUi(); return; } if (mounted) setState(() {}); _voiceOverlay?.markNeedsBuild(); } void _updateVoiceCancel(LongPressMoveUpdateDetails details) { if (!_voiceHolding) return; final shouldCancel = details.offsetFromOrigin.dy < -70; if (_voiceCancel == shouldCancel) return; setState(() => _voiceCancel = shouldCancel); _voiceOverlay?.markNeedsBuild(); } Future _endVoiceHold() async { if (!_voiceHolding) return; final session = _speechSession; if (_voiceCancel) { ++_speechSession; await ScreenshotChannel.cancelSpeechRecognition(); _finishVoiceUi(); return; } setState(() { _voiceHolding = false; _voiceProcessing = true; }); _voiceOverlay?.markNeedsBuild(); await ScreenshotChannel.stopSpeechRecognition(); Future.delayed(const Duration(seconds: 8), () async { if (!mounted || session != _speechSession || !_voiceProcessing) return; ++_speechSession; await ScreenshotChannel.cancelSpeechRecognition(); _finishVoiceUi(); _showVoiceError('语音识别超时,请重试'); }); } void _showVoiceOverlay() { _voiceOverlay?.remove(); _voiceOverlay = OverlayEntry( builder: (context) { final cancelling = _voiceCancel; final processing = _voiceProcessing; final amplitude = (_voiceRms.abs() / 12).clamp(0.0, 1.0); return Positioned.fill( child: Material( color: const Color(0xD9181A1F), child: SafeArea( child: Column( children: [ Spacer(), AnimatedContainer( duration: const Duration(milliseconds: 100), width: 92 + amplitude * 24, height: 92 + amplitude * 24, decoration: BoxDecoration( shape: BoxShape.circle, color: cancelling ? AppTheme.red : processing ? AppTheme.ai : AppTheme.primary, boxShadow: [ BoxShadow( color: (cancelling ? AppTheme.red : AppTheme.primary) .withValues(alpha: 0.35), blurRadius: 30, spreadRadius: 8 + amplitude * 10, ), ], ), child: Icon( cancelling ? Icons.close_rounded : processing ? Icons.more_horiz_rounded : Icons.mic_rounded, size: 42, color: Colors.white, ), ), SizedBox(height: 30), Text( cancelling ? '松开取消' : processing ? '正在识别…' : _voiceText.isEmpty ? '正在听,松开发送到输入框' : _voiceText, textAlign: TextAlign.center, maxLines: 3, overflow: TextOverflow.ellipsis, style: TextStyle( color: Colors.white, fontSize: 17, fontWeight: FontWeight.w700, ), ), SizedBox(height: 10), if (!processing) Text( cancelling ? '移回按钮区域可继续' : '上滑可取消', style: TextStyle(color: context.jz.text3, fontSize: 13), ), Spacer(), SizedBox(height: 96), ], ), ), ), ); }, ); Overlay.of(context).insert(_voiceOverlay!); } void _finishVoiceUi() { _speechSession++; _voiceOverlay?.remove(); _voiceOverlay = null; if (mounted) { setState(() { _voiceHolding = false; _voiceProcessing = false; _voiceCancel = false; _voiceRms = 0; }); } } void _showVoiceError(String raw) { if (!mounted) return; final message = raw .replaceFirst('Exception: ', '') .replaceFirst('PlatformException(', '') .trim(); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(message.isEmpty ? '语音识别不可用' : message)), ); } Future _pickAndParseImage(ImageSource source) async { if (SessionStore.instance.shouldUseLocalOnly) { _showVoiceError('图片 AI 识别需要登录并连接网络'); return; } if (!PublicConfigApi.imageEnabled) { _showVoiceError('图片识别功能当前已关闭'); return; } try { final file = await _picker.pickImage( source: source, imageQuality: 90, maxWidth: 1800, ); if (file == null || !mounted) return; await ScreenshotParseSheet.show(context, file.path); } catch (e) { if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(apiErrorMessage(e)))); } } void _scroll({bool initial = false, bool streaming = false}) { if (!initial && !_userNearBottom) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (!_sc.hasClients) return; final target = _sc.position.maxScrollExtent; if (initial || streaming) { _sc.jumpTo(target); } else { _sc.animateTo( target, duration: const Duration(milliseconds: 180), curve: Curves.easeOut, ); } }); } Future _openAttachment() async { if (SessionStore.instance.shouldUseLocalOnly) { _showVoiceError('图片 AI 识别需要登录并连接网络'); return; } if (!PublicConfigApi.imageEnabled) { _showVoiceError('图片识别功能当前已关闭'); return; } final source = await showJzOptionSheet( context, title: '添加聊天附件', subtitle: '拍摄账单或从相册选择图片,确认后进入识别页面。', options: const [ JzOption( value: ImageSource.camera, label: '拍照', subtitle: '打开相机拍摄账单或支付凭证', leading: Icon(Icons.camera_alt_rounded, color: AppTheme.ai), ), JzOption( value: ImageSource.gallery, label: '从相册选择', subtitle: '导入已有截图或账单图片', leading: Icon(Icons.photo_library_outlined, color: AppTheme.ai), ), ], ); if (source != null && mounted) { await _pickAndParseImage(source); } } List _buildStickerGrid() { return _stk.map((s) { final (asset, col) = stickerStyle[s.key] ?? (AppIcons.tag, context.jz.text3); return GestureDetector( onTap: () => _send(s.key, 'sticker'), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( width: 48, height: 48, decoration: BoxDecoration( color: context.jz.background, borderRadius: BorderRadius.circular(12), ), child: Center(child: AppIcons.icon(asset, size: 28, color: col)), ), SizedBox(height: 3), SizedBox( height: 18, child: Center( child: Text( stickerLabels[s.key] ?? s.label.trim(), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 10.5, color: context.jz.text2), ), ), ), ], ), ); }).toList(); } Widget _inputBar(BuildContext context) { final sendEnabled = _ctrl.text.trim().isNotEmpty && !_sending; final accent = widget.aiMode ? AppTheme.ai : AppTheme.primary; return Container( color: context.jz.card, padding: EdgeInsets.only( left: 10, right: 10, top: 7, bottom: 7 + MediaQuery.of(context).padding.bottom, ), child: Row( children: [ if (PublicConfigApi.voiceEnabled) ...[ SizedBox( width: 38, height: 42, child: IconButton( tooltip: _voiceMode ? '切换键盘' : '切换语音', onPressed: _toggleVoiceMode, icon: _voiceMode ? Icon( Icons.keyboard_alt_outlined, size: 23, color: context.jz.text2, ) : AppIcons.icon( AppIcons.mic, size: 22, color: context.jz.text2, ), padding: EdgeInsets.zero, ), ), SizedBox(width: 5), ], Expanded( child: AnimatedSwitcher( duration: const Duration(milliseconds: 180), transitionBuilder: (child, animation) => FadeTransition( opacity: animation, child: ScaleTransition( scale: Tween(begin: 0.97, end: 1).animate(animation), child: child, ), ), child: _voiceMode ? GestureDetector( key: const ValueKey('voice-input'), behavior: HitTestBehavior.opaque, onLongPressStart: (_) => _startVoiceHold(), onLongPressMoveUpdate: _updateVoiceCancel, onLongPressEnd: (_) => _endVoiceHold(), child: Container( height: 42, alignment: Alignment.center, decoration: BoxDecoration( color: context.jz.background, borderRadius: BorderRadius.circular(12), border: Border.all(color: context.jz.line), ), child: Text( _voiceProcessing ? '正在识别…' : _voiceHolding ? '松开结束' : '按住 说话', style: TextStyle( fontSize: 14, color: _voiceHolding ? accent : context.jz.text, fontWeight: FontWeight.w600, ), ), ), ) : TextField( key: const ValueKey('keyboard-input'), controller: _ctrl, focusNode: _inputFocus, onTap: _closeComposerPanel, textInputAction: TextInputAction.send, onSubmitted: sendEnabled ? (_) => _send() : null, decoration: InputDecoration( hintText: '说句话记一笔,比如:打车花了45', isDense: true, contentPadding: EdgeInsets.symmetric( horizontal: 12, vertical: 11, ), ), ), ), ), if (PublicConfigApi.stickersEnabled) ...[ SizedBox(width: 5), SizedBox( width: 36, height: 42, child: IconButton( tooltip: '表情包', onPressed: () async { if (_stkOpen) { _closeComposerPanel(); return; } _inputFocus.unfocus(); await SystemChannels.textInput.invokeMethod( 'TextInput.hide', ); await Future.delayed(const Duration(milliseconds: 120)); if (!mounted) return; setState(() => _composerPanel = _ComposerPanel.stickers); _stkLoad(); }, icon: AppIcons.icon( AppIcons.smile, size: 21, color: _stkOpen ? accent : context.jz.text2, ), padding: EdgeInsets.zero, ), ), SizedBox(width: 6), ], SizedBox( width: 50, height: 38, child: FilledButton( onPressed: sendEnabled ? _send : null, style: FilledButton.styleFrom( padding: EdgeInsets.zero, backgroundColor: accent, disabledBackgroundColor: context.jz.line, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(11), ), ), child: Text( '发送', style: TextStyle(fontSize: 12.5, fontWeight: FontWeight.w700), ), ), ), ], ), ); } @override Widget build(BuildContext context) { if (!_loaded) { return Center( child: CircularProgressIndicator(color: AppTheme.ai, strokeWidth: 2), ); } if (_loadError case final error? when _msgs.isEmpty) { return AsyncErrorView(message: error, onRetry: _hist); } if (widget.aiMode && _msgs.isEmpty) { return ValueListenableBuilder( valueListenable: PublicConfigApi.companionNotifier, builder: (_, companion, __) { return AiWelcomePage( onSend: _send, companionName: companion.name, companionAvatarKey: companion.avatarKey, ); }, ); } return PopScope( canPop: _composerPanel == _ComposerPanel.none, onPopInvokedWithResult: (didPop, _) { if (!didPop) _closeComposerPanel(); }, child: Column( children: [ Expanded( child: ListView.builder( controller: _sc, padding: const EdgeInsets.all(14), itemCount: _msgs.length, itemBuilder: (c, i) => _Row(m: _msgs[i], undo: _undo), ), ), SizedBox( height: 40, child: ListView( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 12), children: [ ..._q.map( (p) => Padding( padding: const EdgeInsets.only(right: 8), child: ActionChip( label: Text( p, style: TextStyle(fontSize: 11, color: context.jz.text2), ), backgroundColor: context.jz.background, side: BorderSide.none, onPressed: () => _send(p), ), ), ), if (PublicConfigApi.imageEnabled) Padding( padding: const EdgeInsets.only(right: 8), child: ActionChip( avatar: AppIcons.icon( AppIcons.camera, size: 16, color: AppTheme.ai, ), label: Text( '附件', style: TextStyle(fontSize: 11, color: context.jz.text2), ), backgroundColor: context.jz.background, side: BorderSide.none, onPressed: _openAttachment, ), ), ], ), ), _inputBar(context), AnimatedSize( duration: const Duration(milliseconds: 180), curve: Curves.easeOutCubic, child: _stkOpen && PublicConfigApi.stickersEnabled ? Container( color: context.jz.card, padding: EdgeInsets.only( left: 14, right: 14, top: 10, bottom: 14 + MediaQuery.of(context).padding.bottom, ), child: GridView.count( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), crossAxisCount: 4, mainAxisSpacing: 8, crossAxisSpacing: 8, childAspectRatio: 0.78, children: _buildStickerGrid(), ), ) : const SizedBox.shrink(), ), ], ), ); } } class _Row extends StatelessWidget { final ChatMsg m; final void Function(TxItem) undo; const _Row({required this.m, required this.undo}); @override Widget build(BuildContext context) { final isMe = m.role == 'user'; Widget bubble; if (m.type == 'bill_card' && m.transaction != null) { bubble = _Bill(t: m.transaction!, u: undo); } else if (m.type == 'bill_card') { bubble = const _DeletedBill(); } else if (m.type == 'sticker') { final (asset, color) = stickerStyle[m.content] ?? (AppIcons.tag, context.jz.text3); bubble = Container( width: 96, padding: const EdgeInsets.symmetric(vertical: 13), decoration: BoxDecoration( color: context.jz.card, borderRadius: BorderRadius.circular(14), border: Border.all(color: context.jz.line, width: 0.5), ), child: Center(child: AppIcons.icon(asset, size: 46, color: color)), ); } else if (!isMe && m.content.isEmpty) { bubble = Container( constraints: const BoxConstraints(maxWidth: 170), padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 11), decoration: BoxDecoration( color: context.jz.card, borderRadius: const BorderRadius.only( topLeft: Radius.circular(3), topRight: Radius.circular(15), bottomLeft: Radius.circular(15), bottomRight: Radius.circular(15), ), border: Border.all(color: context.jz.line, width: 0.5), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, color: AppTheme.ai, ), ), SizedBox(width: 8), Text( '正在回复…', style: TextStyle(fontSize: 12, color: context.jz.text2), ), ], ), ); } else { bubble = Container( constraints: const BoxConstraints(maxWidth: 270), padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 9), decoration: BoxDecoration( color: isMe ? AppTheme.primary : context.jz.card, borderRadius: BorderRadius.only( topLeft: Radius.circular(isMe ? 15 : 3), topRight: Radius.circular(isMe ? 3 : 15), bottomLeft: const Radius.circular(15), bottomRight: const Radius.circular(15), ), border: isMe ? null : Border.all(color: context.jz.line, width: 0.5), ), child: Text( m.content, style: TextStyle( fontSize: 13, height: 1.5, color: isMe ? Colors.white : context.jz.text, ), ), ); } return Padding( padding: const EdgeInsets.only(bottom: 11), child: Row( mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (!isMe) ...[ CircleAvatar( radius: 16, backgroundColor: AppTheme.ai, child: AppIcons.icon( AppIcons.keyMap[PublicConfigApi.companionAvatarKey] ?? AppIcons.cat, size: 17, color: Colors.white, ), ), SizedBox(width: 8), ], bubble, if (isMe) ...[ SizedBox(width: 8), CircleAvatar( radius: 16, backgroundColor: context.jz.line, child: AppIcons.icon( AppIcons.user, size: 17, color: context.jz.text2, ), ), ], ], ), ); } } class _DeletedBill extends StatelessWidget { const _DeletedBill(); @override Widget build(BuildContext context) { return Container( width: 230, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13), decoration: BoxDecoration( color: context.jz.card, borderRadius: BorderRadius.circular(14), border: Border.all(color: context.jz.line, width: 0.5), ), child: Row( children: [ Icon(Icons.delete_outline, size: 20, color: context.jz.text3), SizedBox(width: 9), Expanded( child: Text( '账单已永久删除', style: TextStyle(fontSize: 12, color: context.jz.text3), ), ), ], ), ); } } class _Bill extends StatelessWidget { final TxItem t; final void Function(TxItem) u; const _Bill({required this.t, required this.u}); @override Widget build(BuildContext context) { return Container( width: 230, decoration: BoxDecoration( color: context.jz.card, borderRadius: BorderRadius.circular(14), border: Border.all(color: context.jz.line, width: 0.5), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), decoration: BoxDecoration( color: t.isIncome ? AppTheme.primary : AppTheme.ai, borderRadius: const BorderRadius.vertical( top: Radius.circular(13), ), ), child: Text( t.isIncome ? 'AI 已记录收入' : 'AI 已记录支出', style: TextStyle( fontSize: 10.5, color: Colors.white, fontWeight: FontWeight.w600, ), ), ), Padding( padding: const EdgeInsets.all(12), child: Row( children: [ CategoryIconBox( iconKey: t.categoryIcon, colorKey: t.categoryColor, size: 36, ), SizedBox(width: 10), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${t.isIncome ? '+' : '-'}¥${t.amount.toStringAsFixed(2)}', style: TextStyle( fontSize: 18, fontWeight: FontWeight.w700, color: t.isIncome ? AppTheme.primary : context.jz.text, ), ), Text( '${t.categoryName}${t.paymentMethod != null ? ' · ${t.paymentMethod}' : ''}', style: TextStyle(fontSize: 10.5, color: context.jz.text2), ), ], ), ], ), ), Divider(height: 0.5, color: context.jz.line), Row( children: [ Expanded( child: Padding( padding: EdgeInsets.symmetric(vertical: 8), child: Text( '已入账', textAlign: TextAlign.center, style: TextStyle( fontSize: 11.5, color: AppTheme.primary, fontWeight: FontWeight.w600, ), ), ), ), Container(width: 0.5, height: 32, color: context.jz.line), Expanded( child: InkWell( onTap: () => u(t), child: Padding( padding: EdgeInsets.symmetric(vertical: 8), child: Text( '撤销', textAlign: TextAlign.center, style: TextStyle(fontSize: 11.5, color: context.jz.text2), ), ), ), ), ], ), ], ), ); } } class ChatTabPage extends StatefulWidget { const ChatTabPage({super.key}); @override State createState() => _ChatTabPageState(); } class _ChatTabPageState extends State { final _chatController = ChatPageController(); @override Widget build(BuildContext context) { return AnimatedBuilder( animation: SessionStore.instance, builder: (context, _) { final accessState = currentAiAccessState(); return ValueListenableBuilder( valueListenable: PublicConfigApi.companionNotifier, builder: (_, companion, __) { final status = switch (accessState) { AiAccessState.guest => '登录后可用', AiAccessState.reauthenticate => '需要重新登录', AiAccessState.cloudDisabled => '云连接已关闭', null => '在线', }; final statusColor = accessState == null ? AppTheme.primary : AppTheme.orange; return Scaffold( appBar: AppBar( title: Column( children: [ Text( companion.name, style: TextStyle( fontSize: 15, fontWeight: FontWeight.w700, ), ), Text( status, style: TextStyle(fontSize: 10, color: statusColor), ), ], ), actions: accessState == null ? [ Padding( padding: const EdgeInsets.only(right: 10), child: Tooltip( message: '清理上下文', child: Semantics( button: true, label: '清理聊天上下文', child: Material( color: context.jz.aiBackground, shape: const CircleBorder(), child: InkWell( onTap: _chatController.clearContext, customBorder: const CircleBorder(), child: SizedBox( width: 38, height: 38, child: Icon( Icons.restart_alt_rounded, size: 20, color: AppTheme.ai, ), ), ), ), ), ), ), ] : null, ), body: accessState == null ? ChatPage(controller: _chatController) : AiAccessGate(state: accessState), ); }, ); }, ); } }