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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user