a4a97c03c2
资料页头部三项(展示名 / 头像 / 数字)自此全部来自服务端,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>
272 lines
9.0 KiB
Dart
272 lines
9.0 KiB
Dart
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<String?> showAvatarUploadSheet(
|
||
BuildContext context, {
|
||
required AvatarUploaderBuilder builder,
|
||
required MediaPurpose purpose,
|
||
String title = '更换头像',
|
||
}) {
|
||
return showModalBottomSheet<String>(
|
||
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<AvatarUploadSheet> createState() => _AvatarUploadSheetState();
|
||
}
|
||
|
||
class _AvatarUploadSheetState extends State<AvatarUploadSheet> {
|
||
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<void> _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<Widget> _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,
|
||
),
|
||
);
|
||
}
|
||
}
|