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) {

View File

@@ -1,20 +1,121 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:im_app/features/settings/presentation/edit_profile_view_model.dart';
import 'package:im_app/features/settings/presentation/settings_view_model.dart';
/// 编辑个人资料页
/// 编辑个人资料页#49 / #50
///
/// 对应 Gitea issue #6
class EditProfilePage extends ConsumerWidget {
/// ## 结构
/// - AppBar 右侧:保存按钮(昵称为空或上传中时禁用)
/// - 头像区88pt 圆形 + 渐变占位 + 相机角标 + 上传进度环
/// - Card 分组表单昵称50字/ 个人简介200字多行
/// - 底部错误 Banner
///
/// ## 数据流
/// - 加载:`editProfileViewModelProvider` build → `_loadCurrentProfile()`
/// - 头像:`_showAvatarSourceSheet()` → `vm.pickAndUploadAvatar(source)`
/// - 保存:`vm.save()` → 成功 → `settingsViewModelProvider.loadProfile()` + pop
class EditProfilePage extends ConsumerStatefulWidget {
const EditProfilePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<EditProfilePage> createState() => _EditProfilePageState();
}
class _EditProfilePageState extends ConsumerState<EditProfilePage> {
late final TextEditingController _nicknameCtrl;
late final TextEditingController _bioCtrl;
@override
void initState() {
super.initState();
_nicknameCtrl = TextEditingController();
_bioCtrl = TextEditingController();
}
@override
void dispose() {
_nicknameCtrl.dispose();
_bioCtrl.dispose();
super.dispose();
}
/// Sync controllers once profile is loaded (isLoading → false, nickname populated)
void _syncControllers(EditProfileState state) {
if (!state.isLoading) {
if (_nicknameCtrl.text != state.nickname) {
_nicknameCtrl.value = TextEditingValue(
text: state.nickname,
selection: TextSelection.collapsed(offset: state.nickname.length),
);
}
if (_bioCtrl.text != state.bio) {
_bioCtrl.value = TextEditingValue(
text: state.bio,
selection: TextSelection.collapsed(offset: state.bio.length),
);
}
}
}
Future<void> _showAvatarSourceSheet() async {
final vm = ref.read(editProfileViewModelProvider.notifier);
await showModalBottomSheet<void>(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (_) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 16),
ListTile(
leading: const Icon(Icons.photo_library_rounded),
title: const Text('从相册选取'),
onTap: () {
Navigator.pop(context);
vm.pickAndUploadAvatar(ImageSource.gallery);
},
),
ListTile(
leading: const Icon(Icons.camera_alt_rounded),
title: const Text('拍照'),
onTap: () {
Navigator.pop(context);
vm.pickAndUploadAvatar(ImageSource.camera);
},
),
const SizedBox(height: 8),
],
),
),
);
}
@override
Widget build(BuildContext context) {
final state = ref.watch(editProfileViewModelProvider);
final vm = ref.read(editProfileViewModelProvider.notifier);
// Sync text controllers after profile loads
_syncControllers(state);
final canSave =
state.nickname.trim().isNotEmpty && !state.isUploadingAvatar;
return Scaffold(
appBar: AppBar(
title: const Text('编辑资料'),
@@ -30,14 +131,17 @@ class EditProfilePage extends ConsumerWidget {
)
else
TextButton(
onPressed: () async {
onPressed: canSave
? () async {
final ok = await vm.save();
if (ok && context.mounted) {
// 刷新我的页面资料卡
ref.read(settingsViewModelProvider.notifier).loadProfile();
ref
.read(settingsViewModelProvider.notifier)
.loadProfile();
Navigator.of(context).pop();
}
},
}
: null,
child: const Text('保存'),
),
],
@@ -45,21 +149,132 @@ class EditProfilePage extends ConsumerWidget {
body: state.isLoading
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16),
children: [
// 头像区域
Center(
child: Stack(
children: [
CircleAvatar(
radius: 44,
backgroundImage: state.avatarUrl != null
? NetworkImage(state.avatarUrl!)
: null,
child: state.avatarUrl == null
? const Icon(Icons.person, size: 40)
: null,
// ── 头像区 ──────────────────────────────────────────────────
Center(child: _AvatarSection(state: state, onTap: _showAvatarSourceSheet)),
const SizedBox(height: 28),
// ── 昵称卡片 ─────────────────────────────────────────────
_FormCard(
label: '昵称',
child: TextField(
controller: _nicknameCtrl,
maxLength: 50,
maxLengthEnforcement: MaxLengthEnforcement.enforced,
onChanged: vm.updateNickname,
style: Theme.of(context).textTheme.bodyLarge,
decoration: InputDecoration(
hintText: '请输入昵称',
border: InputBorder.none,
counterText: '',
suffixText: '${state.nickname.length}/50',
suffixStyle: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
),
const SizedBox(height: 12),
// ── 个人简介卡片 ──────────────────────────────────────────
_FormCard(
label: '个人简介',
child: TextField(
controller: _bioCtrl,
maxLength: 200,
maxLengthEnforcement: MaxLengthEnforcement.enforced,
maxLines: null,
minLines: 1,
keyboardType: TextInputType.multiline,
onChanged: vm.updateBio,
style: Theme.of(context).textTheme.bodyLarge,
decoration: InputDecoration(
hintText: '介绍一下自己',
border: InputBorder.none,
counterText: '',
suffixText: '${state.bio.length}/200',
suffixStyle: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
),
// ── 错误 Banner ───────────────────────────────────────────
if (state.error != null) ...[
const SizedBox(height: 16),
_ErrorBanner(message: state.error!),
],
],
),
);
}
}
// ── 头像区88pt 圆 + 渐变占位 + 相机角标 + 上传进度环)────────────────────
class _AvatarSection extends StatelessWidget {
const _AvatarSection({required this.state, required this.onTap});
final EditProfileState state;
final VoidCallback onTap;
// 与 ProfileHeroCard 保持一致的 8 色渐变方案
static const _gradients = [
[Color(0xFF4776E6), Color(0xFF8E54E9)],
[Color(0xFF11998E), Color(0xFF38EF7D)],
[Color(0xFFFF6B6B), Color(0xFFFFE66D)],
[Color(0xFF2193B0), Color(0xFF6DD5ED)],
[Color(0xFFCC2B5E), Color(0xFF753A88)],
[Color(0xFFEB5757), Color(0xFF000000)],
[Color(0xFF56CCF2), Color(0xFF2F80ED)],
[Color(0xFFF7971E), Color(0xFFFFD200)],
];
@override
Widget build(BuildContext context) {
const size = 88.0;
const radius = size / 2;
return GestureDetector(
onTap: onTap,
child: SizedBox(
width: size + 4,
height: size + 4,
child: Stack(
alignment: Alignment.center,
children: [
// 上传中进度环
if (state.isUploadingAvatar)
SizedBox(
width: size + 4,
height: size + 4,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: Theme.of(context).colorScheme.primary,
),
),
// 头像圆
ClipOval(
child: SizedBox(
width: size,
height: size,
child: state.avatarUrl != null
? Image.network(
state.avatarUrl!,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
_GradientPlaceholder(size: size, gradients: _gradients),
)
: _GradientPlaceholder(size: size, gradients: _gradients),
),
),
// 相机角标(右下)
Positioned(
right: 0,
bottom: 0,
@@ -69,59 +284,123 @@ class EditProfilePage extends ConsumerWidget {
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(context).colorScheme.surface,
width: 2,
),
),
child: const Icon(
Icons.camera_alt_rounded,
size: 14,
color: Colors.white,
),
),
),
],
),
),
);
}
}
class _GradientPlaceholder extends StatelessWidget {
const _GradientPlaceholder({required this.size, required this.gradients});
final double size;
final List<List<Color>> gradients;
@override
Widget build(BuildContext context) {
final pair = gradients[0]; // 默认第一组,加载完用真实头像替换
return Container(
width: size,
height: size,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: pair,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: const Icon(Icons.person_rounded, size: 40, color: Colors.white70),
);
}
}
// ── 表单分组卡片 ─────────────────────────────────────────────────────────────
class _FormCard extends StatelessWidget {
const _FormCard({required this.label, required this.child});
final String label;
final Widget child;
@override
Widget build(BuildContext context) {
return Card(
margin: EdgeInsets.zero,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.5,
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
letterSpacing: 0.5,
),
),
const SizedBox(height: 6),
child,
],
),
),
);
}
}
// ── 错误 Banner ──────────────────────────────────────────────────────────────
class _ErrorBanner extends StatelessWidget {
const _ErrorBanner({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Material(
color: Theme.of(context).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Row(
children: [
Icon(
Icons.warning_rounded,
size: 16,
color: Theme.of(context).colorScheme.onErrorContainer,
),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onErrorContainer,
),
child: const Icon(Icons.camera_alt, size: 16, color: Colors.white),
),
),
],
),
),
const SizedBox(height: 8),
Center(
child: TextButton(
onPressed: () {
// TODO Issue #6: 头像上传CDN 流程)
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('头像上传功能开发中')),
);
},
child: const Text('更换头像'),
),
),
const SizedBox(height: 24),
// 昵称
TextFormField(
initialValue: state.nickname,
decoration: const InputDecoration(
labelText: '昵称',
border: OutlineInputBorder(),
counterText: '',
),
maxLength: 50,
onChanged: vm.updateNickname,
),
const SizedBox(height: 16),
// 个人简介
TextFormField(
initialValue: state.bio,
decoration: const InputDecoration(
labelText: '个人简介',
border: OutlineInputBorder(),
alignLabelWithHint: true,
),
maxLines: 3,
maxLength: 200,
onChanged: vm.updateBio,
),
if (state.error != null) ...[
const SizedBox(height: 12),
Text(
state.error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
],
),
);
}
}