diff --git a/apps/im_app/lib/features/settings/presentation/edit_profile_view_model.dart b/apps/im_app/lib/features/settings/presentation/edit_profile_view_model.dart index 6e56e00..57fdb26 100644 --- a/apps/im_app/lib/features/settings/presentation/edit_profile_view_model.dart +++ b/apps/im_app/lib/features/settings/presentation/edit_profile_view_model.dart @@ -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 { @override EditProfileState build() { @@ -49,6 +83,8 @@ class EditProfileViewModel extends Notifier { return const EditProfileState(isLoading: true); } + // ── 初始化 ──────────────────────────────────────────────────────────────── + Future _loadCurrentProfile() async { try { final profile = await ref.read(fetchProfileUseCaseProvider).execute(); @@ -63,6 +99,8 @@ class EditProfileViewModel extends Notifier { } } + // ── 字段更新 ────────────────────────────────────────────────────────────── + void updateNickname(String value) { state = state.copyWith(nickname: value, clearError: true); } @@ -71,8 +109,64 @@ class EditProfileViewModel extends Notifier { state = state.copyWith(bio: value); } - // TODO Issue #6: 头像上传(CDN 流程) - // void pickAndUploadAvatar() async { ... } + // ── 头像选取与上传(#49) ────────────────────────────────────────────────── + + /// 弹出选图 Sheet(相册 / 拍照)→ 裁剪 → 上传 CDN → 更新预览 + /// + /// 上传中 [isUploadingAvatar] = true,完成后恢复 false。 + /// 失败时设置 [error](由 UI 显示 SnackBar)。 + Future 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 save() async { if (state.nickname.trim().isEmpty) { diff --git a/apps/im_app/lib/features/settings/view/edit_profile_page.dart b/apps/im_app/lib/features/settings/view/edit_profile_page.dart index 02e6c4c..36cfafb 100644 --- a/apps/im_app/lib/features/settings/view/edit_profile_page.dart +++ b/apps/im_app/lib/features/settings/view/edit_profile_page.dart @@ -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 createState() => _EditProfilePageState(); +} + +class _EditProfilePageState extends ConsumerState { + 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 _showAvatarSourceSheet() async { + final vm = ref.read(editProfileViewModelProvider.notifier); + await showModalBottomSheet( + 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> 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, + ), + ), + ), + ], + ), + ), + ); + } +}