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,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 {
|
||||
final ok = await vm.save();
|
||||
if (ok && context.mounted) {
|
||||
// 刷新我的页面资料卡
|
||||
ref.read(settingsViewModelProvider.notifier).loadProfile();
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
onPressed: canSave
|
||||
? () async {
|
||||
final ok = await vm.save();
|
||||
if (ok && context.mounted) {
|
||||
ref
|
||||
.read(settingsViewModelProvider.notifier)
|
||||
.loadProfile();
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
: null,
|
||||
child: const Text('保存'),
|
||||
),
|
||||
],
|
||||
@@ -45,83 +149,258 @@ 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,
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
// ── 头像区 ──────────────────────────────────────────────────
|
||||
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,
|
||||
),
|
||||
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: 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
|
||||
// ── 错误 Banner ───────────────────────────────────────────
|
||||
if (state.error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
state.error!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
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,
|
||||
child: Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user