diff --git a/lib/app/app.dart b/lib/app/app.dart index b84eef5..d1fe75a 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -9,6 +9,7 @@ import 'package:patbond_flutter/app/app_localization.dart'; import 'package:patbond_flutter/core/network/api_client.dart'; import 'package:patbond_flutter/core/network/token_refresher.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/avatar_upload_sheet.dart'; import 'package:patbond_flutter/features/auth/auth_repository.dart'; import 'package:patbond_flutter/features/auth/login_page.dart'; import 'package:patbond_flutter/features/auth/session_manager.dart'; @@ -24,6 +25,7 @@ import 'package:patbond_flutter/features/pets/health_record_analytics.dart'; import 'package:patbond_flutter/features/pets/pet_analytics.dart'; import 'package:patbond_flutter/features/pets/pets_controller.dart'; import 'package:patbond_flutter/features/pets/pets_repository.dart'; +import 'package:patbond_flutter/features/profile/profile_controller.dart'; import 'package:patbond_flutter/state/app_state.dart'; class App extends StatefulWidget { @@ -34,6 +36,7 @@ class App extends StatefulWidget { this.petsRepository, this.communityRepository, this.mediaUploaderFactory, + this.avatarUploaderFactory, }); /// 测试注入口;生产默认走安全存储 + 真实 API。 @@ -45,6 +48,11 @@ class App extends StatefulWidget { /// 发布页媒体上传器构造口(桌面实测替换选图/压缩层;生产为 null)。 final MediaUploaderFactory? mediaUploaderFactory; + /// 头像上传器构造口(T3.5-08/09)。生产为 null → 走 + /// [defaultAvatarUploader];桌面实测与集成测试在此替换选图/压缩层 + /// (Linux 桌面无 image_picker / 压缩原生实现),网络三段仍是生产实现。 + final AvatarUploaderFactory? avatarUploaderFactory; + @override State createState() => _AppState(); } @@ -55,6 +63,8 @@ class _AppState extends State { late final AuthRepository authRepository; late final PetsController petsController; late final CommunityController communityController; + late final ProfileController profileController; + late final AvatarUploaderBuilder _avatarUploaderBuilder; late final PetAnalytics petAnalytics; late final HealthRecordAnalytics healthRecordAnalytics; late final FeedAnalytics feedAnalytics; @@ -108,10 +118,24 @@ class _AppState extends State { // T3-14:Feed segment 接线主壳(数据层 T3-12 就位);T3-16 起 // 点赞/收藏成功埋点经 interactionAnalytics 在 controller 内上报。 interactionAnalytics = CommunityInteractionAnalytics(_analytics.trackEvent); + final communityRepository = + widget.communityRepository ?? _buildCommunityRepository(); communityController = CommunityController( - repository: widget.communityRepository ?? _buildCommunityRepository(), + repository: communityRepository, interactionAnalytics: interactionAnalytics, ); + // T3.5-08/10:资料与统计单例,资料 Tab 与首页问候语共用(一次 /me + // 供两个消费点,改昵称后两处同时变)。 + profileController = ProfileController( + authRepository: authRepository, + communityRepository: communityRepository, + ); + // 头像上传:purpose 由调用页给定(user_avatar / pet_avatar)。仓库复用 + // community 仓库——media 两步上传端点由 user 服务提供,已在 + // _buildCommunityRepository 里单独接线(mediaApi)。 + final avatarFactory = widget.avatarUploaderFactory ?? defaultAvatarUploader; + _avatarUploaderBuilder = (purpose) => + avatarFactory(communityRepository, purpose); petAnalytics = PetAnalytics(_analytics.trackEvent); healthRecordAnalytics = HealthRecordAnalytics(_analytics.trackEvent); feedAnalytics = FeedAnalytics(_analytics.trackEvent); @@ -149,8 +173,19 @@ class _AppState extends State { session: sessionManager, refresher: refresher, ); + // `/api/v1/me` 由 user 服务(:8082)提供,auth(:8081)上没有该路由, + // 故单独接一条 user 线路(与 community 仓库的 mediaApi 同构)。 + final userApi = ApiClient( + dio: buildPatbondDio( + session: sessionManager, + baseUrl: patbondUserApiBaseUrl, + ), + session: sessionManager, + refresher: refresher, + ); return ApiAuthRepository( api: api, + userApi: userApi, session: sessionManager, refresher: refresher, analytics: _analytics, @@ -205,10 +240,12 @@ class _AppState extends State { } void _reportAuthStateChange() { - // 登出即清宠物档案与社区 Feed 内存副本(跨账号不泄漏;重登后重新拉取)。 + // 登出即清宠物档案、社区 Feed 与本人资料内存副本(跨账号不泄漏; + // 重登后重新拉取)。 if (sessionManager.status == AuthStatus.unauthenticated) { petsController.reset(); communityController.reset(); + profileController.reset(); } // 认证状态机切页补点(03 §3.2 非路由曝光 1/2) final page = switch (sessionManager.status) { @@ -227,6 +264,7 @@ class _AppState extends State { appState.dispose(); petsController.dispose(); communityController.dispose(); + profileController.dispose(); if (widget.sessionManager == null) sessionManager.dispose(); super.dispose(); } @@ -250,6 +288,7 @@ class _AppState extends State { appState: appState, petsController: petsController, communityController: communityController, + profileController: profileController, currentUserId: sessionManager.userId, petAnalytics: petAnalytics, healthRecordAnalytics: healthRecordAnalytics, @@ -257,6 +296,7 @@ class _AppState extends State { interactionAnalytics: interactionAnalytics, postAnalytics: postAnalytics, mediaUploaderFactory: widget.mediaUploaderFactory, + avatarUploaderBuilder: _avatarUploaderBuilder, pageViewTracker: _pageViewTracker, onLogout: authRepository.logout, ); diff --git a/lib/core/models/patch_field.dart b/lib/core/models/patch_field.dart new file mode 100644 index 0000000..555d319 --- /dev/null +++ b/lib/core/models/patch_field.dart @@ -0,0 +1,65 @@ +import 'package:flutter/foundation.dart'; + +/// PATCH 请求的**三态字段**:契约 v1.4.0 对「昵称 / 头像」两类天生可选的 +/// 字段定型为三态语义(`UpdateMeRequest` 与 `UpdatePetRequest.avatarAssetId`): +/// +/// | 态 | JSON 表现 | 服务端语义 | +/// | --- | --- | --- | +/// | [PatchField.absent] | **键不出现** | 不改 | +/// | [PatchField.clear] | 键出现且值为 `null` | 清空 | +/// | [PatchField.value] | 键出现且有值 | 设置 | +/// +/// Dart 的 `String?` 只有两态(有值 / null),无法区分「不改」与「清空」—— +/// 若把「不改」也编码为 `null`,未改动的字段会被服务端当成清空指令执行 +/// (用户只改昵称,头像就被顺手删了)。故三态必须由类型承载, +/// 不能靠 `T?` 加约定。 +/// +/// 序列化一律经 [writeTo]:它是「absent 不落键」这条纪律的唯一实现处, +/// 各请求 DTO 不自行拼 map,避免某处漏写 `isPresent` 判断。 +@immutable +class PatchField { + /// 不改:键不出现在 JSON 里。 + const PatchField.absent() : _present = false, _value = null; + + /// 清空:键出现且值为 `null`。 + const PatchField.clear() : _present = true, _value = null; + + /// 设置为 [value]。 + const PatchField.value(T value) : _present = true, _value = value; + + final bool _present; + final T? _value; + + /// 本次 PATCH 是否触及该字段(决定键是否落进 JSON)。 + bool get isPresent => _present; + + /// 是否为「显式清空」(present 且值为 null)。 + bool get isClear => _present && _value == null; + + /// present 且有值时的值;absent 与 clear 均为 null(两者不可由此区分)。 + T? get valueOrNull => _value; + + /// 按三态把自己写进 [json]:absent 不落键;clear 落 `null`; + /// 有值时落 [encode] 的产物(缺省原样写入)。 + void writeTo( + Map json, + String key, { + Object? Function(T value)? encode, + }) { + if (!_present) return; + final value = _value; + json[key] = value == null ? null : (encode?.call(value) ?? value); + } + + @override + bool operator ==(Object other) => + other is PatchField && + other._present == _present && + other._value == _value; + + @override + int get hashCode => Object.hash(_present, _value); + + @override + String toString() => _present ? 'PatchField($_value)' : 'PatchField.absent()'; +} diff --git a/lib/core/widgets/avatar_upload_sheet.dart b/lib/core/widgets/avatar_upload_sheet.dart new file mode 100644 index 0000000..007d6fe --- /dev/null +++ b/lib/core/widgets/avatar_upload_sheet.dart @@ -0,0 +1,271 @@ +import 'package:flutter/material.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/features/community/community_models.dart'; +import 'package:patbond_flutter/features/community/community_repository.dart'; +import 'package:patbond_flutter/features/community/media_uploader.dart'; + +/// 页面侧的构造口:调用页只知道「用途」,仓库与选图/压缩层的装配由 +/// `app.dart` 在装配处完成。 +/// +/// 缺省为 null 时页面**不渲染头像上传入口**:意为「本次构建未装配上传能力」, +/// 而不是「有入口但点了没反应」。生产装配恒注入(见 `app.dart`)。 +typedef AvatarUploaderBuilder = MediaUploader Function(MediaPurpose purpose); + +/// App 级注入口(沿 `MediaUploaderFactory` 先例:工厂拿到已装配的仓库)。 +/// +/// 生产缺省 [defaultAvatarUploader];**桌面实测与集成测试**在此替换选图与 +/// 压缩两层——Linux 桌面既无 image_picker 也无 flutter_image_compress 的 +/// 原生实现,而 createUpload / 预签名 PUT 直传 / confirm 三段仍走生产实现。 +typedef AvatarUploaderFactory = + MediaUploader Function( + CommunityRepository repository, + MediaPurpose purpose, + ); + +/// 生产缺省头像上传器:单图、单并发(头像没有批量语义)。 +MediaUploader defaultAvatarUploader( + CommunityRepository repository, + MediaPurpose purpose, +) => MediaUploader( + repository: repository, + purpose: purpose, + maxImages: 1, + maxConcurrentUploads: 1, +); + +/// 弹出头像上传流程,返回**ready 的 assetId**;用户取消或未走到 ready +/// 即 null(孤儿防护:未 confirm 的 asset 绝不外露,见 [MediaUploader])。 +Future showAvatarUploadSheet( + BuildContext context, { + required AvatarUploaderBuilder builder, + required MediaPurpose purpose, + String title = '更换头像', +}) { + return showModalBottomSheet( + context: context, + useSafeArea: true, + isScrollControlled: true, + showDragHandle: true, + builder: (context) => + AvatarUploadSheet(uploader: builder(purpose), title: title), + ); +} + +/// 头像上传 sheet:复用发布页的 [MediaUploader] 六态编排(queued / +/// compressing / uploading / confirming / ready / failed),单图上限。 +/// +/// 与九宫格的差异只在呈现:这里一次只有一张图,故用整幅预览 + 线性进度 +/// 条,而不是格内 [UploadProgressOverlay];状态机、凭据过期换新、失败可 +/// 重试、孤儿防护全部沿用编排器,本组件不复制任何上传逻辑。 +/// +/// **ready 后仍需用户点「使用这张」**:上传成功不等于用户满意这张图, +/// 自动关闭会剥夺预览确认的机会(头像是长期可见的身份标识)。 +class AvatarUploadSheet extends StatefulWidget { + const AvatarUploadSheet({ + required this.uploader, + required this.title, + super.key, + }); + + /// 本次会话专属的上传器(purpose 与 maxImages=1 由构造口设定); + /// 关闭 sheet 即 dispose,在途请求经 [MediaUploader.reset] 作废。 + final MediaUploader uploader; + + final String title; + + @override + State createState() => _AvatarUploadSheetState(); +} + +class _AvatarUploadSheetState extends State { + MediaUploader get _uploader => widget.uploader; + + @override + void initState() { + super.initState(); + _uploader.addListener(_onChanged); + // 打开即拉起选择器:sheet 的唯一目的就是选一张图,多一次「选择图片」 + // 点击是纯摩擦。用户在系统选择器里取消后回落到空态(可再次选择)。 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _uploader.pickAndAdd(); + }); + } + + void _onChanged() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + _uploader.removeListener(_onChanged); + // 在途任务按 cancelled 收敛(未 confirm 的服务端 asset 弃引用, + // 由服务端超时清理兜底)。 + _uploader + ..reset() + ..dispose(); + super.dispose(); + } + + Future _reselect() async { + // 换图前先清空:maxImages=1 时不清空则 remainingSlots=0,选择器不会拉起。 + _uploader.reset(); + await _uploader.pickAndAdd(); + } + + @override + Widget build(BuildContext context) { + final items = _uploader.items; + final item = items.isEmpty ? null : items.first; + return Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(widget.title, style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 16), + if (item != null) _preview(item), + if (item != null) const SizedBox(height: 14), + ..._body(item), + ], + ), + ); + } + + /// 预览:原图字节直接解码(不落盘),圆形裁切以贴合头像最终形态。 + Widget _preview(MediaUploadItem item) { + return Center( + child: ClipOval( + child: Image.memory( + item.previewBytes, + width: 132, + height: 132, + fit: BoxFit.cover, + errorBuilder: (context, error, stack) => Container( + width: 132, + height: 132, + color: AppColors.surfaceTint, + child: const Icon(Icons.image_outlined, color: AppColors.muted), + ), + ), + ), + ); + } + + List _body(MediaUploadItem? item) { + if (item == null) { + // 选择器拉起中 / 用户取消后的空态。 + return _uploader.isPicking + ? const [_BusyLine(label: '正在打开相册…')] + : [ + const Text( + '还没有选择图片', + textAlign: TextAlign.center, + style: TextStyle(color: AppColors.inkSoft, fontSize: 13), + ), + const SizedBox(height: 14), + FilledButton.icon( + onPressed: _reselect, + icon: const Icon(Icons.photo_library_outlined, size: 18), + label: const Text('选择图片'), + ), + ]; + } + switch (item.phase) { + case MediaItemPhase.queued: + case MediaItemPhase.compressing: + return const [_BusyLine(label: '正在处理图片…')]; + case MediaItemPhase.uploading: + return [ + _ProgressLine(progress: item.progress), + const SizedBox(height: 10), + Text( + '上传中 ${(item.progress * 100).round()}%', + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.inkSoft, fontSize: 13), + ), + ]; + case MediaItemPhase.confirming: + return const [ + _ProgressLine(progress: 1), + SizedBox(height: 10), + Text( + '正在确认…', + textAlign: TextAlign.center, + style: TextStyle(color: AppColors.inkSoft, fontSize: 13), + ), + ]; + case MediaItemPhase.ready: + return [ + FilledButton( + onPressed: () => Navigator.of(context).pop(item.assetId), + child: const Text('使用这张'), + ), + const SizedBox(height: 8), + TextButton(onPressed: _reselect, child: const Text('重新选择')), + ]; + case MediaItemPhase.failed: + return [ + Text( + item.errorMessage ?? '上传失败', + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.errorDark, fontSize: 13), + ), + const SizedBox(height: 12), + // 不可重试(如压缩后仍超 10 MB)只给「重新选择」——重试同一张 + // 必然再失败,给重试钮是误导。 + if (item.retryable) + FilledButton( + onPressed: () => _uploader.retry(item.localId), + child: const Text('重试'), + ), + if (item.retryable) const SizedBox(height: 8), + TextButton(onPressed: _reselect, child: const Text('重新选择')), + ]; + } + } +} + +class _BusyLine extends StatelessWidget { + const _BusyLine({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 10), + Text( + label, + style: const TextStyle(color: AppColors.inkSoft, fontSize: 13), + ), + ], + ); + } +} + +class _ProgressLine extends StatelessWidget { + const _ProgressLine({required this.progress}); + + final double progress; + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(AppRadius.pill)), + child: LinearProgressIndicator( + value: progress, + minHeight: 6, + backgroundColor: AppColors.surfaceTint, + color: AppColors.primary, + ), + ); + } +} diff --git a/lib/features/auth/auth_models.dart b/lib/features/auth/auth_models.dart index 223ba2f..1a65758 100644 --- a/lib/features/auth/auth_models.dart +++ b/lib/features/auth/auth_models.dart @@ -1,4 +1,10 @@ /// 认证接口的响应模型(接口契约冻结稿,字段名与后端一致)。 +library; + +import 'package:patbond_flutter/core/models/patch_field.dart'; + +export 'package:patbond_flutter/core/models/patch_field.dart'; + class AuthTokens { const AuthTokens({ required this.userId, @@ -32,11 +38,16 @@ class AuthTokens { final DateTime refreshTokenExpiresAt; } -/// `GET /api/v1/me` 的用户资料。 +/// `GET` / `PATCH /api/v1/me` 的用户资料(契约 v1.4.0 恰好这 6 个字段)。 +/// +/// **不含 `avatarAssetId`**:客户端对头像 asset 只写不读,「有头像」等价于 +/// [avatarUrl] 非 null。 class UserProfile { const UserProfile({ required this.userId, required this.username, + required this.nickname, + required this.avatarUrl, required this.phone, required this.createdAt, }); @@ -45,6 +56,11 @@ class UserProfile { return UserProfile( userId: json['userId'] as String, username: json['username'] as String, + // DB 原值:服务端**刻意不做 username 回退**(v1.4.0 定型,理由见 + // [displayName]),未设置即 null。 + nickname: json['nickname'] as String?, + // 时效性预签名 GET URL,每次响应现签;不得持久化、过期即重取。 + avatarUrl: json['avatarUrl'] as String?, // 服务端可返回 null(历史数据或未来第三方注册),不得非空强转。 phone: json['phone'] as String?, createdAt: DateTime.parse(json['createdAt'] as String), @@ -53,6 +69,57 @@ class UserProfile { final String userId; final String username; + + /// 昵称 DB 原值,未设置为 null。**编辑态预填只能用它**,不能用 + /// [displayName]——否则保存时会把展示回退值固化成真实昵称。 + final String? nickname; + + /// 头像预签名 GET URL(会过期,禁止入本地存储);无头像 / asset 非 + /// ready / 对象存储未配置三种情况均为 null。 + final String? avatarUrl; + final String? phone; final DateTime createdAt; + + /// 本人视角的展示名:`nickname ?? username`。 + /// + /// **回退刻意做在客户端展示层**:服务端 `/me` 返回 DB 原值不回退(契约 + /// v1.4.0 §Me),因为 `/me` 是本人编辑态——若服务端回退,编辑页会把 + /// `llx` 预填进昵称框,用户误以为设过昵称,下次保存即把这个展示约定 + /// **固化成真实数据**,`/internal/users/profiles` 的 SQL 回退链从此再不 + /// 触发。他人视角(Feed 作者名)的回退由服务端 SQL 层承担,客户端不重复。 + String get displayName => nickname ?? username; + + /// 是否已有头像(响应不回显 avatarAssetId,判定只看 URL 是否现签成功)。 + bool get hasAvatar => avatarUrl != null; +} + +/// `PATCH /api/v1/me` 请求体(契约 v1.4.0,**三态语义**: +/// 键缺省 = 不改 / 显式 `null` = 清空 / 给值 = 设置)。 +/// +/// 两字段都 absent 即**空 patch**——服务端答 400/40000 而非静默 200, +/// 故调用前应以 [isEmpty] 短路(无变更不发请求)。 +/// +/// 昵称的服务端校验:btrim 后长度按**码点**计 1~32;纯空白或空串是 +/// 400/40000 而**不是**隐式清空(清空只走 [PatchField.clear] 一条路)。 +class UpdateMeRequest { + const UpdateMeRequest({ + this.nickname = const PatchField.absent(), + this.avatarAssetId = const PatchField.absent(), + }); + + final PatchField nickname; + + /// 头像 asset(两步上传产物,`purpose` 须为 `user_avatar`)。 + final PatchField avatarAssetId; + + /// 本次 patch 未触及任何字段(发出去必得 400/40000)。 + bool get isEmpty => !nickname.isPresent && !avatarAssetId.isPresent; + + Map toJson() { + final json = {}; + nickname.writeTo(json, 'nickname'); + avatarAssetId.writeTo(json, 'avatarAssetId'); + return json; + } } diff --git a/lib/features/auth/auth_repository.dart b/lib/features/auth/auth_repository.dart index 735d453..85df26a 100644 --- a/lib/features/auth/auth_repository.dart +++ b/lib/features/auth/auth_repository.dart @@ -28,18 +28,33 @@ abstract class AuthRepository { Future restoreSession(); Future me(); + + /// 本人资料部分更新(昵称 / 头像,三态语义见 [UpdateMeRequest])。 + /// 成功返回**更新后的全量 [UserProfile]**(服务端回显,含现签 avatarUrl)。 + Future updateMe(UpdateMeRequest request); } +/// 基于 [ApiClient] 的实现。 +/// +/// **端口线路**(ADR-002 无网关,分端口直连):注册/登录/登出/刷新走 auth +/// 服务(:8081);`GET` 与 `PATCH /api/v1/me` 由 **user 服务**(:8082 +/// `MeController`)提供,故经 [userApi] 直连——auth 上没有 `/api/v1/me` +/// 路由,走主客户端会得到 404。未提供 [userApi] 时回落主客户端(既有测试 +/// 桩场景)。同一线路分离手法与 `ApiCommunityRepository` 的 `mediaApi` 一致。 class ApiAuthRepository implements AuthRepository { ApiAuthRepository({ - required this._api, + required ApiClient api, required this._session, required this._refresher, + ApiClient? userApi, this._analytics, this._uuid = const Uuid(), - }); + }) : _api = api, + // 缺省回落主客户端:既有测试桩只注入一个 ApiClient。 + _userApi = userApi ?? api; final ApiClient _api; + final ApiClient _userApi; final SessionManager _session; final TokenRefresher _refresher; final AnalyticsService? _analytics; @@ -132,11 +147,25 @@ class ApiAuthRepository implements AuthRepository { @override Future me() async { - final data = await _api.request( + final data = await _userApi.request( '/api/v1/me', method: 'GET', requiresAuth: true, ); return UserProfile.fromJson(data! as Map); } + + @override + Future updateMe(UpdateMeRequest request) async { + // 无乐观锁、无幂等键(契约定型:唯一合法写者 + 列级选择性 UPDATE, + // 同 body 重放天然幂等)。三态由 request.toJson 表达——「不改」的字段 + // 根本不出现在 body 里,绝不发成 null。 + final data = await _userApi.request( + '/api/v1/me', + method: 'PATCH', + body: request.toJson(), + requiresAuth: true, + ); + return UserProfile.fromJson(data! as Map); + } } diff --git a/lib/features/community/community_models.dart b/lib/features/community/community_models.dart index fbe7462..5746fb8 100644 --- a/lib/features/community/community_models.dart +++ b/lib/features/community/community_models.dart @@ -60,9 +60,14 @@ enum MediaKind { _enumFromJson(values, value, 'kind'); } -/// 上传用途白名单(M3 定型仅 post_image,决定 objectKey 前缀)。 +/// 上传用途白名单(契约 v1.4.0 三值;决定 objectKey 前缀)。 +/// +/// **用途即引用侧的类型检查**:引用时服务端校验 purpose 相符,故帖图不能 +/// 当头像、两种头像也互不通用(不符者 404/40405)。 enum MediaPurpose { - postImage('post_image'); + postImage('post_image'), + userAvatar('user_avatar'), + petAvatar('pet_avatar'); const MediaPurpose(this.wire); @@ -628,3 +633,25 @@ class FollowStats { final int followingCount; final bool followedByMe; } + +/// 我的社区数字(`GET /api/v1/me/community-stats`,契约 v1.4.0 新增)。 +/// +/// 两数同一集合:本人的、已发布的、未软删的帖(草稿 / 软删 / hidden / +/// archived 均不计;自己赞自己**计入**,与帖子详情的 likeCount 同口径)。 +/// 空数据为 `0` 而非 null,且该端点**永不 404**——任何已认证用户都有 stats。 +class CommunityStats { + const CommunityStats({ + required this.receivedLikeCount, + required this.publishedPostCount, + }); + + factory CommunityStats.fromJson(Map json) { + return CommunityStats( + receivedLikeCount: json['receivedLikeCount'] as int, + publishedPostCount: json['publishedPostCount'] as int, + ); + } + + final int receivedLikeCount; + final int publishedPostCount; +} diff --git a/lib/features/community/community_repository.dart b/lib/features/community/community_repository.dart index e4be060..db6fa9d 100644 --- a/lib/features/community/community_repository.dart +++ b/lib/features/community/community_repository.dart @@ -55,6 +55,9 @@ abstract class CommunityRepository { Future followUser(String userId); Future unfollowUser(String userId); Future getFollowStats(String userId); + + // ---- 我的社区数字(主体恒为调用者,路径上无 userId)---- + Future getMyCommunityStats(); } /// 基于 [ApiClient] 的实现。全部端点走 Bearer 鉴权(复用既有 token @@ -301,4 +304,12 @@ class ApiCommunityRepository implements CommunityRepository { final data = await _request('/api/v1/users/$userId/follow-stats'); return FollowStats.fromJson(_asMap(data)); } + + // ---- 我的社区数字 ---- + + @override + Future getMyCommunityStats() async { + final data = await _request('/api/v1/me/community-stats'); + return CommunityStats.fromJson(_asMap(data)); + } } diff --git a/lib/features/community/media_uploader.dart b/lib/features/community/media_uploader.dart index 82437c7..166f21d 100644 --- a/lib/features/community/media_uploader.dart +++ b/lib/features/community/media_uploader.dart @@ -131,6 +131,7 @@ class MediaUploader extends ChangeNotifier { MediaImageCompressor? compressor, MediaDirectUploadClient? directUpload, this._analytics, + this.purpose = MediaPurpose.postImage, this.maxImages = 9, this.maxConcurrentUploads = 2, this.maxByteSize = 10 * 1024 * 1024, @@ -151,7 +152,13 @@ class MediaUploader extends ChangeNotifier { final DateTime Function() _now; - /// 九宫格上限(05 号规范 §3.2)。 + /// 上传用途(决定服务端 objectKey 前缀,也是引用侧的类型检查依据): + /// 发布页 `post_image`、资料页头像 `user_avatar`、宠物头像 `pet_avatar`。 + /// 用途不符的 asset 在引用时被答 404/40405,所以这个值必须由调用方 + /// 按场景显式给对(M3.5-08/09 起本类不再只服务发布页)。 + final MediaPurpose purpose; + + /// 九宫格上限(05 号规范 §3.2);头像场景传 1。 final int maxImages; final int maxConcurrentUploads; @@ -467,7 +474,7 @@ class MediaUploader extends ChangeNotifier { return _repository.createMediaUpload( CreateMediaUploadRequest( kind: MediaKind.image, - purpose: MediaPurpose.postImage, + purpose: purpose, mimeType: compressed.mimeType, byteSize: compressed.byteSize, ), diff --git a/lib/features/main/main_shell_page.dart b/lib/features/main/main_shell_page.dart index 6bcf175..bbdcabf 100644 --- a/lib/features/main/main_shell_page.dart +++ b/lib/features/main/main_shell_page.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:patbond_flutter/analytics/analytics_page_name.dart'; import 'package:patbond_flutter/analytics/page_view_tracker.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/avatar_upload_sheet.dart'; import 'package:patbond_flutter/features/community/community_controller.dart'; import 'package:patbond_flutter/features/community/community_interaction_analytics.dart'; import 'package:patbond_flutter/features/community/feed_analytics.dart'; @@ -17,6 +18,7 @@ import 'package:patbond_flutter/features/pets/pet_analytics.dart'; import 'package:patbond_flutter/features/pets/pets_controller.dart'; import 'package:patbond_flutter/features/pets/pets_page.dart'; import 'package:patbond_flutter/features/post/post_detail_page.dart'; +import 'package:patbond_flutter/features/profile/profile_controller.dart'; import 'package:patbond_flutter/features/profile/profile_page.dart'; import 'package:patbond_flutter/features/services/services_page.dart'; import 'package:patbond_flutter/state/app_state.dart'; @@ -27,6 +29,7 @@ class MainShellPage extends StatefulWidget { required this.appState, required this.petsController, required this.communityController, + required this.profileController, super.key, this.currentUserId, this.petAnalytics, @@ -35,6 +38,7 @@ class MainShellPage extends StatefulWidget { this.interactionAnalytics, this.postAnalytics, this.mediaUploaderFactory, + this.avatarUploaderBuilder, this.pageViewTracker, this.onLogout, }); @@ -47,6 +51,9 @@ class MainShellPage extends StatefulWidget { /// 社区状态(T3-12 数据层;首页 Feed segment 数据源,T3-14 接线)。 final CommunityController communityController; + /// 本人资料与社区数字(T3.5-08);资料 Tab 与首页问候语共用。 + final ProfileController profileController; + /// 当前登录用户 id(详情页评论删除入口 / 关注钮自见性的 UI 判定)。 final String? currentUserId; @@ -68,6 +75,9 @@ class MainShellPage extends StatefulWidget { /// 发布页 [MediaUploader] 构造口(桌面实测 / 测试替换选图与压缩层)。 final MediaUploaderFactory? mediaUploaderFactory; + /// 头像上传构造口(T3.5-08/09:资料编辑页与宠物详情页)。 + final AvatarUploaderBuilder? avatarUploaderBuilder; + /// Tab 曝光补点(IndexedStack 切换不产生路由事件,03 号评估 §3.2)。 final PageViewTracker? pageViewTracker; @@ -167,6 +177,7 @@ class _MainShellPageState extends State { HomePage( appState: widget.appState, communityController: widget.communityController, + profileController: widget.profileController, feedAnalytics: widget.feedAnalytics, isActive: currentIndex == 0, onOpenServices: openServices, @@ -182,12 +193,18 @@ class _MainShellPageState extends State { controller: widget.petsController, analytics: widget.petAnalytics, healthAnalytics: widget.healthRecordAnalytics, + avatarUploaderBuilder: widget.avatarUploaderBuilder, ), ServicesPage( showPersonal: showPersonalServices, locationWeather: widget.appState.locationWeather, ), - ProfilePage(appState: widget.appState, onLogout: widget.onLogout), + ProfilePage( + appState: widget.appState, + controller: widget.profileController, + avatarUploaderBuilder: widget.avatarUploaderBuilder, + onLogout: widget.onLogout, + ), ]; return Scaffold( diff --git a/lib/features/profile/profile_controller.dart b/lib/features/profile/profile_controller.dart new file mode 100644 index 0000000..e4a1a94 --- /dev/null +++ b/lib/features/profile/profile_controller.dart @@ -0,0 +1,137 @@ +import 'package:flutter/foundation.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/features/auth/auth_models.dart'; +import 'package:patbond_flutter/features/auth/auth_repository.dart'; +import 'package:patbond_flutter/features/community/community_models.dart'; +import 'package:patbond_flutter/features/community/community_repository.dart'; + +/// 资料主链路四态(沿 pets/community 两域惯例)。 +enum ProfileLoadPhase { initial, loading, ready, error } + +/// 统计块的独立三态:`/me` 成功而统计失败时**只降级这一块**,不把整页 +/// 打成 error——昵称与头像已经拿到了,为两个数字丢掉整页是过度反应。 +enum ProfileStatsPhase { loading, ready, error } + +/// 「我的资料」状态控制器(T3.5-08)。 +/// +/// 数据源三处: +/// - `GET /api/v1/me` → 昵称 / 头像(主链路,失败即页面 error 态) +/// - `GET /api/v1/me/community-stats` → 获赞总数 / 作品数 +/// - `GET /api/v1/users/{me}/follow-stats` → 粉丝数 / 关注数 +/// +/// 首页问候语(T3.5-10)与资料页共用本控制器的 [displayName], +/// 保证两处展示名同源、改昵称后一起变(一次 `/me` 供两个消费点)。 +class ProfileController extends ChangeNotifier { + ProfileController({ + required AuthRepository authRepository, + required CommunityRepository communityRepository, + }) : _auth = authRepository, + _community = communityRepository; + + final AuthRepository _auth; + final CommunityRepository _community; + + ProfileLoadPhase _phase = ProfileLoadPhase.initial; + ProfileStatsPhase _statsPhase = ProfileStatsPhase.loading; + UserProfile? _profile; + CommunityStats? _communityStats; + FollowStats? _followStats; + ApiException? _lastError; + bool _disposed = false; + + ProfileLoadPhase get phase => _phase; + ProfileStatsPhase get statsPhase => _statsPhase; + + /// 本人资料(ready 态非 null)。 + UserProfile? get profile => _profile; + + CommunityStats? get communityStats => _communityStats; + FollowStats? get followStats => _followStats; + ApiException? get lastError => _lastError; + + /// 展示名 `nickname ?? username`;资料未到手时为 null(调用方不造假名)。 + String? get displayName => _profile?.displayName; + + /// 加载 / 重试。主链路错误收敛为 error 态供页面渲染 + 重试,不外抛。 + /// + /// 已有资料副本时刷新失败**保留副本**(`_phase` 停在 ready):与详情页 + /// 「有副本即不打断阅读」的既有取舍一致。 + Future refresh() async { + if (_profile == null) { + _phase = ProfileLoadPhase.loading; + } + _lastError = null; + _notify(); + try { + _profile = await _auth.me(); + _phase = ProfileLoadPhase.ready; + } on ApiException catch (error) { + _lastError = error; + if (_profile == null) { + _phase = ProfileLoadPhase.error; + _notify(); + return; + } + } + _notify(); + await refreshStats(); + } + + /// 只重取两块统计(统计块的「重试」入口;主链路资料不动)。 + Future refreshStats() async { + final userId = _profile?.userId; + if (userId == null) return; + _statsPhase = ProfileStatsPhase.loading; + _notify(); + try { + // 顺序取(同一服务、量级极小):任一失败即整块 error。两个数字并列 + // 在同一张卡上,只有一半是数字、另一半是「—」比整块「加载失败 + + // 重试」更费解。 + final community = await _community.getMyCommunityStats(); + final follow = await _community.getFollowStats(userId); + _communityStats = community; + _followStats = follow; + _statsPhase = ProfileStatsPhase.ready; + } on ApiException { + _statsPhase = ProfileStatsPhase.error; + } + _notify(); + } + + /// 提交资料变更(三态语义见 [UpdateMeRequest])。 + /// + /// **空 patch 直接短路不发请求**:服务端对空 patch 答 400/40000(刻意不 + /// 静默 200),客户端没有理由去撞这一枪。 + /// 成功即以服务端回显的全量资料替换本地副本(`avatarUrl` 现签, + /// 不做本地拼装)。失败按类型化异常外抛给编辑页分层呈现。 + Future save(UpdateMeRequest request) async { + if (request.isEmpty) return _profile; + final updated = await _auth.updateMe(request); + _profile = updated; + _phase = ProfileLoadPhase.ready; + _notify(); + return updated; + } + + /// 登出清空:回 initial 并清资料,避免上一账号昵称/头像跨会话泄漏 + /// (沿 `PetsController.reset` 先例)。 + void reset() { + _phase = ProfileLoadPhase.initial; + _statsPhase = ProfileStatsPhase.loading; + _profile = null; + _communityStats = null; + _followStats = null; + _lastError = null; + _notify(); + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/lib/features/profile/profile_display.dart b/lib/features/profile/profile_display.dart new file mode 100644 index 0000000..78ce263 --- /dev/null +++ b/lib/features/profile/profile_display.dart @@ -0,0 +1,47 @@ +import 'package:patbond_flutter/core/network/api_exception.dart'; + +/// 资料页/编辑页的错误文案映射(沿 `petLoadErrorMessage` 先例: +/// 服务端原始 message 一律不上屏)。 +String profileLoadErrorMessage(ApiException? error) => switch (error) { + ApiNetworkException _ => '网络异常,请检查网络后重试', + ApiRateLimitException _ => '请求过于频繁,请稍后再试', + _ => '加载失败,请稍后重试', +}; + +/// 保存资料的失败文案,按契约 v1.4.0 的 `PATCH /api/v1/me` 错误谱分层: +/// +/// | 码 | 成因 | 文案取向 | +/// | --- | --- | --- | +/// | 40000 | 昵称长度/纯空白、空 patch、非法 UUID | 指向输入本身可改 | +/// | 40405 | 头像 asset 不存在/非本人/已删/用途不符 | 引导重新上传 | +/// | 42203 | 本人头像 asset 仍在 uploading/failed | 明说「还没传完」,可重试 | +/// +/// 40000 的三种成因在客户端已各自前置拦截(长度校验 / 空 patch 短路 / +/// assetId 来自服务端),真收到 40000 说明校验与服务端有偏差, +/// 故文案不写死「昵称」,只指向「填写内容」。 +String profileSaveErrorMessage(ApiException? error) => switch (error) { + ApiBusinessException(code: ApiCodes.paramError) => '填写内容不符合要求,请检查后重试', + ApiBusinessException(code: ApiCodes.mediaNotFound) => '头像已失效,请重新上传', + ApiBusinessException(code: ApiCodes.mediaNotReady) => '头像还没上传完,请稍后重试', + ApiNetworkException _ => '网络异常,请检查网络后重试', + ApiRateLimitException _ => '请求过于频繁,请稍后再试', + _ => '保存失败,请稍后重试', +}; + +/// 昵称的客户端校验,与后端 `ck_users_nickname`(btrim + 1~32)对齐。 +/// +/// **长度按码点计**(`String.characters` 的等价物 `runes`):数据库 +/// `char_length` 数的是码点,若按 UTF-16 `String.length` 校验,32 个 emoji +/// 的合法昵称(UTF-16 长度 64)会被客户端误拒。 +/// +/// 返回 null 即通过;非空即字段级 errorText。 +String? validateNickname(String raw) { + // btrim 对齐:服务端先 btrim 再判长度,故校验也先 trim。 + final trimmed = raw.trim(); + if (trimmed.isEmpty) { + // 纯空白是 400/40000 而非隐式清空——清空走「清除昵称」显式入口。 + return '昵称不能为空,如需清除请用下方「清除昵称」'; + } + if (trimmed.runes.length > 32) return '昵称最多 32 个字'; + return null; +} diff --git a/lib/features/profile/profile_edit_page.dart b/lib/features/profile/profile_edit_page.dart new file mode 100644 index 0000000..781bc14 --- /dev/null +++ b/lib/features/profile/profile_edit_page.dart @@ -0,0 +1,335 @@ +import 'package:flutter/material.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/app_text_field.dart'; +import 'package:patbond_flutter/core/widgets/avatar_upload_sheet.dart'; +import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; +import 'package:patbond_flutter/core/widgets/primary_button.dart'; +import 'package:patbond_flutter/features/auth/auth_models.dart'; +import 'package:patbond_flutter/features/community/community_models.dart'; +import 'package:patbond_flutter/features/profile/profile_controller.dart'; +import 'package:patbond_flutter/features/profile/profile_display.dart'; +import 'package:patbond_flutter/widgets/common.dart'; + +/// 资料编辑页(T3.5-08):昵称 + 头像的唯一写入口。 +/// +/// **PATCH 三态在本页成形**(契约 v1.4.0 的 `UpdateMeRequest`): +/// +/// | 用户动作 | 提交表现 | +/// | --- | --- | +/// | 没碰昵称 | `nickname` **键不出现**(不改) | +/// | 改了昵称 | `nickname: "新值"` | +/// | 点「清除昵称」 | `nickname: null`(清空) | +/// | 没碰头像 | `avatarAssetId` **键不出现** | +/// | 传了新头像 | `avatarAssetId: ""` | +/// | 点「清除头像」 | `avatarAssetId: null` | +/// +/// 「不改」与「清空」在 JSON 上是**键缺省 vs 显式 null** 两回事:若把未改 +/// 字段也发成 null,用户只改昵称就会连头像一起被清掉。故本页不维护 +/// 「当前值 → 直接整体提交」的表单模型,而是维护**三态意图**([PatchField]), +/// 与初值比对后只让真正变化的字段落键。 +class ProfileEditPage extends StatefulWidget { + const ProfileEditPage({ + required this.controller, + super.key, + this.avatarUploaderBuilder, + }); + + final ProfileController controller; + + /// 头像上传编排器构造口;null 即本次构建未装配上传能力,隐藏头像入口 + /// (生产装配恒注入,见 `app.dart`)。 + final AvatarUploaderBuilder? avatarUploaderBuilder; + + @override + State createState() => _ProfileEditPageState(); +} + +class _ProfileEditPageState extends State { + late final TextEditingController _nickname; + + /// 页面进入时的资料快照:差量比对的基准(服务端回显的权威值)。 + late final UserProfile _initial; + + /// 昵称的三态意图:null 表示「跟随输入框与初值的比对结果」, + /// 非 null 表示用户已显式点过「清除昵称」。 + bool _nicknameCleared = false; + + /// 头像的三态意图(absent / clear / 新 assetId)。 + PatchField _avatarIntent = const PatchField.absent(); + + /// 新上传头像的本地预览(仅本页会话内有效,不落盘)。 + String? _pendingAvatarUrl; + + String? _nicknameError; + bool _saving = false; + ApiException? _saveError; + + @override + void initState() { + super.initState(); + _initial = widget.controller.profile!; + // **预填只用 DB 原值**,不用 displayName:若把回退出来的 username + // 预填进来,用户会以为自己设过昵称,一保存就把展示约定固化成数据 + // (服务端 /me 刻意不回退,正是为了留住这个区别)。 + _nickname = TextEditingController(text: _initial.nickname ?? ''); + _nickname.addListener(_onNicknameChanged); + } + + @override + void dispose() { + _nickname.dispose(); + super.dispose(); + } + + void _onNicknameChanged() { + // 每次输入都重建:保存钮的可用性由 `_hasChanges` 现算,不重建就会出现 + // 「已经打了字但保存钮还是灰的」。同时用户重新打字即撤销「清除」意图, + // 并清掉上一次的字段级错误。 + setState(() { + if (_nicknameCleared && _nickname.text.isNotEmpty) { + _nicknameCleared = false; + } + _nicknameError = null; + }); + } + + /// 昵称三态:显式清除 > 与初值不同即设置 > 否则不改。 + PatchField get _nicknamePatch { + if (_nicknameCleared) { + // 本来就没有昵称时「清除」是空操作,不落键(避免制造空 patch 之外 + // 的无意义写入)。 + return _initial.nickname == null + ? const PatchField.absent() + : const PatchField.clear(); + } + final trimmed = _nickname.text.trim(); + if (trimmed.isEmpty) return const PatchField.absent(); + if (trimmed == _initial.nickname) return const PatchField.absent(); + return PatchField.value(trimmed); + } + + UpdateMeRequest get _request => + UpdateMeRequest(nickname: _nicknamePatch, avatarAssetId: _avatarIntent); + + bool get _hasChanges => !_request.isEmpty; + + /// 当前应展示的头像:新传的 > 清除意图(无图) > 服务端现值。 + String? get _shownAvatarUrl { + if (_avatarIntent.isClear) return null; + return _pendingAvatarUrl ?? _initial.avatarUrl; + } + + bool get _hasAvatarNow => _shownAvatarUrl != null; + + Future _pickAvatar() async { + final builder = widget.avatarUploaderBuilder; + if (builder == null) return; + final assetId = await showAvatarUploadSheet( + context, + builder: builder, + purpose: MediaPurpose.userAvatar, + ); + if (assetId == null || !mounted) return; + setState(() { + _avatarIntent = PatchField.value(assetId); + // 预览沿用上传器给的可访问 URL?—— 没有:complete 响应的 url 与 + // /me 的 avatarUrl 同为预签名,但本页不缓存它,改为保存后由服务端 + // 回显。上传成功到保存之间以「已选择新头像」文案示意。 + _pendingAvatarUrl = null; + }); + } + + void _clearAvatar() { + setState(() { + _avatarIntent = const PatchField.clear(); + _pendingAvatarUrl = null; + }); + } + + void _clearNickname() { + setState(() { + _nicknameCleared = true; + _nicknameError = null; + _nickname.clear(); + }); + } + + Future _save() async { + // 有内容的昵称才校验:空输入框意为「不改」(清空走显式入口)。 + final raw = _nickname.text; + if (!_nicknameCleared && raw.trim().isNotEmpty) { + final error = validateNickname(raw); + if (error != null) { + setState(() => _nicknameError = error); + return; + } + } + final request = _request; + if (request.isEmpty) { + // 空 patch 服务端答 400/40000(刻意不静默 200);客户端不去撞这一枪。 + Navigator.of(context).pop(false); + return; + } + setState(() { + _saving = true; + _saveError = null; + }); + try { + await widget.controller.save(request); + if (!mounted) return; + Navigator.of(context).pop(true); + } on ApiException catch (error) { + if (!mounted) return; + setState(() { + _saving = false; + _saveError = error; + }); + } + } + + @override + Widget build(BuildContext context) { + final canUploadAvatar = widget.avatarUploaderBuilder != null; + return Scaffold( + appBar: AppBar(title: const Text('编辑资料')), + body: ListView( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 30), + children: [ + if (_saveError != null) ...[ + InlineErrorBanner(message: profileSaveErrorMessage(_saveError)), + const SizedBox(height: 16), + ], + if (canUploadAvatar) ...[ + Center( + child: Column( + children: [ + _AvatarPreview( + url: _shownAvatarUrl, + pendingAssetId: _avatarIntent.valueOrNull, + onTap: _saving ? null : _pickAvatar, + ), + const SizedBox(height: 10), + TextButton.icon( + onPressed: _saving ? null : _pickAvatar, + icon: const Icon(Icons.photo_camera_outlined, size: 18), + label: const Text('更换头像'), + ), + if (_hasAvatarNow) + TextButton( + onPressed: _saving ? null : _clearAvatar, + child: const Text( + '清除头像', + style: TextStyle(color: AppColors.errorDark), + ), + ), + if (_avatarIntent.isClear) + const Text( + '保存后将移除头像', + style: TextStyle(color: AppColors.inkSoft, fontSize: 12), + ), + ], + ), + ), + const SizedBox(height: 20), + ], + AppTextField( + label: '昵称', + controller: _nickname, + enabled: !_saving, + errorText: _nicknameError, + // 未设昵称时点明「现在别人看到的是用户名」——展示回退是客户端 + // 行为,不写进输入框(否则一保存就变成真昵称)。 + helperText: _initial.nickname == null + ? '未设置,当前展示为用户名「${_initial.username}」' + : '1~32 个字', + textInputAction: TextInputAction.done, + onSubmitted: (_) => _saving ? null : _save(), + ), + if (_initial.nickname != null) + Align( + alignment: Alignment.centerLeft, + child: TextButton( + onPressed: _saving ? null : _clearNickname, + child: const Text( + '清除昵称', + style: TextStyle(color: AppColors.errorDark), + ), + ), + ), + if (_nicknameCleared) + const Text( + '保存后将清除昵称,展示名回退为用户名', + style: TextStyle(color: AppColors.inkSoft, fontSize: 12), + ), + const SizedBox(height: 24), + PrimaryButton( + label: '保存', + isLoading: _saving, + // 无变更时禁用:避免用户以为「点了保存但什么都没发生」。 + onPressed: _hasChanges ? _save : null, + ), + ], + ), + ); + } +} + +/// 编辑页头像预览:有 URL 走 [RemoteImage](缓存 key 已剥签名参数); +/// 刚上传但尚未保存时无可用 URL,以「已选择新头像」占位说明。 +class _AvatarPreview extends StatelessWidget { + const _AvatarPreview({ + required this.url, + required this.pendingAssetId, + this.onTap, + }); + + final String? url; + final String? pendingAssetId; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final Widget content; + if (url != null) { + content = RemoteImage( + url: url!, + width: 96, + height: 96, + borderRadius: BorderRadius.circular(48), + ); + } else if (pendingAssetId != null) { + content = const ColoredBox( + color: AppColors.surfaceTint, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.check_circle, color: AppColors.primary, size: 24), + SizedBox(height: 4), + Text( + '已选择\n新头像', + textAlign: TextAlign.center, + style: TextStyle(color: AppColors.primaryDark, fontSize: 10), + ), + ], + ), + ), + ); + } else { + content = const ColoredBox( + color: AppColors.surfaceTint, + child: Icon(Icons.person_outline, color: AppColors.muted, size: 40), + ); + } + return Semantics( + label: '更换头像', + button: onTap != null, + child: InkWell( + onTap: onTap, + customBorder: const CircleBorder(), + child: SizedBox(width: 96, height: 96, child: ClipOval(child: content)), + ), + ); + } +} diff --git a/lib/features/profile/profile_page.dart b/lib/features/profile/profile_page.dart index feaa375..758aeee 100644 --- a/lib/features/profile/profile_page.dart +++ b/lib/features/profile/profile_page.dart @@ -1,17 +1,49 @@ import 'package:flutter/material.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; -import 'package:patbond_flutter/data/demo_data.dart'; +import 'package:patbond_flutter/core/widgets/avatar_upload_sheet.dart'; +import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; +import 'package:patbond_flutter/features/auth/auth_models.dart'; +import 'package:patbond_flutter/features/profile/profile_controller.dart'; +import 'package:patbond_flutter/features/profile/profile_display.dart'; +import 'package:patbond_flutter/features/profile/profile_edit_page.dart'; import 'package:patbond_flutter/state/app_state.dart'; import 'package:patbond_flutter/widgets/common.dart'; -class ProfilePage extends StatelessWidget { - const ProfilePage({required this.appState, super.key, this.onLogout}); +/// 「我的资料」Tab(T3.5-08 真实化)。 +/// +/// 头部三项(展示名 / 头像 / 四个数字)自此全部来自服务端: +/// `GET /me`(昵称与头像)+ `GET /me/community-stats`(获赞 / 作品)+ +/// `GET /users/{me}/follow-stats`(粉丝 / 关注)。M3 之前的硬编码 +/// 「萌宠新手(豆豆家长)」/「24 / 1.8k / 2」已退役。 +/// +/// 四态:loading(居中转圈)/ error(横幅 + 重试)/ ready。**没有独立空态** +/// ——任何已认证用户都有资料,`/me/community-stats` 契约上「永不 404、空数据 +/// 返回 0」,故「新用户什么都没有」的形态就是 ready 态里的一排 0, +/// 而不是另一个页面态。统计块另有独立三态(见 [ProfileStatsPhase])。 +class ProfilePage extends StatefulWidget { + const ProfilePage({ + required this.appState, + required this.controller, + super.key, + this.avatarUploaderBuilder, + this.onLogout, + }); final AppState appState; + /// 资料与统计数据源(Tab 级单例,`app.dart` 装配注入;首页问候语同源)。 + final ProfileController controller; + + /// 头像上传编排器构造口(透传编辑页)。 + final AvatarUploaderBuilder? avatarUploaderBuilder; + /// 真实退出登录入口;未接线时保持演示提示。 final Future Function()? onLogout; + /// 刻意保留的 demo 入口(ADR-022 未纳入本迭代):预约订单与健康卡包属 + /// M5 服务域;地址定位需外部服务;设置与关于待有实际可设项。 + /// 「我的收藏与草稿」的后端能力已就位(`/me/bookmarks`、`/me/posts`), + /// 但列表页本单未做(见 05 号报告遗留),故仍走演示提示。 static const menuItems = [ (Icons.assignment_outlined, '我的预约订单', '查看进行中与历史服务'), (Icons.bookmarks_outlined, '我的收藏与草稿', '已保存的宠物作品与攻略'), @@ -20,18 +52,63 @@ class ProfilePage extends StatelessWidget { (Icons.settings_outlined, '设置与关于', '隐私设置与版本信息'), ]; + @override + State createState() => _ProfilePageState(); +} + +class _ProfilePageState extends State { + ProfileController get _controller => widget.controller; + + @override + void initState() { + super.initState(); + // 主壳挂载即预取(IndexedStack 各 Tab 同时构建,沿 pets/home 先例); + // 重登后控制器已 reset 回 initial,会重新拉取。首页问候语也消费这一次。 + // + // **预取推到帧末**:`refresh()` 起手就同步 notify 一次(切 loading 态), + // 而同一控制器的另一个监听者(首页问候语)在本页 initState 时已构建完成 + // ——在 initState 里同步通知它会命中 Flutter「build 期间 setState」断言。 + // pets/home 各自只有自己一个监听者,故它们在 initState 里直取无妨。 + if (_controller.phase == ProfileLoadPhase.initial) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _controller.phase == ProfileLoadPhase.initial) { + _controller.refresh(); + } + }); + } + } + void showDemoMessage(BuildContext context, String name) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('「$name」功能为演示入口'))); } + Future _openEdit() async { + if (_controller.profile == null) return; + final saved = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ProfileEditPage( + controller: _controller, + avatarUploaderBuilder: widget.avatarUploaderBuilder, + ), + ), + ); + if (saved == true && mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('资料已更新'))); + } + } + + /// 刻意保留的 demo 家具:AppState 仍承载首页天气/本地服务的演示数据, + /// 这个入口是它唯一的复位口(不影响服务端真实资料)。 Future reset(BuildContext context) async { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('恢复演示数据'), - content: const Text('宠物资料、疫苗记录以及新增帖子都会恢复为初始状态。'), + content: const Text('首页天气与本地服务的演示内容会恢复为初始状态(不影响账号资料与宠物档案)。'), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), @@ -45,7 +122,7 @@ class ProfilePage extends StatelessWidget { ), ); if (confirmed == true) { - await appState.resetDemoData(); + await widget.appState.resetDemoData(); if (context.mounted) { ScaffoldMessenger.of( context, @@ -56,6 +133,43 @@ class ProfilePage extends StatelessWidget { @override Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _controller, + builder: (context, _) { + switch (_controller.phase) { + case ProfileLoadPhase.initial: + case ProfileLoadPhase.loading: + return const Center(child: CircularProgressIndicator()); + case ProfileLoadPhase.error: + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + InlineErrorBanner( + message: profileLoadErrorMessage(_controller.lastError), + ), + const SizedBox(height: 16), + FilledButton( + onPressed: _controller.refresh, + child: const Text('重试'), + ), + ], + ), + ), + ); + case ProfileLoadPhase.ready: + return RefreshIndicator( + onRefresh: _controller.refresh, + child: _content(_controller.profile!), + ); + } + }, + ); + } + + Widget _content(UserProfile profile) { return ListView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 30), children: [ @@ -67,43 +181,38 @@ class ProfilePage extends StatelessWidget { ), child: Column( children: [ - RemoteImage( - url: userAvatar, - width: 82, - height: 82, - borderRadius: BorderRadius.circular(41), - ), + _ProfileAvatar(url: profile.avatarUrl, onTap: _openEdit), const SizedBox(height: 12), - const Text( - '萌宠新手(豆豆家长)', - style: TextStyle( + Text( + // 展示回退在客户端:`nickname ?? username`(服务端 /me 返回 + // DB 原值不回退,见 UserProfile.displayName 的理由)。 + profile.displayName, + style: const TextStyle( color: Colors.white, fontSize: 17, fontWeight: FontWeight.w800, ), ), const SizedBox(height: 5), - const Text( - 'Patbond 社区创作达人', - style: TextStyle(color: AppColors.accent, fontSize: 12), + Text( + '@${profile.username}', + style: const TextStyle(color: AppColors.accent, fontSize: 12), ), - const SizedBox(height: 20), + const SizedBox(height: 12), + OutlinedButton.icon( + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide(color: Colors.white38), + visualDensity: VisualDensity.compact, + ), + onPressed: _openEdit, + icon: const Icon(Icons.edit_outlined, size: 16), + label: const Text('编辑资料'), + ), + const SizedBox(height: 16), const Divider(color: Colors.white24), const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - const _ProfileStat(value: '24', label: '关注我'), - const _ProfileStat(value: '1.8k', label: '获赞'), - _ProfileStat( - // 「我的资料」头部整体仍是 demo 家具(M5 范围):三项 - // 统计同源 demo 常量;AppState.posts 随 T3-17 退役后 - // 本项直读 demo 列表长度,不伪装真实数据。 - value: '${initialPosts.length}', - label: '我的作品', - ), - ], - ), + _statsRow(), ], ), ), @@ -111,7 +220,7 @@ class ProfilePage extends StatelessWidget { SectionCard( padding: const EdgeInsets.symmetric(vertical: 6), child: Column( - children: menuItems.map((item) { + children: ProfilePage.menuItems.map((item) { return ListTile( leading: CircleAvatar( backgroundColor: AppColors.surfaceTint, @@ -139,12 +248,112 @@ class ProfilePage extends StatelessWidget { ), const SizedBox(height: 10), TextButton( - onPressed: onLogout ?? () => showDemoMessage(context, '退出登录'), + onPressed: widget.onLogout ?? () => showDemoMessage(context, '退出登录'), child: const Text('切换账号或退出登录'), ), ], ); } + + /// 统计块三态:loading 占位「—」+ 转圈 / error 提示 + 重试 / ready 实数。 + /// + /// 数字**不做 1.8k 式压缩**:获赞总数要能与帖子详情的 likeCount 逐一对上 + /// (同一口径,含自赞),压缩会让「对不上」变成常态。 + Widget _statsRow() { + switch (_controller.statsPhase) { + case ProfileStatsPhase.loading: + return const SizedBox( + height: 46, + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + case ProfileStatsPhase.error: + return SizedBox( + height: 46, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + '统计加载失败', + style: TextStyle(color: Colors.white70, fontSize: 12), + ), + TextButton( + onPressed: _controller.refreshStats, + child: const Text( + '重试', + style: TextStyle(color: AppColors.accent), + ), + ), + ], + ), + ); + case ProfileStatsPhase.ready: + final follow = _controller.followStats; + final community = _controller.communityStats; + return Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _ProfileStat(value: '${follow?.followerCount ?? 0}', label: '关注我'), + _ProfileStat(value: '${follow?.followingCount ?? 0}', label: '我关注'), + _ProfileStat( + value: '${community?.receivedLikeCount ?? 0}', + label: '获赞', + ), + _ProfileStat( + value: '${community?.publishedPostCount ?? 0}', + label: '我的作品', + ), + ], + ); + } + } +} + +/// 资料页头像:有 URL 走 [RemoteImage](缓存 key 剥签名参数,URL 不落盘); +/// 无头像时本地占位(不显示破图)。 +class _ProfileAvatar extends StatelessWidget { + const _ProfileAvatar({required this.url, required this.onTap}); + + final String? url; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + label: '编辑资料', + button: true, + child: InkWell( + onTap: onTap, + customBorder: const CircleBorder(), + child: SizedBox( + width: 82, + height: 82, + child: ClipOval( + child: url == null + ? const ColoredBox( + color: AppColors.surfaceTint, + child: Icon( + Icons.person_outline, + color: AppColors.muted, + size: 36, + ), + ) + : RemoteImage( + url: url!, + width: 82, + height: 82, + borderRadius: BorderRadius.circular(41), + ), + ), + ), + ), + ); + } } class _ProfileStat extends StatelessWidget { diff --git a/test/core/models/patch_field_test.dart b/test/core/models/patch_field_test.dart new file mode 100644 index 0000000..0c7893d --- /dev/null +++ b/test/core/models/patch_field_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/core/models/patch_field.dart'; + +/// PATCH 三态的 JSON 表现(契约 v1.4.0 的 `UpdateMeRequest` / +/// `UpdatePetRequest.avatarAssetId`)。 +/// +/// 本文件守住的红线只有一条:**absent 绝不落键**。若 absent 落成 `null`, +/// 用户「只改昵称」会把头像一起清掉(服务端把显式 null 当清空指令)。 +void main() { + test('absent 不落键 / clear 落 null / value 落值', () { + final json = {}; + const PatchField.absent().writeTo(json, 'a'); + expect(json.containsKey('a'), isFalse, reason: 'absent 必须连键都不出现'); + + const PatchField.clear().writeTo(json, 'b'); + expect(json.containsKey('b'), isTrue); + expect(json['b'], isNull); + + const PatchField.value('x').writeTo(json, 'c'); + expect(json['c'], 'x'); + }); + + test('absent 与 clear 的 valueOrNull 同为 null,只能靠 isPresent 区分', () { + const absent = PatchField.absent(); + const clear = PatchField.clear(); + expect(absent.valueOrNull, isNull); + expect(clear.valueOrNull, isNull); + expect(absent.isPresent, isFalse); + expect(clear.isPresent, isTrue); + expect(absent.isClear, isFalse); + expect(clear.isClear, isTrue); + expect(const PatchField.value('x').isClear, isFalse); + }); + + test('encode 只作用于有值态(clear 不调 encode,避免 null 解引用)', () { + final json = {}; + var encodeCalls = 0; + const PatchField.clear().writeTo( + json, + 'n', + encode: (value) { + encodeCalls += 1; + return value.toString(); + }, + ); + expect(json['n'], isNull); + expect(encodeCalls, 0); + + const PatchField.value(7).writeTo( + json, + 'm', + encode: (value) { + encodeCalls += 1; + return value.toString(); + }, + ); + expect(json['m'], '7'); + expect(encodeCalls, 1); + }); + + test('值相等即相等(absent 与 clear 不相等)', () { + expect( + const PatchField.value('a'), + const PatchField.value('a'), + ); + expect( + const PatchField.absent(), + isNot(const PatchField.clear()), + ); + }); +} diff --git a/test/core/widgets/avatar_upload_sheet_test.dart b/test/core/widgets/avatar_upload_sheet_test.dart new file mode 100644 index 0000000..0afde3f --- /dev/null +++ b/test/core/widgets/avatar_upload_sheet_test.dart @@ -0,0 +1,162 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/avatar_upload_sheet.dart'; +import 'package:patbond_flutter/features/community/community_models.dart'; +import 'package:patbond_flutter/features/community/media_direct_upload.dart'; +import 'package:patbond_flutter/features/community/media_uploader.dart'; + +import '../../helpers/community_test_helpers.dart'; +import '../../helpers/media_test_helpers.dart'; + +/// 头像上传 sheet 的六态呈现(状态机本身由 MediaUploader 单测覆盖, +/// 本文件只验「哪个阶段给用户看什么、能点什么」)。 +void main() { + late FakeCommunityRepository community; + late FakeMediaImagePicker picker; + late FakeMediaCompressor compressor; + late FakeDirectUploadClient direct; + + setUp(() { + community = FakeCommunityRepository(); + picker = FakeMediaImagePicker([decodablePickedImage()]); + compressor = FakeMediaCompressor(); + direct = FakeDirectUploadClient(); + community.onCreateMediaUpload = (_) async => credentials(); + community.onCompleteMediaUpload = (assetId) async => + readyAsset(assetId: assetId); + }); + + MediaUploader build(MediaPurpose purpose) => MediaUploader( + repository: community, + purpose: purpose, + picker: picker, + compressor: compressor, + directUpload: direct, + maxImages: 1, + maxConcurrentUploads: 1, + ); + + /// 挂一个按钮拉起 sheet,并把 pop 结果记录下来。 + Future> pumpSheet( + WidgetTester tester, { + MediaPurpose purpose = MediaPurpose.userAvatar, + }) async { + final results = []; + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () async { + results.add( + await showAvatarUploadSheet( + context, + builder: build, + purpose: purpose, + ), + ); + }, + child: const Text('打开'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('打开')); + await tester.pump(); + return results; + } + + testWidgets('打开即自动拉起选择器(无需多点一次「选择图片」)', (tester) async { + picker.gate = Completer(); + await pumpSheet(tester); + await tester.pump(); + + expect(find.text('正在打开相册…'), findsOneWidget); + expect(picker.limits, [1], reason: 'maxImages=1 → 只取一张'); + + picker.gate!.complete(); + await tester.pumpAndSettle(); + }); + + testWidgets('上传中:线性进度 + 百分比;ready 后给预览与「使用这张」', (tester) async { + direct.manual = true; + final results = await pumpSheet(tester); + await tester.pumpAndSettle(); + + expect(find.byType(LinearProgressIndicator), findsOneWidget); + final call = direct.calls.single; + call.emitProgress(30, 100); + await tester.pump(); + expect(find.text('上传中 30%'), findsOneWidget); + + call.succeed(); + await tester.pumpAndSettle(); + + expect(find.text('使用这张'), findsOneWidget); + expect(find.byType(Image), findsOneWidget, reason: 'ready 态给整幅预览'); + + await tester.tap(find.text('使用这张')); + await tester.pumpAndSettle(); + + expect(results.single, 'a-1', reason: '只交付 ready 态的 assetId(孤儿防护)'); + }); + + testWidgets('purpose 透传到 createUpload(宠物头像 = pet_avatar)', (tester) async { + await pumpSheet(tester, purpose: MediaPurpose.petAvatar); + await tester.pumpAndSettle(); + + expect(community.lastMediaUploadRequest!.purpose, MediaPurpose.petAvatar); + expect(community.lastMediaUploadRequest!.purpose.wire, 'pet_avatar'); + }); + + testWidgets('可重试失败:给「重试」+「重新选择」,重试成功后可确认', (tester) async { + direct.scriptedOutcomes.add( + const MediaDirectUploadException(message: '断网'), + ); + final results = await pumpSheet(tester); + await tester.pumpAndSettle(); + + expect(find.text('网络中断,上传失败'), findsOneWidget); + expect(find.text('重试'), findsOneWidget); + expect(find.text('重新选择'), findsOneWidget); + + await tester.tap(find.text('重试')); + await tester.pumpAndSettle(); + + expect(find.text('使用这张'), findsOneWidget); + await tester.tap(find.text('使用这张')); + await tester.pumpAndSettle(); + expect(results.single, 'a-1'); + }); + + testWidgets('不可重试失败(压缩后仍超限):只给「重新选择」,不给「重试」', (tester) async { + compressor.sizePerQuality = {80: 20 * 1024 * 1024, 60: 15 * 1024 * 1024}; + await pumpSheet(tester); + await tester.pumpAndSettle(); + + expect(find.text('图片过大,压缩后仍超过 10 MB'), findsOneWidget); + expect(find.text('重试'), findsNothing, reason: '重试同一张必然再失败,给重试钮是误导'); + expect(find.text('重新选择'), findsOneWidget); + }); + + testWidgets('用户在系统选择器里取消 → 空态可再次选择,pop 结果为 null', (tester) async { + picker = FakeMediaImagePicker(const []); + final results = await pumpSheet(tester); + await tester.pumpAndSettle(); + + expect(find.text('还没有选择图片'), findsOneWidget); + expect(find.text('选择图片'), findsOneWidget); + + // 关闭 sheet:未走到 ready,不交付任何 assetId。 + await tester.tapAt(const Offset(10, 10)); + await tester.pumpAndSettle(); + expect(results.single, isNull); + }); +} diff --git a/test/features/profile/profile_controller_test.dart b/test/features/profile/profile_controller_test.dart new file mode 100644 index 0000000..fff60f7 --- /dev/null +++ b/test/features/profile/profile_controller_test.dart @@ -0,0 +1,200 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/features/auth/auth_models.dart'; +import 'package:patbond_flutter/features/community/community_models.dart'; +import 'package:patbond_flutter/features/profile/profile_controller.dart'; + +import '../../helpers/auth_test_helpers.dart'; +import '../../helpers/community_test_helpers.dart'; + +void main() { + late FakeCommunityRepository community; + + setUp(() { + community = FakeCommunityRepository(); + }); + + ProfileController build(FakeAuthRepository auth) => + ProfileController(authRepository: auth, communityRepository: community); + + test('四态:initial → loading → ready(资料 + 两块统计齐备)', () async { + final gate = Completer(); + final controller = build(FakeAuthRepository(meHandler: () => gate.future)); + community.onGetMyCommunityStats = () async => + CommunityStats.fromJson(sampleCommunityStatsJson()); + community.onGetFollowStats = (_) async => + FollowStats.fromJson(sampleFollowStatsJson()); + + expect(controller.phase, ProfileLoadPhase.initial); + final pending = controller.refresh(); + expect(controller.phase, ProfileLoadPhase.loading); + + gate.complete(buildProfile(nickname: '小柴')); + await pending; + + expect(controller.phase, ProfileLoadPhase.ready); + expect(controller.statsPhase, ProfileStatsPhase.ready); + expect(controller.displayName, '小柴'); + expect(controller.communityStats!.receivedLikeCount, 128); + expect(controller.followStats!.followerCount, 24); + }); + + test('/me 失败且无副本 → error 态 + 可重试;不外抛', () async { + var attempts = 0; + final auth = FakeAuthRepository( + meHandler: () async { + attempts += 1; + if (attempts == 1) throw const ApiNetworkException('断网'); + return buildProfile(nickname: '小柴'); + }, + ); + final controller = build(auth); + + await controller.refresh(); + expect(controller.phase, ProfileLoadPhase.error); + expect(controller.lastError, isA()); + expect(controller.profile, isNull); + + await controller.refresh(); + expect(controller.phase, ProfileLoadPhase.ready); + expect(controller.displayName, '小柴'); + }); + + test('已有副本时刷新失败保留副本(不打断阅读,停在 ready)', () async { + var attempts = 0; + final controller = build( + FakeAuthRepository( + meHandler: () async { + attempts += 1; + if (attempts == 1) return buildProfile(nickname: '小柴'); + throw const ApiNetworkException('断网'); + }, + ), + ); + + await controller.refresh(); + await controller.refresh(); + + expect(controller.phase, ProfileLoadPhase.ready); + expect(controller.displayName, '小柴'); + expect(controller.lastError, isA()); + }); + + test('统计失败只降级统计块,资料仍 ready;refreshStats 可单独重试', () async { + var statsAttempts = 0; + community.onGetMyCommunityStats = () async { + statsAttempts += 1; + if (statsAttempts == 1) throw const ApiNetworkException('断网'); + return CommunityStats.fromJson(sampleCommunityStatsJson()); + }; + community.onGetFollowStats = (_) async => + FollowStats.fromJson(sampleFollowStatsJson()); + final controller = build(FakeAuthRepository()); + + await controller.refresh(); + expect(controller.phase, ProfileLoadPhase.ready); + expect(controller.statsPhase, ProfileStatsPhase.error); + expect(controller.communityStats, isNull); + + await controller.refreshStats(); + expect(controller.statsPhase, ProfileStatsPhase.ready); + expect(controller.phase, ProfileLoadPhase.ready); + }); + + test('空数据:两个数字为 0(不是 null、不是 404)', () async { + community.onGetMyCommunityStats = () async => CommunityStats.fromJson( + sampleCommunityStatsJson(receivedLikeCount: 0, publishedPostCount: 0), + ); + community.onGetFollowStats = (_) async => FollowStats.fromJson( + sampleFollowStatsJson(followerCount: 0, followingCount: 0), + ); + final controller = build(FakeAuthRepository()); + + await controller.refresh(); + + expect(controller.communityStats!.receivedLikeCount, 0); + expect(controller.communityStats!.publishedPostCount, 0); + }); + + test('save:空 patch 直接短路,不发请求(服务端对空 patch 答 400)', () async { + final auth = FakeAuthRepository( + updateMeHandler: (_) async => buildProfile(nickname: '不该被调用'), + ); + final controller = build(auth); + await controller.refresh(); + + await controller.save(const UpdateMeRequest()); + + expect(auth.updateMePayloads, isEmpty); + }); + + test('save 成功即以服务端回显替换副本(含 avatarUrl 现签值)', () async { + final auth = FakeAuthRepository( + updateMeHandler: (_) async => buildProfile( + nickname: '小柴', + avatarUrl: 'https://m/a.jpg?X-Amz-Signature=new', + ), + ); + final controller = build(auth); + await controller.refresh(); + expect(controller.displayName, 'llx'); + + await controller.save( + const UpdateMeRequest(nickname: PatchField.value('小柴')), + ); + + expect(controller.displayName, '小柴'); + expect(controller.profile!.avatarUrl, contains('X-Amz-Signature=new')); + expect(auth.updateMePayloads.single, {'nickname': '小柴'}); + }); + + test('save 失败按类型化异常外抛(供编辑页分层呈现),副本不动', () async { + final auth = FakeAuthRepository( + updateMeHandler: (_) async => throw const ApiBusinessException( + code: ApiCodes.mediaNotReady, + message: 'not ready', + ), + ); + final controller = build(auth); + await controller.refresh(); + + await expectLater( + controller.save( + const UpdateMeRequest(avatarAssetId: PatchField.value('a-1')), + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + ApiCodes.mediaNotReady, + ), + ), + ); + expect(controller.displayName, 'llx'); + }); + + test('reset:登出清空资料与统计,回 initial(跨账号不泄漏)', () async { + final controller = build(FakeAuthRepository()); + await controller.refresh(); + expect(controller.profile, isNotNull); + + controller.reset(); + + expect(controller.phase, ProfileLoadPhase.initial); + expect(controller.profile, isNull); + expect(controller.displayName, isNull); + expect(controller.communityStats, isNull); + expect(controller.followStats, isNull); + }); + + test('follow-stats 用的是本人 userId(路径主体正确)', () async { + final controller = build( + FakeAuthRepository(meHandler: () async => buildProfile(userId: 'u-42')), + ); + await controller.refresh(); + expect(community.calls, contains('followStats:u-42')); + expect(community.calls, contains('communityStats')); + }); +} diff --git a/test/features/profile/profile_edit_page_test.dart b/test/features/profile/profile_edit_page_test.dart new file mode 100644 index 0000000..ee9ada6 --- /dev/null +++ b/test/features/profile/profile_edit_page_test.dart @@ -0,0 +1,295 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; +import 'package:patbond_flutter/features/community/community_models.dart'; +import 'package:patbond_flutter/features/community/media_uploader.dart'; +import 'package:patbond_flutter/features/profile/profile_controller.dart'; +import 'package:patbond_flutter/features/profile/profile_edit_page.dart'; + +import '../../helpers/auth_test_helpers.dart'; +import '../../helpers/community_test_helpers.dart'; +import '../../helpers/media_test_helpers.dart'; + +/// T3.5-08 编辑页:**PATCH 三态载荷**是本文件的重点。 +/// +/// 每个成功用例都断言一次「未改字段的键不出现在 JSON 里」——这是三态与 +/// 两态的唯一可观测差别,也是「只改昵称却把头像清掉」这类事故的唯一防线。 + +/// 昵称输入框(AppTextField 内层 TextFormField)。 +Finder get nicknameField => find.byType(TextFormField); + +void main() { + late FakeCommunityRepository community; + late FakeMediaImagePicker picker; + late FakeMediaCompressor compressor; + late FakeDirectUploadClient direct; + + setUp(() { + community = FakeCommunityRepository(); + community.onGetMyCommunityStats = () async => + CommunityStats.fromJson(sampleCommunityStatsJson()); + community.onGetFollowStats = (_) async => + FollowStats.fromJson(sampleFollowStatsJson()); + picker = FakeMediaImagePicker([decodablePickedImage()]); + compressor = FakeMediaCompressor(); + direct = FakeDirectUploadClient(); + community.onCreateMediaUpload = (_) async => credentials(); + community.onCompleteMediaUpload = (assetId) async => + readyAsset(assetId: assetId); + }); + + MediaUploader buildUploader(MediaPurpose purpose) => MediaUploader( + repository: community, + purpose: purpose, + picker: picker, + compressor: compressor, + directUpload: direct, + maxImages: 1, + maxConcurrentUploads: 1, + ); + + /// 直接 push 编辑页(资料页链路另有测试;这里只测编辑页本身)。 + Future pumpEdit( + WidgetTester tester, { + String? nickname, + String? avatarUrl, + bool withUploader = true, + ApiException? saveError, + }) async { + tester.view.physicalSize = const Size(700, 1800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + final auth = FakeAuthRepository( + meHandler: () async => buildProfile( + username: 'llx', + nickname: nickname, + avatarUrl: avatarUrl, + ), + updateMeHandler: (_) async { + if (saveError != null) throw saveError; + return buildProfile(username: 'llx', nickname: nickname); + }, + ); + final controller = ProfileController( + authRepository: auth, + communityRepository: community, + ); + addTearDown(controller.dispose); + await controller.refresh(); + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: ProfileEditPage( + controller: controller, + avatarUploaderBuilder: withUploader ? buildUploader : null, + ), + ), + ); + await tester.pumpAndSettle(); + return auth; + } + + group('三态载荷', () { + testWidgets('只改昵称 → 载荷只有 nickname,avatarAssetId 键不出现', (tester) async { + final auth = await pumpEdit( + tester, + avatarUrl: 'https://m/a.jpg?X-Amz-Signature=s', + ); + + await tester.enterText(nicknameField, '小柴'); + await tester.pump(); + await tester.tap(find.text('保存')); + await tester.pumpAndSettle(); + + final payload = auth.updateMePayloads.single; + expect(payload, {'nickname': '小柴'}); + expect( + payload.containsKey('avatarAssetId'), + isFalse, + reason: '发成 avatarAssetId: null 会把用户已有头像一起清掉', + ); + }); + + testWidgets('点「清除昵称」 → nickname 显式 null,头像键不出现', (tester) async { + final auth = await pumpEdit( + tester, + nickname: '小柴', + avatarUrl: 'https://m/a.jpg?X-Amz-Signature=s', + ); + + await tester.tap(find.text('清除昵称')); + await tester.pumpAndSettle(); + expect(find.text('保存后将清除昵称,展示名回退为用户名'), findsOneWidget); + + await tester.tap(find.text('保存')); + await tester.pumpAndSettle(); + + final payload = auth.updateMePayloads.single; + expect(payload.containsKey('nickname'), isTrue); + expect(payload['nickname'], isNull); + expect(payload.containsKey('avatarAssetId'), isFalse); + }); + + testWidgets('点「清除头像」 → avatarAssetId 显式 null,nickname 键不出现', ( + tester, + ) async { + final auth = await pumpEdit( + tester, + nickname: '小柴', + avatarUrl: 'https://m/a.jpg?X-Amz-Signature=s', + ); + + await tester.tap(find.text('清除头像')); + await tester.pumpAndSettle(); + expect(find.text('保存后将移除头像'), findsOneWidget); + + await tester.tap(find.text('保存')); + await tester.pumpAndSettle(); + + final payload = auth.updateMePayloads.single; + expect(payload.containsKey('avatarAssetId'), isTrue); + expect(payload['avatarAssetId'], isNull); + expect( + payload.containsKey('nickname'), + isFalse, + reason: '昵称没碰过就不该出现在载荷里(更不该是 null)', + ); + }); + + testWidgets('输入与原昵称相同 → 视为未改,保存钮禁用、不发请求', (tester) async { + final auth = await pumpEdit(tester, nickname: '小柴'); + + // 预填即原值。 + expect(find.text('小柴'), findsOneWidget); + final button = tester.widget( + find.widgetWithText(FilledButton, '保存'), + ); + expect(button.onPressed, isNull); + expect(auth.updateMePayloads, isEmpty); + }); + + testWidgets('无昵称时预填空串(不预填 username,避免把回退固化成真昵称)', (tester) async { + await pumpEdit(tester); + + final field = tester.widget(nicknameField); + expect(field.controller!.text, ''); + expect(find.text('未设置,当前展示为用户名「llx」'), findsOneWidget); + // 无昵称时不给「清除昵称」入口(清一个不存在的东西无意义)。 + expect(find.text('清除昵称'), findsNothing); + }); + }); + + group('校验与失败语义', () { + testWidgets('33 码点昵称:字段级 errorText,不发请求', (tester) async { + final auth = await pumpEdit(tester); + + await tester.enterText(nicknameField, '柴' * 33); + await tester.pump(); + await tester.tap(find.text('保存')); + await tester.pumpAndSettle(); + + expect(find.text('昵称最多 32 个字'), findsOneWidget); + expect(auth.updateMePayloads, isEmpty); + }); + + testWidgets('纯空格昵称:不是隐式清空,提示走显式清除入口', (tester) async { + final auth = await pumpEdit(tester, nickname: '小柴'); + + await tester.enterText(nicknameField, ' '); + await tester.pump(); + // 空白输入被判为「不改」,保存钮禁用(不会误发清空)。 + final button = tester.widget( + find.widgetWithText(FilledButton, '保存'), + ); + expect(button.onPressed, isNull); + expect(auth.updateMePayloads, isEmpty); + }); + + testWidgets('42203 头像未就绪 → 横幅提示,页面留在编辑态可重试', (tester) async { + await pumpEdit( + tester, + saveError: const ApiBusinessException( + code: ApiCodes.mediaNotReady, + message: 'asset uploading', + ), + ); + + await tester.enterText(nicknameField, '小柴'); + await tester.pump(); + await tester.tap(find.text('保存')); + await tester.pumpAndSettle(); + + expect(find.byType(InlineErrorBanner), findsOneWidget); + expect(find.text('头像还没上传完,请稍后重试'), findsOneWidget); + expect(find.byType(ProfileEditPage), findsOneWidget); + }); + + testWidgets('40405 头像 asset 失效 → 提示重新上传', (tester) async { + await pumpEdit( + tester, + saveError: const ApiBusinessException( + code: ApiCodes.mediaNotFound, + message: 'gone', + ), + ); + + await tester.enterText(nicknameField, '小柴'); + await tester.pump(); + await tester.tap(find.text('保存')); + await tester.pumpAndSettle(); + + expect(find.text('头像已失效,请重新上传'), findsOneWidget); + }); + }); + + group('头像上传接线', () { + testWidgets('上传全链路:purpose=user_avatar,ready 后确认 → 载荷带新 assetId', ( + tester, + ) async { + final auth = await pumpEdit(tester); + + await tester.tap(find.text('更换头像')); + await tester.pumpAndSettle(); + + // 上传成功后 sheet 给预览 + 「使用这张」。 + expect(find.text('使用这张'), findsOneWidget); + await tester.tap(find.text('使用这张')); + await tester.pumpAndSettle(); + + // 用途即引用侧的类型检查:必须是 user_avatar。 + final request = community.lastMediaUploadRequest!; + expect(request.purpose, MediaPurpose.userAvatar); + expect(request.purpose.wire, 'user_avatar'); + + expect(find.text('已选择\n新头像'), findsOneWidget); + + await tester.tap(find.text('保存')); + await tester.pumpAndSettle(); + + final payload = auth.updateMePayloads.single; + expect(payload, {'avatarAssetId': 'a-1'}); + expect(payload.containsKey('nickname'), isFalse); + }); + + testWidgets('上传后再改昵称 → 两键齐出(一次 PATCH 改两样)', (tester) async { + final auth = await pumpEdit(tester); + + await tester.tap(find.text('更换头像')); + await tester.pumpAndSettle(); + await tester.tap(find.text('使用这张')); + await tester.pumpAndSettle(); + + await tester.enterText(nicknameField, '小柴'); + await tester.pump(); + await tester.tap(find.text('保存')); + await tester.pumpAndSettle(); + + expect(auth.updateMePayloads.single, { + 'nickname': '小柴', + 'avatarAssetId': 'a-1', + }); + }); + }); +} diff --git a/test/features/profile/profile_models_test.dart b/test/features/profile/profile_models_test.dart new file mode 100644 index 0000000..c8ec3a3 --- /dev/null +++ b/test/features/profile/profile_models_test.dart @@ -0,0 +1,153 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/features/auth/auth_models.dart'; +import 'package:patbond_flutter/features/profile/profile_display.dart'; + +import '../../helpers/auth_test_helpers.dart'; + +void main() { + group('展示名回退 nickname ?? username', () { + test('有昵称:展示昵称,nickname 字段保留 DB 原值', () { + final profile = buildProfile(username: 'llx', nickname: '小柴'); + expect(profile.displayName, '小柴'); + expect(profile.nickname, '小柴'); + }); + + test('无昵称:展示 username,但 nickname 仍为 null(不被回退值污染)', () { + final profile = buildProfile(username: 'llx'); + expect(profile.displayName, 'llx'); + expect( + profile.nickname, + isNull, + reason: '回退只在展示层发生;nickname 必须保持 null,否则编辑页预填后会被固化成真昵称', + ); + }); + + test('hasAvatar 只看 avatarUrl(响应不外露 avatarAssetId)', () { + expect(buildProfile().hasAvatar, isFalse); + expect( + buildProfile(avatarUrl: 'https://m/a.jpg?X-Amz-Signature=s').hasAvatar, + isTrue, + ); + }); + }); + + group('UpdateMeRequest 三态载荷', () { + test('什么都没碰 → isEmpty 且 JSON 为空对象', () { + const request = UpdateMeRequest(); + expect(request.isEmpty, isTrue); + expect(request.toJson(), isEmpty); + }); + + test('只改昵称:avatarAssetId 键**不出现**(不改,而不是清空)', () { + const request = UpdateMeRequest(nickname: PatchField.value('小柴')); + final json = request.toJson(); + expect(json, {'nickname': '小柴'}); + expect( + json.containsKey('avatarAssetId'), + isFalse, + reason: '发成 avatarAssetId: null 会把用户头像一起清掉', + ); + }); + + test('只清昵称:nickname 显式 null,头像键仍不出现', () { + const request = UpdateMeRequest(nickname: PatchField.clear()); + final json = request.toJson(); + expect(json.containsKey('nickname'), isTrue); + expect(json['nickname'], isNull); + expect(json.containsKey('avatarAssetId'), isFalse); + }); + + test('一次改两样:两键齐出', () { + const request = UpdateMeRequest( + nickname: PatchField.value('小柴'), + avatarAssetId: PatchField.value('asset-9'), + ); + expect(request.toJson(), {'nickname': '小柴', 'avatarAssetId': 'asset-9'}); + expect(request.isEmpty, isFalse); + }); + + test('只清头像:nickname 键不出现', () { + const request = UpdateMeRequest( + avatarAssetId: PatchField.clear(), + ); + final json = request.toJson(); + expect(json.containsKey('nickname'), isFalse); + expect(json['avatarAssetId'], isNull); + }); + }); + + group('昵称校验与 ck_users_nickname 对齐', () { + test('1 码点通过;32 CJK 通过;33 拒', () { + expect(validateNickname('柴'), isNull); + expect(validateNickname('柴' * 32), isNull); + expect(validateNickname('柴' * 33), isNotNull); + }); + + test('32 个 emoji(UTF-16 长度 64)通过——长度按码点计而非 String.length', () { + const emoji = '🐕'; + final name = emoji * 32; + expect(name.length, 64, reason: '前提:UTF-16 长度确为 64'); + expect( + validateNickname(name), + isNull, + reason: '按 String.length 校验会误拒数据库能存的 32 emoji 昵称', + ); + expect(validateNickname(emoji * 33), isNotNull); + }); + + test('btrim 对齐:前后空格不计入长度', () { + expect(validateNickname(' 豆豆 '), isNull); + expect(validateNickname(' ${'柴' * 32} '), isNull); + }); + + test('纯空白不是隐式清空,而是校验失败并指向显式清除入口', () { + final message = validateNickname(' '); + expect(message, isNotNull); + expect(message, contains('清除昵称')); + }); + }); + + group('错误文案分层', () { + test('保存失败按 PATCH /me 错误谱分层', () { + expect( + profileSaveErrorMessage( + const ApiBusinessException(code: ApiCodes.paramError, message: 'x'), + ), + '填写内容不符合要求,请检查后重试', + ); + expect( + profileSaveErrorMessage( + const ApiBusinessException( + code: ApiCodes.mediaNotFound, + message: 'x', + ), + ), + '头像已失效,请重新上传', + ); + expect( + profileSaveErrorMessage( + const ApiBusinessException( + code: ApiCodes.mediaNotReady, + message: 'x', + ), + ), + '头像还没上传完,请稍后重试', + ); + expect( + profileSaveErrorMessage(const ApiNetworkException()), + '网络异常,请检查网络后重试', + ); + }); + + test('加载失败文案不透出服务端 message', () { + expect( + profileLoadErrorMessage( + const ApiNetworkException('Connection refused'), + ), + '网络异常,请检查网络后重试', + ); + expect(profileLoadErrorMessage(null), '加载失败,请稍后重试'); + }); + }); +} diff --git a/test/features/profile/profile_page_test.dart b/test/features/profile/profile_page_test.dart new file mode 100644 index 0000000..9667f28 --- /dev/null +++ b/test/features/profile/profile_page_test.dart @@ -0,0 +1,207 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; +import 'package:patbond_flutter/features/auth/auth_models.dart'; +import 'package:patbond_flutter/features/community/community_models.dart'; +import 'package:patbond_flutter/features/profile/profile_controller.dart'; +import 'package:patbond_flutter/features/profile/profile_edit_page.dart'; +import 'package:patbond_flutter/features/profile/profile_page.dart'; +import 'package:patbond_flutter/state/app_state.dart'; + +import '../../helpers/auth_test_helpers.dart'; +import '../../helpers/community_test_helpers.dart'; + +void main() { + late FakeCommunityRepository community; + + setUp(() { + community = FakeCommunityRepository(); + community.onGetMyCommunityStats = () async => + CommunityStats.fromJson(sampleCommunityStatsJson()); + community.onGetFollowStats = (_) async => + FollowStats.fromJson(sampleFollowStatsJson()); + }); + + Future pumpProfile( + WidgetTester tester, { + required FakeAuthRepository auth, + }) async { + // 资料页头部 + 五行菜单 + 两个底部按钮,加高视口保证全在栏内。 + tester.view.physicalSize = const Size(700, 2400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + final controller = ProfileController( + authRepository: auth, + communityRepository: community, + ); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: Scaffold( + body: ProfilePage(appState: AppState(), controller: controller), + ), + ), + ); + return controller; + } + + testWidgets('loading 态:居中转圈,无 demo 文案', (tester) async { + final gate = Completer(); + await pumpProfile( + tester, + auth: FakeAuthRepository(meHandler: () => gate.future), + ); + // 预取推到帧末,故先走一帧再断言。 + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.text('萌宠新手(豆豆家长)'), findsNothing); + expect(find.text('Patbond 社区创作达人'), findsNothing); + + gate.complete(buildProfile()); + await tester.pumpAndSettle(); + }); + + testWidgets('ready + 有昵称:展示昵称与 @username,四个数字来自服务端', (tester) async { + await pumpProfile( + tester, + auth: FakeAuthRepository( + meHandler: () async => buildProfile(username: 'llx', nickname: '小柴'), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('小柴'), findsOneWidget); + expect(find.text('@llx'), findsOneWidget); + // 24 关注我 / 7 我关注 / 128 获赞 / 12 作品(helpers 样本)。 + expect(find.text('24'), findsOneWidget); + expect(find.text('7'), findsOneWidget); + expect(find.text('128'), findsOneWidget); + expect(find.text('12'), findsOneWidget); + expect(find.text('获赞'), findsOneWidget); + expect(find.text('我的作品'), findsOneWidget); + // demo 硬编码彻底退役。 + expect(find.text('1.8k'), findsNothing); + expect(find.text('萌宠新手(豆豆家长)'), findsNothing); + }); + + testWidgets('ready + 无昵称:展示名回退 username(回退在客户端展示层)', (tester) async { + await pumpProfile( + tester, + auth: FakeAuthRepository( + meHandler: () async => buildProfile(username: 'llx'), + ), + ); + await tester.pumpAndSettle(); + + // 展示名与 @username 两处都是 llx。 + expect(find.text('llx'), findsOneWidget); + expect(find.text('@llx'), findsOneWidget); + }); + + testWidgets('空数据:四个数字全 0(不是空白、不是错误态)', (tester) async { + community.onGetMyCommunityStats = () async => CommunityStats.fromJson( + sampleCommunityStatsJson(receivedLikeCount: 0, publishedPostCount: 0), + ); + community.onGetFollowStats = (_) async => FollowStats.fromJson( + sampleFollowStatsJson(followerCount: 0, followingCount: 0), + ); + await pumpProfile(tester, auth: FakeAuthRepository()); + await tester.pumpAndSettle(); + + expect(find.text('0'), findsNWidgets(4)); + expect(find.text('统计加载失败'), findsNothing); + }); + + testWidgets('error 态:横幅 + 重试;重试成功进 ready', (tester) async { + var attempts = 0; + await pumpProfile( + tester, + auth: FakeAuthRepository( + meHandler: () async { + attempts += 1; + if (attempts == 1) throw const ApiNetworkException('断网'); + return buildProfile(nickname: '小柴'); + }, + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(InlineErrorBanner), findsOneWidget); + expect(find.text('网络异常,请检查网络后重试'), findsOneWidget); + + await tester.tap(find.text('重试')); + await tester.pumpAndSettle(); + + expect(find.text('小柴'), findsOneWidget); + expect(find.byType(InlineErrorBanner), findsNothing); + }); + + testWidgets('统计块独立降级:资料照常显示,统计给「加载失败 + 重试」', (tester) async { + var statsAttempts = 0; + community.onGetMyCommunityStats = () async { + statsAttempts += 1; + if (statsAttempts == 1) throw const ApiNetworkException('断网'); + return CommunityStats.fromJson(sampleCommunityStatsJson()); + }; + await pumpProfile( + tester, + auth: FakeAuthRepository( + meHandler: () async => buildProfile(nickname: '小柴'), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('小柴'), findsOneWidget, reason: '统计失败不该拖垮整页'); + expect(find.text('统计加载失败'), findsOneWidget); + + await tester.tap(find.text('重试')); + await tester.pumpAndSettle(); + + expect(find.text('128'), findsOneWidget); + expect(find.text('统计加载失败'), findsNothing); + }); + + testWidgets('点「编辑资料」进编辑页;保存成功回资料页并提示', (tester) async { + final auth = FakeAuthRepository( + meHandler: () async => buildProfile(username: 'llx'), + updateMeHandler: (_) async => + buildProfile(username: 'llx', nickname: '小柴'), + ); + await pumpProfile(tester, auth: auth); + await tester.pumpAndSettle(); + + await tester.tap(find.text('编辑资料')); + await tester.pumpAndSettle(); + expect(find.byType(ProfileEditPage), findsOneWidget); + + await tester.enterText(find.byType(TextFormField), '小柴'); + await tester.pump(); + await tester.tap(find.text('保存')); + await tester.pumpAndSettle(); + + expect(find.byType(ProfileEditPage), findsNothing); + expect(find.text('资料已更新'), findsOneWidget); + // 头部随之更新为新昵称。 + expect(find.text('小柴'), findsOneWidget); + }); + + testWidgets('未装配上传能力时编辑页不渲染头像入口', (tester) async { + await pumpProfile( + tester, + auth: FakeAuthRepository(meHandler: () async => buildProfile()), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('编辑资料')); + await tester.pumpAndSettle(); + + expect(find.text('更换头像'), findsNothing); + expect(find.byType(TextFormField), findsOneWidget, reason: '昵称输入仍在'); + }); +} diff --git a/test/helpers/auth_test_helpers.dart b/test/helpers/auth_test_helpers.dart index a2bb24b..b5bdff1 100644 --- a/test/helpers/auth_test_helpers.dart +++ b/test/helpers/auth_test_helpers.dart @@ -26,15 +26,23 @@ class FakeAuthRepository implements AuthRepository { this.loginHandler, this.registerHandler, this.restoreHandler, + this.meHandler, + this.updateMeHandler, }); final Future Function()? loginHandler; final Future Function()? registerHandler; final Future Function()? restoreHandler; + final Future Function()? meHandler; + final Future Function(UpdateMeRequest)? updateMeHandler; int loginCalls = 0; int registerCalls = 0; int logoutCalls = 0; + int meCalls = 0; + + /// 收到的 PATCH 载荷(三态断言用:**未改字段不应出现在 map 里**)。 + final List> updateMePayloads = []; @override Future login({required String username, required String password}) { @@ -64,9 +72,51 @@ class FakeAuthRepository implements AuthRepository { } @override - Future me() async => throw UnimplementedError(); + Future me() async { + meCalls += 1; + // 缺省给一个「无昵称无头像」的样本(回退到 username 的形态): + // 主壳级测试不必逐个注入。 + return meHandler?.call() ?? Future.value(buildProfile()); + } + + @override + Future updateMe(UpdateMeRequest request) async { + updateMePayloads.add(request.toJson()); + if (updateMeHandler == null) throw UnimplementedError(); + return updateMeHandler!(request); + } } +/// `/me` 响应样本(契约 v1.4.0 六字段全字段)。 +Map sampleMeJson({ + String userId = 'user-1', + String username = 'llx', + String? nickname, + String? avatarUrl, + String? phone = '+8613800138000', +}) => { + 'userId': userId, + 'username': username, + 'nickname': nickname, + 'avatarUrl': avatarUrl, + 'phone': phone, + 'createdAt': '2026-09-01T10:00:00+08:00', +}; + +UserProfile buildProfile({ + String userId = 'user-1', + String username = 'llx', + String? nickname, + String? avatarUrl, +}) => UserProfile.fromJson( + sampleMeJson( + userId: userId, + username: username, + nickname: nickname, + avatarUrl: avatarUrl, + ), +); + /// mock dio:用假 HttpClientAdapter 返回预设响应并记录请求。 class FakeHttpAdapter implements HttpClientAdapter { FakeHttpAdapter(this.handler); diff --git a/test/helpers/community_test_helpers.dart b/test/helpers/community_test_helpers.dart index 38387e0..89c3f20 100644 --- a/test/helpers/community_test_helpers.dart +++ b/test/helpers/community_test_helpers.dart @@ -201,6 +201,7 @@ class FakeCommunityRepository implements CommunityRepository { Future Function(String commentId)? onDeleteComment; Future Function(String userId, bool target)? onFollowToggle; Future Function(String userId)? onGetFollowStats; + Future Function()? onGetMyCommunityStats; Future Function(CreateMediaUploadRequest request)? onCreateMediaUpload; Future Function(String assetId)? onCompleteMediaUpload; @@ -213,6 +214,9 @@ class FakeCommunityRepository implements CommunityRepository { /// createPost 收到的幂等键顺序(同键重放断言用)。 final List idempotencyKeys = []; + /// 最近一次 createMediaUpload 的请求(purpose 断言用)。 + CreateMediaUploadRequest? lastMediaUploadRequest; + @override Future> getFeed({int? limit, String? cursor}) { calls.add('feed:cursor=$cursor'); @@ -254,6 +258,7 @@ class FakeCommunityRepository implements CommunityRepository { CreateMediaUploadRequest request, ) { calls.add('createUpload:${request.mimeType}:${request.byteSize}'); + lastMediaUploadRequest = request; return onCreateMediaUpload!(request); } @@ -338,10 +343,44 @@ class FakeCommunityRepository implements CommunityRepository { @override Future getFollowStats(String userId) { calls.add('followStats:$userId'); - return onGetFollowStats!(userId); + return onGetFollowStats?.call(userId) ?? + Future.value(FollowStats.fromJson(sampleFollowStatsJson())); + } + + @override + Future getMyCommunityStats() { + calls.add('communityStats'); + return onGetMyCommunityStats?.call() ?? + // 缺省零值(契约:空数据返回 0 而非 null,且永不 404)。 + Future.value( + CommunityStats.fromJson( + sampleCommunityStatsJson( + receivedLikeCount: 0, + publishedPostCount: 0, + ), + ), + ); } @override Future> listMyBookmarks({int? limit, String? cursor}) => throw UnimplementedError(); } + +Map sampleCommunityStatsJson({ + int receivedLikeCount = 128, + int publishedPostCount = 12, +}) => { + 'receivedLikeCount': receivedLikeCount, + 'publishedPostCount': publishedPostCount, +}; + +Map sampleFollowStatsJson({ + int followerCount = 24, + int followingCount = 7, + bool followedByMe = false, +}) => { + 'followerCount': followerCount, + 'followingCount': followingCount, + 'followedByMe': followedByMe, +}; diff --git a/test/smoke/detail_interactions_smoke_test.dart b/test/smoke/detail_interactions_smoke_test.dart index 86ae478..9ce510e 100644 --- a/test/smoke/detail_interactions_smoke_test.dart +++ b/test/smoke/detail_interactions_smoke_test.dart @@ -252,6 +252,9 @@ class _SwitchableRepository implements CommunityRepository { Future> listMyBookmarks({int? limit, String? cursor}) => target.listMyBookmarks(limit: limit, cursor: cursor); + @override + Future getMyCommunityStats() => target.getMyCommunityStats(); + @override Future followUser(String userId) => target.followUser(userId);