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';
|
import 'package:im_app/features/settings/di/settings_providers.dart';
|
||||||
|
|
||||||
/// 编辑个人资料页状态
|
/// 编辑个人资料页状态(#49 / #50)
|
||||||
class EditProfileState {
|
class EditProfileState {
|
||||||
final String nickname;
|
final String nickname;
|
||||||
final String bio;
|
final String bio;
|
||||||
final String? avatarUrl;
|
final String? avatarUrl;
|
||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
final bool isSaving;
|
final bool isSaving;
|
||||||
|
final bool isUploadingAvatar;
|
||||||
final String? error;
|
final String? error;
|
||||||
|
|
||||||
const EditProfileState({
|
const EditProfileState({
|
||||||
@@ -17,6 +25,7 @@ class EditProfileState {
|
|||||||
this.avatarUrl,
|
this.avatarUrl,
|
||||||
this.isLoading = false,
|
this.isLoading = false,
|
||||||
this.isSaving = false,
|
this.isSaving = false,
|
||||||
|
this.isUploadingAvatar = false,
|
||||||
this.error,
|
this.error,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -27,6 +36,7 @@ class EditProfileState {
|
|||||||
bool clearAvatarUrl = false,
|
bool clearAvatarUrl = false,
|
||||||
bool? isLoading,
|
bool? isLoading,
|
||||||
bool? isSaving,
|
bool? isSaving,
|
||||||
|
bool? isUploadingAvatar,
|
||||||
String? error,
|
String? error,
|
||||||
bool clearError = false,
|
bool clearError = false,
|
||||||
}) {
|
}) {
|
||||||
@@ -36,12 +46,36 @@ class EditProfileState {
|
|||||||
avatarUrl: clearAvatarUrl ? null : (avatarUrl ?? this.avatarUrl),
|
avatarUrl: clearAvatarUrl ? null : (avatarUrl ?? this.avatarUrl),
|
||||||
isLoading: isLoading ?? this.isLoading,
|
isLoading: isLoading ?? this.isLoading,
|
||||||
isSaving: isSaving ?? this.isSaving,
|
isSaving: isSaving ?? this.isSaving,
|
||||||
|
isUploadingAvatar: isUploadingAvatar ?? this.isUploadingAvatar,
|
||||||
error: clearError ? null : (error ?? this.error),
|
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> {
|
class EditProfileViewModel extends Notifier<EditProfileState> {
|
||||||
@override
|
@override
|
||||||
EditProfileState build() {
|
EditProfileState build() {
|
||||||
@@ -49,6 +83,8 @@ class EditProfileViewModel extends Notifier<EditProfileState> {
|
|||||||
return const EditProfileState(isLoading: true);
|
return const EditProfileState(isLoading: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 初始化 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
Future<void> _loadCurrentProfile() async {
|
Future<void> _loadCurrentProfile() async {
|
||||||
try {
|
try {
|
||||||
final profile = await ref.read(fetchProfileUseCaseProvider).execute();
|
final profile = await ref.read(fetchProfileUseCaseProvider).execute();
|
||||||
@@ -63,6 +99,8 @@ class EditProfileViewModel extends Notifier<EditProfileState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 字段更新 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
void updateNickname(String value) {
|
void updateNickname(String value) {
|
||||||
state = state.copyWith(nickname: value, clearError: true);
|
state = state.copyWith(nickname: value, clearError: true);
|
||||||
}
|
}
|
||||||
@@ -71,8 +109,64 @@ class EditProfileViewModel extends Notifier<EditProfileState> {
|
|||||||
state = state.copyWith(bio: value);
|
state = state.copyWith(bio: value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO Issue #6: 头像上传(CDN 流程)
|
// ── 头像选取与上传(#49) ──────────────────────────────────────────────────
|
||||||
// void pickAndUploadAvatar() async { ... }
|
|
||||||
|
/// 弹出选图 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 {
|
Future<bool> save() async {
|
||||||
if (state.nickname.trim().isEmpty) {
|
if (state.nickname.trim().isEmpty) {
|
||||||
|
|||||||
@@ -1,20 +1,121 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/edit_profile_view_model.dart';
|
||||||
import 'package:im_app/features/settings/presentation/settings_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});
|
const EditProfilePage({super.key});
|
||||||
|
|
||||||
@override
|
@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 state = ref.watch(editProfileViewModelProvider);
|
||||||
final vm = ref.read(editProfileViewModelProvider.notifier);
|
final vm = ref.read(editProfileViewModelProvider.notifier);
|
||||||
|
|
||||||
|
// Sync text controllers after profile loads
|
||||||
|
_syncControllers(state);
|
||||||
|
|
||||||
|
final canSave =
|
||||||
|
state.nickname.trim().isNotEmpty && !state.isUploadingAvatar;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('编辑资料'),
|
title: const Text('编辑资料'),
|
||||||
@@ -30,14 +131,17 @@ class EditProfilePage extends ConsumerWidget {
|
|||||||
)
|
)
|
||||||
else
|
else
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () async {
|
onPressed: canSave
|
||||||
|
? () async {
|
||||||
final ok = await vm.save();
|
final ok = await vm.save();
|
||||||
if (ok && context.mounted) {
|
if (ok && context.mounted) {
|
||||||
// 刷新我的页面资料卡
|
ref
|
||||||
ref.read(settingsViewModelProvider.notifier).loadProfile();
|
.read(settingsViewModelProvider.notifier)
|
||||||
|
.loadProfile();
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
: null,
|
||||||
child: const Text('保存'),
|
child: const Text('保存'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -45,21 +149,132 @@ class EditProfilePage extends ConsumerWidget {
|
|||||||
body: state.isLoading
|
body: state.isLoading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: ListView(
|
: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16),
|
||||||
children: [
|
children: [
|
||||||
// 头像区域
|
// ── 头像区 ──────────────────────────────────────────────────
|
||||||
Center(
|
Center(child: _AvatarSection(state: state, onTap: _showAvatarSourceSheet)),
|
||||||
child: Stack(
|
|
||||||
children: [
|
const SizedBox(height: 28),
|
||||||
CircleAvatar(
|
|
||||||
radius: 44,
|
// ── 昵称卡片 ─────────────────────────────────────────────
|
||||||
backgroundImage: state.avatarUrl != null
|
_FormCard(
|
||||||
? NetworkImage(state.avatarUrl!)
|
label: '昵称',
|
||||||
: null,
|
child: TextField(
|
||||||
child: state.avatarUrl == null
|
controller: _nicknameCtrl,
|
||||||
? const Icon(Icons.person, size: 40)
|
maxLength: 50,
|
||||||
: null,
|
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(
|
Positioned(
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
@@ -69,59 +284,123 @@ class EditProfilePage extends ConsumerWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.primary,
|
color: Theme.of(context).colorScheme.primary,
|
||||||
shape: BoxShape.circle,
|
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user