新增:资料页真实化 + 编辑页——/me 读写、PATCH 三态、获赞与作品统计(T3.5-08)
资料页头部三项(展示名 / 头像 / 数字)自此全部来自服务端,176 行硬编码 demo(「萌宠新手(豆豆家长)」/ 24 / 1.8k / 2)退役。 - `PatchField<T>` 承载契约 v1.4.0 的 PATCH 三态(absent 不落键 / clear 落 显式 null / value 落值)。三态必须由类型承载而非 `T?` 加约定:把「不改」 也编码成 null,用户只改昵称就会连头像一起被服务端清掉。 - `UserProfile` 补 nickname / avatarUrl,展示回退 `nickname ?? username` **做在客户端展示层**——服务端 /me 刻意返回 DB 原值,编辑页因此只用原值 预填,避免把展示约定固化成真实昵称。 - `ProfileController`:`/me` 主链路四态 + 两块统计独立三态(统计失败只降级 这一块,不为两个数字丢掉整页);登出 reset 防跨账号泄漏。 - 编辑页维护三态意图而非「当前值整体提交」:昵称 / 头像各有显式清除入口, 未改字段的键根本不进 JSON;空 patch 直接短路(服务端对空 patch 答 400)。 - 昵称校验按**码点**计 1~32 且先 btrim,与 `ck_users_nickname` 对齐; 纯空白是校验失败而非隐式清空。 - `MediaUploader` 的 purpose 参数化 + 复用其六态编排的 `AvatarUploadSheet` (单图、预览确认后才交付 ready assetId,孤儿防护不变)。 - 修复 `/api/v1/me` 端口线路:该端点由 user 服务(:8082)提供,`ApiAuthRepository` 此前只挂 auth(:8081)会得到 404。此前无人消费 me(),故这条错线一直没被 触发;本单是第一个真实消费者,桌面实测即暴露。 测试 526 → 588(+62)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<String>.absent(),
|
||||
this.avatarAssetId = const PatchField<String>.absent(),
|
||||
});
|
||||
|
||||
final PatchField<String> nickname;
|
||||
|
||||
/// 头像 asset(两步上传产物,`purpose` 须为 `user_avatar`)。
|
||||
final PatchField<String> avatarAssetId;
|
||||
|
||||
/// 本次 patch 未触及任何字段(发出去必得 400/40000)。
|
||||
bool get isEmpty => !nickname.isPresent && !avatarAssetId.isPresent;
|
||||
|
||||
Map<String, Object?> toJson() {
|
||||
final json = <String, Object?>{};
|
||||
nickname.writeTo(json, 'nickname');
|
||||
avatarAssetId.writeTo(json, 'avatarAssetId');
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,18 +28,33 @@ abstract class AuthRepository {
|
||||
Future<SessionRestoreResult> restoreSession();
|
||||
|
||||
Future<UserProfile> me();
|
||||
|
||||
/// 本人资料部分更新(昵称 / 头像,三态语义见 [UpdateMeRequest])。
|
||||
/// 成功返回**更新后的全量 [UserProfile]**(服务端回显,含现签 avatarUrl)。
|
||||
Future<UserProfile> 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<UserProfile> 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<String, dynamic>);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UserProfile> 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<String, dynamic>);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, dynamic> json) {
|
||||
return CommunityStats(
|
||||
receivedLikeCount: json['receivedLikeCount'] as int,
|
||||
publishedPostCount: json['publishedPostCount'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
final int receivedLikeCount;
|
||||
final int publishedPostCount;
|
||||
}
|
||||
|
||||
@@ -55,6 +55,9 @@ abstract class CommunityRepository {
|
||||
Future<FollowState> followUser(String userId);
|
||||
Future<FollowState> unfollowUser(String userId);
|
||||
Future<FollowStats> getFollowStats(String userId);
|
||||
|
||||
// ---- 我的社区数字(主体恒为调用者,路径上无 userId)----
|
||||
Future<CommunityStats> 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<CommunityStats> getMyCommunityStats() async {
|
||||
final data = await _request('/api/v1/me/community-stats');
|
||||
return CommunityStats.fromJson(_asMap(data));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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<MainShellPage> {
|
||||
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<MainShellPage> {
|
||||
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(
|
||||
|
||||
@@ -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<void> 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<void> 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<UserProfile?> 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: "<ready assetId>"` |
|
||||
/// | 点「清除头像」 | `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<ProfileEditPage> createState() => _ProfileEditPageState();
|
||||
}
|
||||
|
||||
class _ProfileEditPageState extends State<ProfileEditPage> {
|
||||
late final TextEditingController _nickname;
|
||||
|
||||
/// 页面进入时的资料快照:差量比对的基准(服务端回显的权威值)。
|
||||
late final UserProfile _initial;
|
||||
|
||||
/// 昵称的三态意图:null 表示「跟随输入框与初值的比对结果」,
|
||||
/// 非 null 表示用户已显式点过「清除昵称」。
|
||||
bool _nicknameCleared = false;
|
||||
|
||||
/// 头像的三态意图(absent / clear / 新 assetId)。
|
||||
PatchField<String> _avatarIntent = const PatchField<String>.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<String> get _nicknamePatch {
|
||||
if (_nicknameCleared) {
|
||||
// 本来就没有昵称时「清除」是空操作,不落键(避免制造空 patch 之外
|
||||
// 的无意义写入)。
|
||||
return _initial.nickname == null
|
||||
? const PatchField<String>.absent()
|
||||
: const PatchField<String>.clear();
|
||||
}
|
||||
final trimmed = _nickname.text.trim();
|
||||
if (trimmed.isEmpty) return const PatchField<String>.absent();
|
||||
if (trimmed == _initial.nickname) return const PatchField<String>.absent();
|
||||
return PatchField<String>.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<void> _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<String>.value(assetId);
|
||||
// 预览沿用上传器给的可访问 URL?—— 没有:complete 响应的 url 与
|
||||
// /me 的 avatarUrl 同为预签名,但本页不缓存它,改为保存后由服务端
|
||||
// 回显。上传成功到保存之间以「已选择新头像」文案示意。
|
||||
_pendingAvatarUrl = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _clearAvatar() {
|
||||
setState(() {
|
||||
_avatarIntent = const PatchField<String>.clear();
|
||||
_pendingAvatarUrl = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _clearNickname() {
|
||||
setState(() {
|
||||
_nicknameCleared = true;
|
||||
_nicknameError = null;
|
||||
_nickname.clear();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void> 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<ProfilePage> createState() => _ProfilePageState();
|
||||
}
|
||||
|
||||
class _ProfilePageState extends State<ProfilePage> {
|
||||
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<void> _openEdit() async {
|
||||
if (_controller.profile == null) return;
|
||||
final saved = await Navigator.of(context).push<bool>(
|
||||
MaterialPageRoute<bool>(
|
||||
builder: (_) => ProfileEditPage(
|
||||
controller: _controller,
|
||||
avatarUploaderBuilder: widget.avatarUploaderBuilder,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (saved == true && mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('资料已更新')));
|
||||
}
|
||||
}
|
||||
|
||||
/// 刻意保留的 demo 家具:AppState 仍承载首页天气/本地服务的演示数据,
|
||||
/// 这个入口是它唯一的复位口(不影响服务端真实资料)。
|
||||
Future<void> reset(BuildContext context) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user