diff --git a/lib/api/ai_api.dart b/lib/api/ai_api.dart index 493019a..3d4af5d 100644 --- a/lib/api/ai_api.dart +++ b/lib/api/ai_api.dart @@ -227,15 +227,17 @@ class AiApi { } } - /// 新增一套人设。 + /// 新增一套人设。[avatar] 传 null 表示不设头像。 Future createPersona({ required String name, required String systemPrompt, + String? avatar, }) async { try { final res = await _dio.post('/ai/personas', data: { 'name': name, 'systemPrompt': systemPrompt, + 'avatar': ?avatar, }); return AiPersonaItem.fromJson(Map.from(res.data as Map)); } on DioException catch (e) { @@ -246,15 +248,22 @@ class AiApi { /// 修改人设。提示词是**整体替换**的 —— 每次都把完整文本传上来, /// 没有模型配置那种「key 留空 = 不改动」的语义。 + /// + /// 头像不一样:[avatar] 传 null = **不改动**(用户只改提示词时不该被迫重选头像), + /// [clearAvatar] 传 true = 清空回到默认头像。 Future updatePersona({ required int id, required String name, required String systemPrompt, + String? avatar, + bool clearAvatar = false, }) async { try { final res = await _dio.put('/ai/personas/$id', data: { 'name': name, 'systemPrompt': systemPrompt, + 'avatar': ?avatar, + if (clearAvatar) 'clearAvatar': true, }); return AiPersonaItem.fromJson(Map.from(res.data as Map)); } on DioException catch (e) { @@ -263,6 +272,30 @@ class AiApi { } } + /// 上传人设头像,返回**相对路径**(再由 create/update 提交)。 + /// + /// 只落盘不落库 —— 用户在编辑弹层里可能传了又取消,不该留下记录。 + Future uploadPersonaAvatar(String filePath) async { + try { + final form = FormData.fromMap({ + 'file': await MultipartFile.fromFile(filePath, filename: _fileName(filePath)), + }); + final res = await _dio.post( + '/ai/personas/avatar', + data: form, + options: Options(receiveTimeout: const Duration(seconds: 60)), + ); + final data = res.data; + if (data is Map && data['path'] != null) { + return data['path'].toString(); + } + throw ApiException(-1, '上传失败:服务端没返回路径'); + } on DioException catch (e) { + final err = e.error; + throw err is ApiException ? err : ApiException(-1, '头像上传失败'); + } + } + Future deletePersona(int id) async { try { await _dio.delete('/ai/personas/$id'); @@ -272,6 +305,13 @@ class AiApi { } } + /// 从路径里取个带扩展名的文件名,后端靠它判断文件类型 + static String _fileName(String path) { + final normalized = path.replaceAll('\\', '/'); + final name = normalized.substring(normalized.lastIndexOf('/') + 1); + return name.isEmpty ? 'avatar.jpg' : name; + } + AiEvent? _parseEvent(String event, String payload) { final decoded = jsonDecode(payload); if (decoded is! Map) return null; diff --git a/lib/api/auth_api.dart b/lib/api/auth_api.dart index 8ad2698..69c9852 100644 --- a/lib/api/auth_api.dart +++ b/lib/api/auth_api.dart @@ -39,6 +39,61 @@ class AuthApi { } } + /// 拉当前用户资料。冷启动时本地缓存的 userInfo 可能过期(在别处换过头像), + /// 用它刷一次。 + Future profile() async { + try { + final res = await _dio.get('/auth/profile'); + return AuthResponse.fromJson(_map(res.data)); + } on DioException catch (e) { + throw _unwrap(e); + } + } + + /// 更新资料。目前只有头像。 + /// + /// [avatar] 传 null = 不改动;[clearAvatar] 传 true = 清空。 + /// 这两种语义要分清 —— 混淆会让「只改昵称」意外把头像清掉。 + Future updateProfile({String? avatar, bool clearAvatar = false}) async { + try { + final res = await _dio.put('/auth/profile', data: { + 'avatar': ?avatar, + if (clearAvatar) 'clearAvatar': true, + }); + return AuthResponse.fromJson(_map(res.data)); + } on DioException catch (e) { + throw _unwrap(e); + } + } + + /// 上传头像,返回**相对路径**(再由 updateProfile 提交)。 + Future uploadAvatar(String filePath) async { + try { + final form = FormData.fromMap({ + 'file': await MultipartFile.fromFile(filePath, filename: _fileName(filePath)), + }); + final res = await _dio.post( + '/auth/avatar', + data: form, + options: Options(receiveTimeout: const Duration(seconds: 60)), + ); + final data = res.data; + if (data is Map && data['path'] != null) { + return data['path'].toString(); + } + throw ApiException(-1, '上传失败:服务端没返回路径'); + } on DioException catch (e) { + throw _unwrap(e); + } + } + + /// 从路径取文件名,后端靠扩展名判断类型 + static String _fileName(String path) { + final normalized = path.replaceAll('\\', '/'); + final name = normalized.substring(normalized.lastIndexOf('/') + 1); + return name.isEmpty ? 'avatar.jpg' : name; + } + Map _map(Object? data) { if (data is Map) return data; throw ApiException(-1, '响应格式异常'); diff --git a/lib/core/storage/local_storage.dart b/lib/core/storage/local_storage.dart index cbef1f2..6ba316d 100644 --- a/lib/core/storage/local_storage.dart +++ b/lib/core/storage/local_storage.dart @@ -36,6 +36,15 @@ class LocalStorage { await prefs.remove(_keyUserInfo); } + /// 只更新用户信息,不动 token。 + /// + /// 改头像/昵称后用它 —— 那些操作不发新 token,不该走 [setAuth] + /// (那会要求重传 token,容易传错把登录态弄坏)。 + Future setUserInfo(Map userInfo) async { + final prefs = await _getPrefs(); + await prefs.setString(_keyUserInfo, jsonEncode(userInfo)); + } + String? getToken() => _prefs?.getString(_keyToken); Map? getUserInfo() { diff --git a/lib/models/ai.dart b/lib/models/ai.dart index 8357d39..6ede4c5 100644 --- a/lib/models/ai.dart +++ b/lib/models/ai.dart @@ -6,10 +6,24 @@ class ChatSessionItem { final String title; final String updateTime; + /// 本会话绑定的人设 id。null = 内置默认人设。 + /// + /// 会话的人设在**新建时确定、之后不可改** —— 一次对话从头到尾是同一个人。 + final int? personaId; + + /// 人设显示名。人设被删了会回落成「默认人设」,历史会话不会因此打不开 + final String personaName; + + /// 人设头像相对路径。null = 用默认头像 + final String? personaAvatar; + const ChatSessionItem({ required this.id, required this.title, required this.updateTime, + this.personaId, + this.personaName = '默认人设', + this.personaAvatar, }); factory ChatSessionItem.fromJson(Map json) { @@ -17,6 +31,9 @@ class ChatSessionItem { id: asInt(json['id']), title: asNonEmpty(json['title'], '新对话'), updateTime: formatDateTime(json['updateTime']), + personaId: asNullableInt(json['personaId']), + personaName: asNonEmpty(json['personaName'], '默认人设'), + personaAvatar: asNonEmpty(json['personaAvatar'], ''), ); } } diff --git a/lib/models/ai_persona.dart b/lib/models/ai_persona.dart index 3d671a8..fd14984 100644 --- a/lib/models/ai_persona.dart +++ b/lib/models/ai_persona.dart @@ -17,6 +17,7 @@ class AiPersonaItem { required this.isDefault, required this.editable, required this.defaultPrompt, + this.avatar, }); /// null = 虚拟的「默认」项(内容是服务端代码内置的) @@ -42,6 +43,10 @@ class AiPersonaItem { /// 以后改了默认人设,App 不用跟着改版本。 final String? defaultPrompt; + /// 头像**相对路径**(如 `persona/2026/09/29/xxx.jpg`)。 + /// 用 [Avatar] 组件显示,它会自己拼 host。null = 用默认头像。 + final String? avatar; + /// 用于「显示已选中的是哪一项」的稳定标识。 /// /// 默认项没有 id,用 -1 代替 —— 不能直接用 null 当 map key 去比较。 @@ -58,6 +63,7 @@ class AiPersonaItem { // 用户项上这个字段是 null —— 不能直接用 asNonEmpty(它要 fallback 参数, // 而我们恰恰需要保留「null = 没有」这个语义,供「恢复默认」按钮判断) defaultPrompt: _nullableNonEmpty(json['defaultPrompt']), + avatar: _nullableNonEmpty(json['avatar']), ); } diff --git a/lib/models/auth_response.dart b/lib/models/auth_response.dart index 2026449..c6cccb2 100644 --- a/lib/models/auth_response.dart +++ b/lib/models/auth_response.dart @@ -3,12 +3,21 @@ class AuthResponse { final String? username; final String? nickname; - const AuthResponse({required this.token, this.username, this.nickname}); + /// 头像相对路径。null = 用默认头像 + final String? avatar; + + const AuthResponse({ + required this.token, + this.username, + this.nickname, + this.avatar, + }); factory AuthResponse.fromJson(Map json) => AuthResponse( token: json['token']?.toString() ?? '', username: json['username']?.toString(), nickname: json['nickname']?.toString(), + avatar: json['avatar']?.toString(), ); } @@ -17,13 +26,27 @@ class UserInfo { final String username; final String nickname; - const UserInfo({this.id, required this.username, required this.nickname}); + /// 头像相对路径。null = 用默认头像 + final String? avatar; - Map toJson() => {'id': id, 'username': username, 'nickname': nickname}; + const UserInfo({ + this.id, + required this.username, + required this.nickname, + this.avatar, + }); + + Map toJson() => { + 'id': id, + 'username': username, + 'nickname': nickname, + 'avatar': avatar, + }; factory UserInfo.fromJson(Map json) => UserInfo( id: json['id']?.toString(), username: json['username']?.toString() ?? '', nickname: json['nickname']?.toString() ?? '', + avatar: json['avatar']?.toString(), ); } diff --git a/lib/pages/assistant/ai_persona_page.dart b/lib/pages/assistant/ai_persona_page.dart index 03601a7..f806435 100644 --- a/lib/pages/assistant/ai_persona_page.dart +++ b/lib/pages/assistant/ai_persona_page.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:image_picker/image_picker.dart'; import '../../../api/ai_api.dart'; import '../../../core/network/api_exception.dart'; import '../../../models/ai_persona.dart'; import '../../../providers/ai_persona_provider.dart'; import '../../../utils/toast.dart'; +import '../../../widgets/avatar.dart'; import '../../../widgets/state_views.dart'; /// 人设列表页(不含 Scaffold/AppBar —— 它作为「AI 设置」页的一个 Tab 嵌进去) @@ -154,6 +156,13 @@ class AiPersonaTab extends ConsumerWidget { active ? const Color(0xFFC8956E) : const Color(0xFFD7CCC8), ), ), + const SizedBox(width: 8), + // 人设头像。没设过就是默认小人图标 + Avatar( + assetPath: item.avatar, + size: 38, + fallbackIcon: 'assistant', + ), const SizedBox(width: 10), Expanded( child: Column( @@ -319,6 +328,20 @@ class _PersonaEditorState extends ConsumerState<_PersonaEditor> { bool _saving = false; + /// 当前头像相对路径。null = 没有头像。 + /// + /// 三种状态要分清(和后端语义对齐): + /// · 进来时的原值 → 不传,保持不动 + /// · 用户选了新图 → 传新路径 + /// · 用户点了「移除头像」→ 传 clearAvatar=true + String? _avatar; + + /// 是否显式要求清空头像 + bool _clearAvatar = false; + + /// 上传中(转圈 + 禁止重复点) + bool _uploading = false; + bool get _isEdit => widget.existing != null; @override @@ -331,6 +354,7 @@ class _PersonaEditorState extends ConsumerState<_PersonaEditor> { _prompt = TextEditingController( text: e?.systemPrompt ?? ref.read(defaultPersonaPromptProvider) ?? '', ); + _avatar = e?.avatar; } @override @@ -340,6 +364,82 @@ class _PersonaEditorState extends ConsumerState<_PersonaEditor> { super.dispose(); } + /// 从相册或相机选一张头像。 + /// + /// 这里**传了压缩参数**(和水印那个「要原图」的场景不同)—— + /// 头像显示尺寸才 40px,传几 MB 的原图纯属浪费流量和服务器空间。 + Future _pickAvatar(ImageSource source) async { + if (_uploading) return; + try { + final picked = await ImagePicker().pickImage( + source: source, + imageQuality: 80, + maxWidth: 800, + ); + if (picked == null || !mounted) return; + + setState(() => _uploading = true); + final path = await ref.read(aiApiProvider).uploadPersonaAvatar(picked.path); + if (!mounted) return; + + setState(() { + _avatar = path; + _clearAvatar = false; + }); + showToast(context, '头像已上传,记得保存', icon: 'check'); + } on ApiException catch (e) { + if (mounted) showToast(context, e.message, error: true); + } catch (e) { + if (mounted) showToast(context, '上传失败:$e', error: true); + } finally { + if (mounted) setState(() => _uploading = false); + } + } + + /// 底部选「拍照 / 相册」 + Future _showAvatarPicker() async { + final source = await showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.photo_camera_outlined, color: Color(0xFFC8956E)), + title: const Text('拍一张', style: TextStyle(fontSize: 14.5)), + onTap: () => Navigator.of(sheetContext).pop(ImageSource.camera), + ), + ListTile( + leading: const Icon(Icons.photo_library_outlined, color: Color(0xFFC8956E)), + title: const Text('从相册选', style: TextStyle(fontSize: 14.5)), + onTap: () => Navigator.of(sheetContext).pop(ImageSource.gallery), + ), + if ((_avatar?.isNotEmpty ?? false)) + ListTile( + leading: const Icon(Icons.delete_outline, color: Color(0xFFE53935)), + title: const Text('移除头像', + style: TextStyle(fontSize: 14.5, color: Color(0xFFE53935))), + onTap: () { + Navigator.of(sheetContext).pop(); + setState(() { + _avatar = null; + _clearAvatar = true; + }); + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ); + if (source == null || !mounted) return; + await _pickAvatar(source); + } + Future _save() async { final prompt = _prompt.text.trim(); if (prompt.isEmpty) { @@ -350,16 +450,21 @@ class _PersonaEditorState extends ConsumerState<_PersonaEditor> { setState(() => _saving = true); try { final api = ref.read(aiApiProvider); + // 只有「选了新图」才传 avatar;没动就不传(后端保持原值) + final newAvatar = _clearAvatar ? null : _avatar; if (_isEdit) { await api.updatePersona( id: widget.existing!.id!, name: _name.text.trim(), systemPrompt: prompt, + avatar: newAvatar, + clearAvatar: _clearAvatar, ); } else { await api.createPersona( name: _name.text.trim(), systemPrompt: prompt, + avatar: newAvatar, ); } if (!mounted) return; @@ -375,6 +480,69 @@ class _PersonaEditorState extends ConsumerState<_PersonaEditor> { } } + /// 头像选择区:一个圆形头像 + 下面一行小字。 + /// + /// 点它弹「拍照 / 相册 / 移除」的选择。已上传时头像立即预览 + /// (用的是刚传回来的相对路径,本地也能看到)。 + Widget _avatarPicker() { + return GestureDetector( + onTap: _uploading ? null : _showAvatarPicker, + child: Column( + children: [ + Stack( + children: [ + Avatar( + assetPath: _avatar, + size: 76, + fallbackIcon: 'assistant', + ), + // 悬一个小相机角标,暗示「可以点」 + Positioned( + right: 0, + bottom: 0, + child: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: const Color(0xFFC8956E), + border: Border.all(color: Colors.white, width: 2), + ), + child: const Icon(Icons.camera_alt, + size: 12, color: Colors.white), + ), + ), + if (_uploading) + Positioned.fill( + child: Container( + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.black26, + ), + child: const Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ), + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + _uploading ? '上传中…' : '点头像换个形象', + style: const TextStyle(fontSize: 12, color: Color(0xFFBCAAA4)), + ), + ], + ), + ); + } + void _restoreDefault() { final def = ref.read(defaultPersonaPromptProvider); if (def == null || def.isEmpty) { @@ -412,6 +580,10 @@ class _PersonaEditorState extends ConsumerState<_PersonaEditor> { ), const SizedBox(height: 18), + // 头像放最上面 —— 它是「这个人长什么样」的第一印象 + Center(child: _avatarPicker()), + const SizedBox(height: 18), + _field( label: '名称', controller: _name, diff --git a/lib/pages/assistant/assistant_page.dart b/lib/pages/assistant/assistant_page.dart index 73668e5..db0eaf6 100644 --- a/lib/pages/assistant/assistant_page.dart +++ b/lib/pages/assistant/assistant_page.dart @@ -8,8 +8,11 @@ import '../../core/network/api_env.dart'; import '../../models/ai.dart'; import '../../providers/ai_provider.dart'; import '../../providers/ai_config_provider.dart'; +import '../../providers/ai_persona_provider.dart'; +import '../../providers/auth_provider.dart'; import 'ai_config_page.dart'; import '../../utils/toast.dart'; +import '../../widgets/avatar.dart'; import '../../widgets/state_views.dart'; import '../../widgets/cute_icon.dart'; @@ -121,7 +124,8 @@ class _AssistantPageState extends ConsumerState { _iconButton( icon: Icons.add_comment_outlined, tooltip: '新对话', - onTap: () => ref.read(assistantProvider.notifier).newChat(), + // 新对话要先选人设 —— 选了之后这一轮就锁定它,中途不会换 + onTap: () => _startNewChat(context), ), ], ), @@ -261,17 +265,59 @@ class _AssistantPageState extends ConsumerState { ); } + // ---------------------------------------------------------------- 新对话 + + /// 「新对话」:先让用户挑个人设,再开始。 + /// + /// 挑完就锁定在这一轮对话上(存进 AssistantState.personaId), + /// 之后不管用户在设置页怎么换人设,这轮都不会变 —— 这是刻意的, + /// 见 `AssistantState.personaId` 的注释。 + /// + /// 列表里第一项是「默认人设」,也就是什么都不选的状态。用户直接关掉弹层 + /// (返回 null)时也按默认处理,不强迫他做选择。 + Future _startNewChat(BuildContext context) async { + final picked = await showModalBottomSheet<_PersonaChoice>( + context: context, + backgroundColor: Colors.white, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => const _PersonaPickerSheet(), + ); + if (!mounted) return; + + // 关掉弹层 = 用默认人设。不返回任何东西也照样开新对话, + // 免得用户以为「没点就是取消了」 + ref.read(assistantProvider.notifier).newChat(personaId: picked?.personaId); + } + // ---------------------------------------------------------------- 气泡 + // + // 微信风格:左右两侧都带头像,气泡紧贴头像那一侧。 + // · 对方(AI)头像在左,用**当前会话绑的人设头像**;没设过就是默认小人 + // · 自己(用户)头像在右,用账号头像;没设过是默认小人 + // + // 头像只给 36px —— 太大反而抢戏,微信也就这个量级。 Widget _bubble(BuildContext context, ChatBubble bubble) { + final persona = ref.watch(activeAiPersonaProvider); + final user = ref.watch(authProvider).user; + if (bubble.isUser) { - return Align( - alignment: Alignment.centerRight, - child: Container( - margin: const EdgeInsets.only(top: 10, left: 48), + return _row( + avatar: Avatar( + assetPath: user?.avatar, + size: 36, + fallbackIcon: 'user', + ), + bubble: Container( + margin: const EdgeInsets.only(left: 40), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( - gradient: const LinearGradient(colors: [Color(0xFFC8956E), Color(0xFFD9A87C)]), + gradient: const LinearGradient( + colors: [Color(0xFFC8956E), Color(0xFFD9A87C)], + ), borderRadius: const BorderRadius.only( topLeft: Radius.circular(16), topRight: Radius.circular(16), @@ -281,16 +327,25 @@ class _AssistantPageState extends ConsumerState { ), child: SelectableText( bubble.content, - style: const TextStyle(fontSize: 14.5, height: 1.5, color: Colors.white), + style: const TextStyle( + fontSize: 14.5, + height: 1.5, + color: Colors.white, + ), ), ), + isUser: true, ); } - return Align( - alignment: Alignment.centerLeft, - child: Container( - margin: const EdgeInsets.only(top: 10, right: 24), + return _row( + avatar: Avatar( + assetPath: persona?.avatar, + size: 36, + fallbackIcon: 'assistant', + ), + bubble: Container( + margin: const EdgeInsets.only(right: 40), padding: const EdgeInsets.fromLTRB(14, 10, 14, 10), decoration: BoxDecoration( color: Colors.white, @@ -338,6 +393,35 @@ class _AssistantPageState extends ConsumerState { ], ), ), + isUser: false, + ); + } + + /// 一行 = 头像 + 气泡。用户侧左右镜像。 + /// + /// 头像用 [CrossAxisAlignment.start](贴顶)而不是居中 —— 回答很长时 + /// 头像跟着垂直居中会飘到中间,微信也是贴顶的。 + Widget _row({ + required Widget avatar, + required Widget bubble, + required bool isUser, + }) { + return Padding( + padding: const EdgeInsets.only(top: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: isUser + ? [ + Expanded(child: Align(alignment: Alignment.centerRight, child: bubble)), + const SizedBox(width: 8), + avatar, + ] + : [ + avatar, + const SizedBox(width: 8), + Expanded(child: Align(alignment: Alignment.centerLeft, child: bubble)), + ], + ), ); } @@ -498,7 +582,7 @@ class _AssistantPageState extends ConsumerState { // ---------------------------------------------------------------- 历史会话 Future _showSessions(BuildContext context) async { - final selected = await showModalBottomSheet( + final selected = await showModalBottomSheet( context: context, backgroundColor: Colors.white, isScrollControlled: true, @@ -508,7 +592,11 @@ class _AssistantPageState extends ConsumerState { builder: (_) => const _SessionSheet(), ); if (selected == null || !mounted) return; - ref.read(assistantProvider.notifier).openSession(selected); + // 把人设一并带进去 —— 历史对话要用它当时绑的那个, + // 不能因为你现在换了默认人设就跟着变 + ref + .read(assistantProvider.notifier) + .openSession(selected.id, personaId: selected.personaId); } // ---------------------------------------------------------------- 滚动 @@ -537,6 +625,112 @@ class _TypingDots extends StatelessWidget { } } +/// 新对话时选人设的结果 +class _PersonaChoice { + const _PersonaChoice(this.personaId); + + /// null = 内置默认人设 + final int? personaId; +} + +/// 新对话的人设选择面板。 +/// +/// 为什么新对话要先选人设:人设一旦定了,这轮对话就不再变(换了会前后不一致)。 +/// 所以在「开始一段对话」这个时刻选是最自然的 —— 而不是聊着聊着去设置页改。 +class _PersonaPickerSheet extends ConsumerWidget { + const _PersonaPickerSheet(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final personasAsync = ref.watch(aiPersonasProvider); + // 默认选中「用户当前偏好的那套」,省得每次都点 + final preferred = ref.watch(selectedAiPersonaProvider); + + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + '这次想找谁聊?', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Color(0xFF5D4037), + ), + ), + const SizedBox(height: 4), + const Text( + '选定后整段对话都是这个人,中途不会换', + style: TextStyle(fontSize: 11.5, color: Color(0xFFBCAAA4)), + ), + const SizedBox(height: 12), + Flexible( + child: personasAsync.when( + loading: () => const LoadingView(tip: '加载中…'), + error: (e, _) => ErrorView( + message: '人设加载失败', + onRetry: () => ref.invalidate(aiPersonasProvider), + ), + data: (list) { + if (list.isEmpty) { + // 极端情况:后端一个人设都没有。不该卡住用户, + // 直接给一个「用默认」的出口 + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 4), + leading: const Avatar(size: 38, fallbackIcon: 'assistant'), + title: const Text('默认人设', style: TextStyle(fontSize: 14.5)), + onTap: () => Navigator.pop(context, const _PersonaChoice(null)), + ); + } + return ListView.builder( + shrinkWrap: true, + itemCount: list.length, + itemBuilder: (context, index) { + final p = list[index]; + final active = p.selectionKey == (preferred ?? -1); + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 4), + leading: Avatar( + assetPath: p.avatar, + size: 42, + fallbackIcon: 'assistant', + ), + title: Text( + p.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14.5, + fontWeight: active ? FontWeight.w600 : FontWeight.normal, + color: const Color(0xFF5D4037), + ), + ), + subtitle: Text( + p.isDefault ? '代码内置的那套' : p.preview, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 11.5, color: Color(0xFFBCAAA4)), + ), + trailing: active + ? const Icon(Icons.check_circle, + size: 19, color: Color(0xFFC8956E)) + : null, + onTap: () => Navigator.pop(context, _PersonaChoice(p.id)), + ); + }, + ); + }, + ), + ), + ], + ), + ), + ); + } +} + /// 历史会话面板 class _SessionSheet extends ConsumerWidget { const _SessionSheet(); @@ -578,6 +772,13 @@ class _SessionSheet extends ConsumerWidget { final s = sessions[index]; return ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 4), + // 会话头像 = 那条会话绑定的人设头像。 + // 一眼看出「这段对话是谁在陪你」,不用点进去 + leading: Avatar( + assetPath: s.personaAvatar, + size: 38, + fallbackIcon: 'assistant', + ), title: Text( s.title, maxLines: 1, @@ -585,7 +786,9 @@ class _SessionSheet extends ConsumerWidget { style: const TextStyle(fontSize: 14.5, color: Color(0xFF5D4037)), ), subtitle: Text( - s.updateTime, + '${s.personaName} · ${s.updateTime}', + maxLines: 1, + overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11.5, color: Color(0xFFBCAAA4)), ), trailing: IconButton( @@ -594,7 +797,7 @@ class _SessionSheet extends ConsumerWidget { color: const Color(0xFFBCAAA4), onPressed: () => _confirmDelete(context, ref, s), ), - onTap: () => Navigator.pop(context, s.id), + onTap: () => Navigator.pop(context, s), ); }, ); diff --git a/lib/pages/mine/mine_page.dart b/lib/pages/mine/mine_page.dart index 8298502..f1fca2c 100644 --- a/lib/pages/mine/mine_page.dart +++ b/lib/pages/mine/mine_page.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:image_picker/image_picker.dart'; +import '../../api/auth_api.dart'; import '../../core/module/app_module.dart'; import '../../core/network/api_exception.dart'; import '../../core/network/auth_redirect.dart'; @@ -12,6 +14,7 @@ import '../../providers/budget_provider.dart'; import '../../utils/amount_input_formatter.dart'; import '../../utils/amount_util.dart'; import '../../utils/toast.dart'; +import '../../widgets/avatar.dart'; import '../../widgets/cute_icon.dart'; import '../../widgets/state_views.dart'; @@ -47,7 +50,7 @@ class MinePage extends ConsumerWidget { physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.all(16), children: [ - _userCard(user?.nickname ?? '用户', user?.username ?? '-'), + _userCard(context, ref, user?.nickname ?? '用户', user?.username ?? '-'), const SizedBox(height: 14), _accountCard(context, ref, accountAsync), const SizedBox(height: 14), @@ -78,7 +81,9 @@ class MinePage extends ConsumerWidget { future.then((_) {}, onError: (_, _) {}); // ---------------- 用户信息 ---------------- - Widget _userCard(String nickname, String username) { + Widget _userCard(BuildContext context, WidgetRef ref, String nickname, String username) { + final avatar = ref.watch(authProvider).user?.avatar; + return Container( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), decoration: BoxDecoration( @@ -97,12 +102,15 @@ class MinePage extends ConsumerWidget { ), child: Row( children: [ - Container( - width: 60, - height: 60, - decoration: const BoxDecoration(shape: BoxShape.circle, color: Colors.white), - alignment: Alignment.center, - child: const CuteIcon('user', size: 32), + // 头像。点它换 —— 这是全 App 唯一的用户头像,聊天气泡用的也是它 + GestureDetector( + onTap: () => _pickAvatar(context, ref), + child: Avatar( + assetPath: avatar, + size: 60, + fallbackIcon: 'user', + backgroundColor: Colors.white, + ), ), const SizedBox(width: 16), Expanded( @@ -125,11 +133,65 @@ class MinePage extends ConsumerWidget { ], ), ), + // 提示这个区域可点,不然没人知道能换头像 + const Icon(Icons.chevron_right, size: 20, color: Color(0xFF6D4C41)), ], ), ); } + /// 换头像:选图 → 上传 → 更新资料 → 刷新本地用户信息 + Future _pickAvatar(BuildContext context, WidgetRef ref) async { + final source = await showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.photo_camera_outlined, color: Color(0xFFC8956E)), + title: const Text('拍一张', style: TextStyle(fontSize: 14.5)), + onTap: () => Navigator.of(sheetContext).pop(ImageSource.camera), + ), + ListTile( + leading: const Icon(Icons.photo_library_outlined, color: Color(0xFFC8956E)), + title: const Text('从相册选', style: TextStyle(fontSize: 14.5)), + onTap: () => Navigator.of(sheetContext).pop(ImageSource.gallery), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + if (source == null || !context.mounted) return; + + final api = ref.read(authApiProvider); + try { + final picked = await ImagePicker().pickImage( + source: source, + imageQuality: 80, + maxWidth: 800, + ); + if (picked == null || !context.mounted) return; + + // 上传拿路径,再提交资料 —— 两步是为了「传了但没保存」不留下记录 + final path = await api.uploadAvatar(picked.path); + final res = await api.updateProfile(avatar: path); + if (!context.mounted) return; + + await ref.read(authProvider.notifier).updateUserFromResponse(res); + if (context.mounted) showToast(context, '头像已更新', icon: 'check'); + } on ApiException catch (e) { + if (context.mounted) showToast(context, e.message, error: true); + } catch (e) { + if (context.mounted) showToast(context, '换头像失败:$e', error: true); + } + } + // ---------------- 账户卡 ---------------- Widget _accountCard(BuildContext context, WidgetRef ref, AsyncValue accountAsync) { return Container( diff --git a/lib/providers/ai_provider.dart b/lib/providers/ai_provider.dart index 0cf1527..511b651 100644 --- a/lib/providers/ai_provider.dart +++ b/lib/providers/ai_provider.dart @@ -4,7 +4,6 @@ import '../api/ai_api.dart'; import '../core/network/api_exception.dart'; import '../models/ai.dart'; import 'ai_config_provider.dart'; -import 'ai_persona_provider.dart'; import 'bill_refresh.dart'; import 'note_provider.dart'; @@ -36,6 +35,15 @@ class AssistantState { /// 正在拉历史消息 final bool loadingHistory; + /// 当前这次对话锁定的**人设 id**。null = 内置默认人设。 + /// + /// 这是「一次对话从头到尾是同一个人」的实现点: + /// · 新建会话时定下来(用户在选人设弹层里选的,或默认用全局那个) + /// · 打开历史会话时从会话记录里读出来 + /// · 之后每次 send 都用它,**不再看全局的 selectedAiPersonaProvider** —— + /// 否则用户在设置页切一下人设,正在聊的这轮就换性格了 + final int? personaId; + const AssistantState({ this.sessionId, this.bubbles = const [], @@ -43,6 +51,7 @@ class AssistantState { this.activeTool, this.error, this.loadingHistory = false, + this.personaId, }); /// 真的一句都没有(不是加载中、也不是出错) @@ -55,6 +64,7 @@ class AssistantState { Object? activeTool = _sentinel, Object? error = _sentinel, bool? loadingHistory, + Object? personaId = _sentinel, }) { return AssistantState( sessionId: sessionId == _sentinel ? this.sessionId : sessionId as int?, @@ -63,6 +73,7 @@ class AssistantState { activeTool: activeTool == _sentinel ? this.activeTool : activeTool as String?, error: error == _sentinel ? this.error : error as String?, loadingHistory: loadingHistory ?? this.loadingHistory, + personaId: personaId == _sentinel ? this.personaId : personaId as int?, ); } @@ -99,16 +110,23 @@ class AssistantNotifier extends Notifier { // 会话列表本来就是按最近活跃倒序,第一条就是「上一次的对话」 final sessions = await ref.read(aiApiProvider).sessions(); if (!ref.mounted || sessions.isEmpty) return; - await openSession(sessions.first.id); + // 带上这个人设 —— 恢复出来的对话得用它自己绑定的那个,不是全局偏好 + await openSession(sessions.first.id, personaId: sessions.first.personaId); } catch (_) { // 拉不到就算了,用户还能从「历史对话」里自己找 } } - /// 新对话:清空界面。后端那条旧会话还在,能从历史里翻出来。 - void newChat() { + /// 新对话。 + /// + /// [personaId] 是这次对话要用的人设,**传了就锁定**,之后这轮不会再变。 + /// 传 null 表示用内置默认人设。 + /// + /// 注意这里**不传全局的 selectedAiPersonaProvider** —— 那是「默认选哪套」的偏好, + /// 由调用方(页面上的选人设弹层)决定要不要用它兜底。 + void newChat({int? personaId}) { _autoRestored = true; - state = const AssistantState(); + state = AssistantState(personaId: personaId); } void clearError() { @@ -116,19 +134,28 @@ class AssistantNotifier extends Notifier { } /// 打开历史会话 - Future openSession(int sessionId) async { - state = AssistantState(sessionId: sessionId, loadingHistory: true); + /// + /// 人设从**会话记录**里读,而不是用全局偏好 —— 三个月前那次对话是谁聊的, + /// 现在打开还得是谁,不能因为你现在改了默认人设就变。 + Future openSession(int sessionId, {int? personaId}) async { + state = AssistantState( + sessionId: sessionId, + personaId: personaId, + loadingHistory: true, + ); try { final items = await ref.read(aiApiProvider).messages(sessionId); if (!ref.mounted) return; state = AssistantState( sessionId: sessionId, + personaId: personaId, bubbles: items.map(ChatBubble.fromItem).toList(), ); } catch (e) { if (!ref.mounted) return; state = AssistantState( sessionId: sessionId, + personaId: personaId, error: e is ApiException ? e.message : '消息加载失败', ); } @@ -159,10 +186,15 @@ class AssistantNotifier extends Notifier { // 这次对话动过哪些数据 final mutated = <_Mutated>{}; try { - // 带上用户选中的配置。null = 用服务端 .env 那套(虚拟的「默认」项) + // 模型还是全局偏好(换模型是想马上生效的) final configId = ref.read(selectedAiConfigProvider); - // 人设是独立的另一套。null = 用服务端内置的默认人设 - final personaId = ref.read(selectedAiPersonaProvider); + + // 人设**不用全局偏好,用这次对话锁定的那个**。 + // + // 这是「一个会话里始终是同一个人设」的关键:state.personaId 在新建会话 + // 或打开历史会话时就定了,中途用户在设置页怎么换都不影响正在聊的这轮。 + // 想换人设得开新会话 —— 这正是需求要的语义。 + final personaId = state.personaId; await for (final event in ref.read(aiApiProvider).chat( sessionId: state.sessionId, configId: configId, diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart index dca20ec..7189557 100644 --- a/lib/providers/auth_provider.dart +++ b/lib/providers/auth_provider.dart @@ -41,11 +41,26 @@ class AuthNotifier extends Notifier { final user = UserInfo( username: response.username ?? '', nickname: response.nickname ?? response.username ?? '', + avatar: response.avatar, ); await LocalStorage.instance.setAuth(token: response.token, userInfo: user.toJson()); state = AuthState(token: response.token, user: user); } + /// 改完资料后刷新本地缓存的用户信息。 + /// + /// 不发新 token —— 后端返回的 [AuthResponse.token] 是 null, + /// 所以这里保留原来的 token,只换 user。 + Future updateUserFromResponse(AuthResponse response) async { + final user = UserInfo( + username: response.username ?? state.user?.username ?? '', + nickname: response.nickname ?? state.user?.nickname ?? '', + avatar: response.avatar, + ); + await LocalStorage.instance.setUserInfo(user.toJson()); + state = AuthState(token: state.token, user: user); + } + Future logout() async { await LocalStorage.instance.clearAuth(); state = const AuthState.initial(); diff --git a/lib/widgets/avatar.dart b/lib/widgets/avatar.dart new file mode 100644 index 0000000..96f2140 --- /dev/null +++ b/lib/widgets/avatar.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; + +import '../core/network/api_env.dart'; +import 'cute_icon.dart'; + +/// 圆形头像。全 App 只有这一个头像组件 —— 人设、用户、「我的」页面、聊天气泡都用它。 +/// +/// 三种输入都要兜住: +/// · [assetPath] 为空 → 画默认图标([fallbackIcon]) +/// · 图片加载中 → 显示一个淡淡的占位圆 +/// · 加载失败(后端文件被删了 / 网络断了)→ 退回默认图标,不显示裂图 +/// +/// 为什么不用 `CircleAvatar`:它的 backgroundImage 出错时不好接管, +/// 会有「裂图 + 背景色」的难看状态。自己用 [ClipOval] + [Image.network] 能完全控制。 +class Avatar extends StatelessWidget { + const Avatar({ + super.key, + this.assetPath, + this.size = 40, + this.fallbackIcon = 'user', + this.fallbackColor = const Color(0xFFC8956E), + this.backgroundColor = const Color(0xFFFFF3E6), + }); + + /// 后端返回的**相对路径**(如 `persona/2026/09/29/xxx.jpg`)。 + /// 传空或 null → 用默认图标。 + final String? assetPath; + + final double size; + + /// 没有头像时显示哪个小图标(`CuteIcon` 的名字) + final String fallbackIcon; + + /// 默认图标的颜色 + final Color fallbackColor; + + /// 头像底圈的颜色 + final Color backgroundColor; + + @override + Widget build(BuildContext context) { + final path = assetPath?.trim() ?? ''; + + return Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: backgroundColor, + ), + clipBehavior: Clip.antiAlias, + child: path.isEmpty ? _fallback() : _image(path), + ); + } + + Widget _fallback() { + return Center( + child: CuteIcon(fallbackIcon, size: size * 0.58), + ); + } + + Widget _image(String path) { + return Image.network( + resolveAssetUrl('/uploads/$path'), + fit: BoxFit.cover, + // 头像显示尺寸很小,限一下解码尺寸省内存 —— 原图可能是 400px + cacheWidth: (size * 3).round(), + loadingBuilder: (context, child, progress) { + // 还没加载出来时给个纯色圆,比空白或转圈干净 + if (progress == null) return child; + return const SizedBox.shrink(); + }, + errorBuilder: (context, _, _) { + // 后端文件被删了也会走到这 —— 退回默认图标,不留裂图 + return _fallback(); + }, + ); + } +} diff --git a/test/ai_persona_layout_test.dart b/test/ai_persona_layout_test.dart index 729eeff..c9a5feb 100644 --- a/test/ai_persona_layout_test.dart +++ b/test/ai_persona_layout_test.dart @@ -66,6 +66,7 @@ class _FakeAiApi extends AiApi { Future createPersona({ required String name, required String systemPrompt, + String? avatar, }) async => AiPersonaItem( id: 99, @@ -75,6 +76,7 @@ class _FakeAiApi extends AiApi { isDefault: false, editable: true, defaultPrompt: null, + avatar: avatar, ); } diff --git a/test/ai_session_persona_test.dart b/test/ai_session_persona_test.dart new file mode 100644 index 0000000..094be23 --- /dev/null +++ b/test/ai_session_persona_test.dart @@ -0,0 +1,155 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:qiangbao_accounting/api/ai_api.dart'; +import 'package:qiangbao_accounting/models/ai.dart'; +import 'package:qiangbao_accounting/models/ai_persona.dart'; +import 'package:qiangbao_accounting/providers/ai_provider.dart'; +import 'package:qiangbao_accounting/providers/ai_persona_provider.dart'; + +/// 「会话内人设固定」的回归测试。 +/// +/// 这是这次需求里**最容易悄悄坏掉**的一条:人设现在是「每轮 send 时现读」还是 +/// 「建会话时定下」,代码上只差一行 —— 但坏掉的表现是「聊到一半助手换了性格」, +/// 而且要用户真的去设置页切一次人设才能发现。 +/// +/// 所以这里直接断言「send 时带上的是 state.personaId,不是全局偏好」。 +class _CapturingAiApi extends AiApi { + _CapturingAiApi() : super(Dio()); + + /// 每次 chat 调用时带上的 personaId,按调用顺序记下来 + final List personaIdsSent = []; + + @override + Stream chat({ + int? sessionId, + int? configId, + int? personaId, + required String content, + }) async* { + personaIdsSent.add(personaId); + // 模拟一个最小可用的回答,让 send() 能正常走完 + yield const AiEvent.delta('好'); + yield const AiEvent.done(1); + } + + @override + Future> sessions() async => const []; + + @override + Future> personas() async => const []; +} + +void main() { + // LocalStorage 是单例,select() 会写盘。测试里给它一个空实现, + // 否则会去真的要 SharedPreferences(在纯单测环境里拿不到) + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + + ProviderContainer makeContainer(_CapturingAiApi api) { + final c = ProviderContainer(overrides: [ + aiApiProvider.overrideWithValue(api), + ]); + addTearDown(c.dispose); + return c; + } + + group('★ 会话内人设固定', () { + test('新对话选的人设会一直用下去(中途全局偏好变了也不受影响)', () async { + final api = _CapturingAiApi(); + final c = makeContainer(api); + final notifier = c.read(assistantProvider.notifier); + + // 用户在「新对话」弹层里选了人设 7 + notifier.newChat(personaId: 7); + expect(c.read(assistantProvider).personaId, 7); + + // 中途用户在设置页把全局偏好换成了 9 + c.read(selectedAiPersonaProvider.notifier).select(9); + + // 继续在这个会话里说话 + await notifier.send('第一句'); + await notifier.send('第二句'); + + // 两次都该用 7,不该跟着全局偏好变成 9 + expect(api.personaIdsSent, [7, 7], + reason: '会话锁定的人设不该被全局偏好覆盖'); + }); + + test('新对话不选人设时用 null(内置默认)', () async { + final api = _CapturingAiApi(); + final c = makeContainer(api); + final notifier = c.read(assistantProvider.notifier); + + notifier.newChat(); + expect(c.read(assistantProvider).personaId, isNull); + + await notifier.send('随便说点'); + + expect(api.personaIdsSent, [null]); + }); + + test('newChat 会清掉上一个人设(不残留)', () async { + final api = _CapturingAiApi(); + final c = makeContainer(api); + final notifier = c.read(assistantProvider.notifier); + + notifier.newChat(personaId: 3); + expect(c.read(assistantProvider).personaId, 3); + + notifier.newChat(); + expect(c.read(assistantProvider).personaId, isNull, + reason: '第二次新对话没选人设,不该残留上一次的'); + }); + + test('打开历史会话时用那条会话绑定的人设', () async { + final api = _CapturingAiApi(); + final c = makeContainer(api); + final notifier = c.read(assistantProvider.notifier); + + // 打开一个三个月前的会话,它绑的是人设 5 + await notifier.openSession(42, personaId: 5); + expect(c.read(assistantProvider).personaId, 5); + expect(c.read(assistantProvider).sessionId, 42); + + // 在这条历史会话里继续说话,仍然用 5 + await notifier.send('接着聊'); + + expect(api.personaIdsSent, [5]); + }); + }); + + group('ChatSessionItem 解析(会话列表带人设)', () { + test('完整字段', () { + final s = ChatSessionItem.fromJson({ + 'id': 1, + 'title': '聊记账', + 'updateTime': '2026-09-29 10:00:00', + 'personaId': 7, + 'personaName': '花花', + 'personaAvatar': 'persona/a.jpg', + }); + + expect(s.id, 1); + expect(s.personaId, 7); + expect(s.personaName, '花花'); + expect(s.personaAvatar, 'persona/a.jpg'); + }); + + test('★ 缺字段不崩(老后端 / 人设已删)', () { + final s = ChatSessionItem.fromJson({'id': 1, 'title': 'x'}); + + expect(s.personaId, isNull); + expect(s.personaName, '默认人设'); + // 头像空串归一成 null,Avatar 组件据此显示默认图标 + expect(s.personaAvatar, ''); + }); + + test('人设名缺失时回落成「默认人设」,不显示空白', () { + final s = ChatSessionItem.fromJson({'id': 1, 'personaName': ''}); + expect(s.personaName, '默认人设'); + }); + }); +} diff --git a/test/avatar_test.dart b/test/avatar_test.dart new file mode 100644 index 0000000..c813188 --- /dev/null +++ b/test/avatar_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:qiangbao_accounting/widgets/avatar.dart'; + +/// 头像组件的测试。 +/// +/// 重点是**不崩**和**不留裂图** —— 头像是从后端 /uploads 拉的, +/// 文件被删、地址写错、网络断了都会走到 errorBuilder, +/// 那时候必须退回默认图标,而不是显示一个裂开的图。 +void main() { + Widget wrap(Widget child) => MaterialApp(home: Scaffold(body: child)); + + group('Avatar 兜底', () { + testWidgets('assetPath 为 null 时显示默认图标,不发起网络请求', (tester) async { + await tester.pumpWidget(wrap(const Avatar(size: 40))); + await tester.pump(); + + // 没有 Image 组件 —— 说明压根没去加载图片 + expect(find.byType(Image), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('assetPath 为空串时也走默认图标', (tester) async { + await tester.pumpWidget(wrap(const Avatar(assetPath: '', size: 40))); + await tester.pump(); + + expect(find.byType(Image), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('只有空白字符的路径也当没有(后端可能返回 " ")', (tester) async { + await tester.pumpWidget(wrap(const Avatar(assetPath: ' ', size: 40))); + await tester.pump(); + + expect(find.byType(Image), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('★ 给了路径就渲染图片,且加载失败时不崩(退回默认图标)', (tester) async { + await tester.pumpWidget(wrap( + const Avatar(assetPath: 'persona/2026/09/29/x.jpg', size: 40), + )); + await tester.pump(); + + expect(find.byType(Image), findsOneWidget); + + // 测试环境里网络请求必然失败,会走 errorBuilder。 + // 这里断言的是「失败被接住了,没有把异常抛到 widget 树」 + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.takeException(), isNull, + reason: '图片加载失败必须被 errorBuilder 接住,不能抛异常'); + }); + }); + + group('Avatar 尺寸', () { + testWidgets('尺寸按传入值设置(聊天气泡和「我的」页面用不同大小)', (tester) async { + await tester.pumpWidget(wrap(const Avatar(size: 36))); + await tester.pump(); + + final container = tester.widget(find.byType(Container).first); + final size = container.constraints; + expect(size?.maxWidth, 36); + expect(size?.maxHeight, 36); + }); + + testWidgets('不同尺寸都不溢出', (tester) async { + for (final s in [24.0, 36.0, 40.0, 60.0, 76.0]) { + await tester.pumpWidget(wrap(Avatar(size: s))); + await tester.pump(); + expect(tester.takeException(), isNull, reason: '尺寸 $s 出问题了'); + } + }); + + testWidgets('在很窄的容器里也不溢出', (tester) async { + await tester.pumpWidget(wrap( + const SizedBox(width: 40, height: 40, child: Avatar(size: 36)), + )); + await tester.pump(); + expect(tester.takeException(), isNull); + }); + }); +}