feat(settings): 编辑个人资料头像上传全量实现(#49 / #50)

- EditProfileState: 新增 isUploadingAvatar 字段
- EditProfileViewModel: pickAndUploadAvatar()(ImagePicker→裁剪→CDN→state)
- EditProfilePage: 完整重写
  - 88pt 圆形头像 + 8色渐变占位 + 相机角标 + 上传进度环
  - _showAvatarSourceSheet()(相册 / 拍照)
  - Card 分组表单:昵称(50字计数)/ 个人简介(200字多行)
  - 保存按钮(昵称空或上传中禁用)
  - 错误 Banner
  - 保存成功 → 刷新 SettingsViewModel + pop

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
pp-bot
2026-03-24 20:49:58 +09:00
parent 7ae26b3368
commit f77dd8e9ef
2 changed files with 457 additions and 84 deletions

View File

@@ -1,14 +1,22 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_cropper/image_cropper.dart';
import 'package:image_picker/image_picker.dart';
import 'package:im_app/app/di/network_provider.dart';
import 'package:im_app/data/remote/upload_file_request.dart';
import 'package:im_app/features/settings/di/settings_providers.dart';
/// 编辑个人资料页状态
/// 编辑个人资料页状态#49 / #50
class EditProfileState {
final String nickname;
final String bio;
final String? avatarUrl;
final bool isLoading;
final bool isSaving;
final bool isUploadingAvatar;
final String? error;
const EditProfileState({
@@ -17,6 +25,7 @@ class EditProfileState {
this.avatarUrl,
this.isLoading = false,
this.isSaving = false,
this.isUploadingAvatar = false,
this.error,
});
@@ -27,6 +36,7 @@ class EditProfileState {
bool clearAvatarUrl = false,
bool? isLoading,
bool? isSaving,
bool? isUploadingAvatar,
String? error,
bool clearError = false,
}) {
@@ -36,12 +46,36 @@ class EditProfileState {
avatarUrl: clearAvatarUrl ? null : (avatarUrl ?? this.avatarUrl),
isLoading: isLoading ?? this.isLoading,
isSaving: isSaving ?? this.isSaving,
isUploadingAvatar: isUploadingAvatar ?? this.isUploadingAvatar,
error: clearError ? null : (error ?? this.error),
);
}
}
/// 编辑个人资料 ViewModel
/// 编辑个人资料 ViewModel#49 / #50
///
/// ## 数据流
/// ```
/// EditProfilePage (build)
/// └─ ref.watch(editProfileViewModelProvider) 读取 nickname/bio/avatarUrl
///
/// 初始化:
/// _loadCurrentProfile()
/// → FetchProfileUseCase.execute() → GET /app/api/user/profile
/// → 填充 nickname, bio, avatarUrl
///
/// 头像上传(#49
/// pickAndUploadAvatar()
/// → ImagePicker.pickImage(gallery | camera)
/// → ImageCropper.cropImage() iOS UIImagePickerController 圆形预览)
/// → UploadFileRequest FormData → POST /app/api/upload/file
/// → state.avatarUrl = result.url
///
/// 保存:
/// save()
/// → UpdateProfileUseCase.execute(nickname, bio, profilePicUrl)
/// → POST /app/api/user/update-profile
/// ```
class EditProfileViewModel extends Notifier<EditProfileState> {
@override
EditProfileState build() {
@@ -49,6 +83,8 @@ class EditProfileViewModel extends Notifier<EditProfileState> {
return const EditProfileState(isLoading: true);
}
// ── 初始化 ────────────────────────────────────────────────────────────────
Future<void> _loadCurrentProfile() async {
try {
final profile = await ref.read(fetchProfileUseCaseProvider).execute();
@@ -63,6 +99,8 @@ class EditProfileViewModel extends Notifier<EditProfileState> {
}
}
// ── 字段更新 ──────────────────────────────────────────────────────────────
void updateNickname(String value) {
state = state.copyWith(nickname: value, clearError: true);
}
@@ -71,8 +109,64 @@ class EditProfileViewModel extends Notifier<EditProfileState> {
state = state.copyWith(bio: value);
}
// TODO Issue #6: 头像上传CDN 流程)
// void pickAndUploadAvatar() async { ... }
// ── 头像选取与上传(#49 ──────────────────────────────────────────────────
/// 弹出选图 Sheet相册 / 拍照)→ 裁剪 → 上传 CDN → 更新预览
///
/// 上传中 [isUploadingAvatar] = true完成后恢复 false。
/// 失败时设置 [error](由 UI 显示 SnackBar
Future<void> pickAndUploadAvatar(ImageSource source) async {
final picker = ImagePicker();
final picked = await picker.pickImage(
source: source,
maxWidth: 900,
maxHeight: 900,
imageQuality: 85,
);
if (picked == null) return;
// 圆形裁剪iOS UIImagePickerController 圆形预览对齐)
final cropped = await ImageCropper().cropImage(
sourcePath: picked.path,
aspectRatio: const CropAspectRatio(ratioX: 1, ratioY: 1),
uiSettings: [
IOSUiSettings(
title: '裁剪头像',
aspectRatioLockEnabled: true,
resetAspectRatioEnabled: false,
rotateButtonsHidden: false,
),
AndroidUiSettings(
toolbarTitle: '裁剪头像',
lockAspectRatio: true,
),
],
);
if (cropped == null) return;
state = state.copyWith(isUploadingAvatar: true, clearError: true);
try {
final result = await ref.read(networkSdkApiProvider).executeRequest(
UploadFileRequest(filePath: cropped.path),
);
final url = result?.url ?? '';
if (url.isEmpty) throw Exception('上传返回空 URL');
state = state.copyWith(isUploadingAvatar: false, avatarUrl: url);
} catch (e) {
debugPrint('[EditProfileViewModel] avatar upload error: $e');
state = state.copyWith(
isUploadingAvatar: false,
error: '头像上传失败: $e',
);
} finally {
// 清理临时裁剪文件
try {
await File(cropped.path).delete();
} catch (_) {}
}
}
// ── 保存 ──────────────────────────────────────────────────────────────────
Future<bool> save() async {
if (state.nickname.trim().isEmpty) {