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, ), ); } }