diff --git a/frontend/mobile-app/lib/core/services/account_service.dart b/frontend/mobile-app/lib/core/services/account_service.dart index c9fcecb2..9cc09299 100644 --- a/frontend/mobile-app/lib/core/services/account_service.dart +++ b/frontend/mobile-app/lib/core/services/account_service.dart @@ -1,5 +1,6 @@ import 'dart:io'; import 'package:device_info_plus/device_info_plus.dart'; +import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:uuid/uuid.dart'; import '../network/api_client.dart'; @@ -553,7 +554,7 @@ class AccountService { return result; } - /// 获取头像 SVG + /// 获取头像 SVG(初始生成的头像) Future getAvatarSvg() async { debugPrint('$_tag getAvatarSvg() - 获取头像 SVG'); final result = await _secureStorage.read(key: StorageKeys.avatarSvg); @@ -561,6 +562,14 @@ class AccountService { return result; } + /// 获取头像 URL(用户上传的头像) + Future getAvatarUrl() async { + debugPrint('$_tag getAvatarUrl() - 获取头像 URL'); + final result = await _secureStorage.read(key: StorageKeys.avatarUrl); + debugPrint('$_tag getAvatarUrl() - 结果: $result'); + return result; + } + /// 获取推荐码 Future getReferralCode() async { debugPrint('$_tag getReferralCode() - 获取推荐码'); @@ -819,6 +828,79 @@ class AccountService { } } + /// 上传头像 + /// + /// [imageFile] - 图片文件 + /// 返回新的头像URL + Future uploadAvatar(File imageFile) async { + debugPrint('$_tag uploadAvatar() - 开始上传头像'); + debugPrint('$_tag uploadAvatar() - 文件路径: ${imageFile.path}'); + + try { + // 获取文件名和MIME类型 + final fileName = imageFile.path.split('/').last; + final extension = fileName.split('.').last.toLowerCase(); + String mimeType; + switch (extension) { + case 'jpg': + case 'jpeg': + mimeType = 'image/jpeg'; + break; + case 'png': + mimeType = 'image/png'; + break; + case 'gif': + mimeType = 'image/gif'; + break; + case 'webp': + mimeType = 'image/webp'; + break; + default: + mimeType = 'image/jpeg'; + } + + debugPrint('$_tag uploadAvatar() - 文件名: $fileName, MIME: $mimeType'); + + // 创建 FormData + final formData = FormData.fromMap({ + 'file': await MultipartFile.fromFile( + imageFile.path, + filename: fileName, + contentType: DioMediaType.parse(mimeType), + ), + }); + + // 调用 API + debugPrint('$_tag uploadAvatar() - 调用 POST /user/upload-avatar'); + final response = await _apiClient.post( + '/user/upload-avatar', + data: formData, + ); + debugPrint('$_tag uploadAvatar() - API 响应状态码: ${response.statusCode}'); + + if (response.data == null) { + throw const ApiException('上传头像失败: 空响应'); + } + + final responseData = response.data as Map; + final newAvatarUrl = responseData['avatarUrl'] as String; + debugPrint('$_tag uploadAvatar() - 新头像URL: $newAvatarUrl'); + + // 更新本地存储(使用 avatarUrl 而不是 avatarSvg) + await _secureStorage.write(key: StorageKeys.avatarUrl, value: newAvatarUrl); + debugPrint('$_tag uploadAvatar() - 本地存储已更新'); + + return newAvatarUrl; + } on ApiException catch (e) { + debugPrint('$_tag uploadAvatar() - API 异常: $e'); + rethrow; + } catch (e, stackTrace) { + debugPrint('$_tag uploadAvatar() - 未知异常: $e'); + debugPrint('$_tag uploadAvatar() - 堆栈: $stackTrace'); + throw ApiException('上传头像失败: $e'); + } + } + /// 获取我的资料(从服务器) Future> getMyProfile() async { debugPrint('$_tag getMyProfile() - 获取我的资料'); diff --git a/frontend/mobile-app/lib/core/storage/storage_keys.dart b/frontend/mobile-app/lib/core/storage/storage_keys.dart index 13494724..48447678 100644 --- a/frontend/mobile-app/lib/core/storage/storage_keys.dart +++ b/frontend/mobile-app/lib/core/storage/storage_keys.dart @@ -4,7 +4,8 @@ class StorageKeys { // 账号信息 static const String userSerialNum = 'user_serial_num'; // 用户序列号 static const String username = 'username'; // 随机用户名 - static const String avatarSvg = 'avatar_svg'; // 随机 SVG 头像 + static const String avatarSvg = 'avatar_svg'; // 随机 SVG 头像(初始生成) + static const String avatarUrl = 'avatar_url'; // 用户上传的头像URL static const String referralCode = 'referral_code'; // 推荐码 static const String isAccountCreated = 'is_account_created'; // 账号是否已创建 diff --git a/frontend/mobile-app/lib/features/profile/presentation/pages/edit_profile_page.dart b/frontend/mobile-app/lib/features/profile/presentation/pages/edit_profile_page.dart index acd8480d..95e64d9b 100644 --- a/frontend/mobile-app/lib/features/profile/presentation/pages/edit_profile_page.dart +++ b/frontend/mobile-app/lib/features/profile/presentation/pages/edit_profile_page.dart @@ -1,7 +1,9 @@ +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; +import 'package:image_picker/image_picker.dart'; import '../../../../core/di/injection_container.dart'; /// 编辑资料页面 - 允许用户修改头像和昵称 @@ -17,9 +19,18 @@ class _EditProfilePageState extends ConsumerState { // 昵称控制器 final TextEditingController _nicknameController = TextEditingController(); - // 当前头像SVG + // 图片选择器 + final ImagePicker _imagePicker = ImagePicker(); + + // 当前头像SVG(初始生成的头像) String? _avatarSvg; + // 当前头像URL(用户上传的头像) + String? _avatarUrl; + + // 本地选择的图片文件(待上传) + File? _selectedImageFile; + // 原始昵称(用于判断是否修改) String? _originalNickname; @@ -29,6 +40,9 @@ class _EditProfilePageState extends ConsumerState { // 是否正在加载 bool _isLoading = true; + // 是否正在上传头像 + bool _isUploadingAvatar = false; + @override void initState() { super.initState(); @@ -41,12 +55,14 @@ class _EditProfilePageState extends ConsumerState { final username = await accountService.getUsername(); final avatarSvg = await accountService.getAvatarSvg(); + final avatarUrl = await accountService.getAvatarUrl(); if (mounted) { setState(() { _nicknameController.text = username ?? ''; _originalNickname = username; _avatarSvg = avatarSvg; + _avatarUrl = avatarUrl; _isLoading = false; }); } @@ -73,38 +89,120 @@ class _EditProfilePageState extends ConsumerState { } /// 拍照 - void _takePhoto() { + Future _takePhoto() async { Navigator.pop(context); - // TODO: 实现拍照功能 - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('拍照功能开发中'), - backgroundColor: Color(0xFFD4AF37), - ), - ); + + try { + final XFile? image = await _imagePicker.pickImage( + source: ImageSource.camera, + maxWidth: 512, + maxHeight: 512, + imageQuality: 85, + ); + + if (image != null && mounted) { + setState(() { + _selectedImageFile = File(image.path); + }); + // 立即上传头像 + await _uploadSelectedAvatar(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('拍照失败: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } } /// 从相册选择 - void _pickFromGallery() { + Future _pickFromGallery() async { Navigator.pop(context); - // TODO: 实现相册选择功能 - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('相册选择功能开发中'), - backgroundColor: Color(0xFFD4AF37), - ), - ); + + try { + final XFile? image = await _imagePicker.pickImage( + source: ImageSource.gallery, + maxWidth: 512, + maxHeight: 512, + imageQuality: 85, + ); + + if (image != null && mounted) { + setState(() { + _selectedImageFile = File(image.path); + }); + // 立即上传头像 + await _uploadSelectedAvatar(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('选择图片失败: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } } - /// 删除头像 + /// 上传选中的头像 + Future _uploadSelectedAvatar() async { + if (_selectedImageFile == null) return; + + setState(() { + _isUploadingAvatar = true; + }); + + try { + final accountService = ref.read(accountServiceProvider); + final newAvatarUrl = await accountService.uploadAvatar(_selectedImageFile!); + + if (mounted) { + setState(() { + _avatarUrl = newAvatarUrl; + _selectedImageFile = null; // 清除本地文件引用 + _isUploadingAvatar = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('头像上传成功'), + backgroundColor: Color(0xFFD4AF37), + ), + ); + } + } catch (e) { + if (mounted) { + setState(() { + _selectedImageFile = null; + _isUploadingAvatar = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('头像上传失败: ${e.toString().replaceAll('Exception: ', '')}'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + /// 删除头像(恢复默认SVG头像) void _deleteAvatar() { Navigator.pop(context); setState(() { - _avatarPath = null; + _avatarUrl = null; + _selectedImageFile = null; }); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('头像已删除'), + content: Text('已恢复默认头像'), backgroundColor: Color(0xFFD4AF37), ), ); @@ -278,41 +376,50 @@ class _EditProfilePageState extends ConsumerState { /// 构建头像区域 Widget _buildAvatarSection() { return GestureDetector( - onTap: _showAvatarPicker, + onTap: _isUploadingAvatar ? null : _showAvatarPicker, child: Column( children: [ // 头像 - Container( - width: 128, - height: 128, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: const Color(0xFFFFF5E6), - border: Border.all( - color: const Color(0x33D4AF37), - width: 2, + Stack( + children: [ + Container( + width: 128, + height: 128, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: const Color(0xFFFFF5E6), + border: Border.all( + color: const Color(0x33D4AF37), + width: 2, + ), + ), + child: ClipOval( + child: _buildAvatarContent(), + ), ), - ), - child: ClipOval( - child: _avatarSvg != null - ? SvgPicture.string( - _avatarSvg!, - width: 128, - height: 128, - fit: BoxFit.cover, - ) - : const Icon( - Icons.person, - size: 64, - color: Color(0xFF8B5A2B), + // 上传中的loading指示器 + if (_isUploadingAvatar) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.black.withOpacity(0.5), ), - ), + child: const Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Colors.white), + strokeWidth: 3, + ), + ), + ), + ), + ], ), const SizedBox(height: 16), // 修改头像文字 - const Text( - '修改头像', - style: TextStyle( + Text( + _isUploadingAvatar ? '上传中...' : '修改头像', + style: const TextStyle( fontSize: 16, fontFamily: 'Inter', fontWeight: FontWeight.w500, @@ -325,6 +432,66 @@ class _EditProfilePageState extends ConsumerState { ); } + /// 构建头像内容(支持本地文件、网络URL、SVG) + Widget _buildAvatarContent() { + // 优先显示本地选中的图片(正在上传中) + if (_selectedImageFile != null) { + return Image.file( + _selectedImageFile!, + width: 128, + height: 128, + fit: BoxFit.cover, + ); + } + + // 其次显示已上传的网络图片URL + if (_avatarUrl != null && _avatarUrl!.isNotEmpty) { + return Image.network( + _avatarUrl!, + width: 128, + height: 128, + fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Center( + child: CircularProgressIndicator( + value: loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, + valueColor: const AlwaysStoppedAnimation(Color(0xFFD4AF37)), + strokeWidth: 2, + ), + ); + }, + errorBuilder: (context, error, stackTrace) { + // 加载失败时显示SVG或默认头像 + return _buildSvgOrDefaultAvatar(); + }, + ); + } + + // 最后显示SVG或默认头像 + return _buildSvgOrDefaultAvatar(); + } + + /// 构建SVG或默认头像 + Widget _buildSvgOrDefaultAvatar() { + if (_avatarSvg != null) { + return SvgPicture.string( + _avatarSvg!, + width: 128, + height: 128, + fit: BoxFit.cover, + ); + } + return const Icon( + Icons.person, + size: 64, + color: Color(0xFF8B5A2B), + ); + } + /// 构建昵称输入区域 Widget _buildNicknameSection() { return Padding( diff --git a/frontend/mobile-app/lib/features/profile/presentation/pages/profile_page.dart b/frontend/mobile-app/lib/features/profile/presentation/pages/profile_page.dart index 46cf00fd..19118f41 100644 --- a/frontend/mobile-app/lib/features/profile/presentation/pages/profile_page.dart +++ b/frontend/mobile-app/lib/features/profile/presentation/pages/profile_page.dart @@ -21,6 +21,7 @@ class _ProfilePageState extends ConsumerState { String _nickname = '加载中...'; String _serialNumber = '--'; String? _avatarSvg; + String? _avatarUrl; final String _referrerSerial = '87654321'; final String _community = '星空社区'; final String _parentCommunity = '银河社区'; @@ -71,12 +72,14 @@ class _ProfilePageState extends ConsumerState { final username = await accountService.getUsername(); final serialNum = await accountService.getUserSerialNum(); final avatarSvg = await accountService.getAvatarSvg(); + final avatarUrl = await accountService.getAvatarUrl(); if (mounted) { setState(() { _nickname = username ?? '未设置昵称'; _serialNumber = serialNum?.toString() ?? '--'; _avatarSvg = avatarSvg; + _avatarUrl = avatarUrl; }); } } @@ -195,8 +198,12 @@ class _ProfilePageState extends ConsumerState { } /// 编辑资料 - void _goToEditProfile() { - context.push(RoutePaths.editProfile); + Future _goToEditProfile() async { + final result = await context.push(RoutePaths.editProfile); + // 如果编辑页面返回 true,说明有更新,刷新用户数据 + if (result == true && mounted) { + _loadUserData(); + } } /// 格式化数字 @@ -282,18 +289,7 @@ class _ProfilePageState extends ConsumerState { ), child: ClipRRect( borderRadius: BorderRadius.circular(40), - child: _avatarSvg != null - ? SvgPicture.string( - _avatarSvg!, - width: 80, - height: 80, - fit: BoxFit.cover, - ) - : const Icon( - Icons.person, - size: 40, - color: Color(0xFF8B5A2B), - ), + child: _buildAvatarContent(), ), ), ), @@ -344,6 +340,56 @@ class _ProfilePageState extends ConsumerState { ); } + /// 构建头像内容(支持网络URL和SVG) + Widget _buildAvatarContent() { + // 优先显示已上传的网络图片URL + if (_avatarUrl != null && _avatarUrl!.isNotEmpty) { + return Image.network( + _avatarUrl!, + width: 80, + height: 80, + fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return const Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Color(0xFFD4AF37)), + ), + ), + ); + }, + errorBuilder: (context, error, stackTrace) { + // 加载失败时显示SVG或默认头像 + return _buildSvgOrDefaultAvatar(); + }, + ); + } + + // 显示SVG或默认头像 + return _buildSvgOrDefaultAvatar(); + } + + /// 构建SVG或默认头像 + Widget _buildSvgOrDefaultAvatar() { + if (_avatarSvg != null) { + return SvgPicture.string( + _avatarSvg!, + width: 80, + height: 80, + fit: BoxFit.cover, + ); + } + return const Icon( + Icons.person, + size: 40, + color: Color(0xFF8B5A2B), + ); + } + /// 构建推荐人信息卡片 Widget _buildReferralInfoCard() { return Container(