feat(mobile-app): implement avatar upload with image picker
- Add image picker for camera and gallery selection - Add uploadAvatar method in AccountService - Support SVG and image URL display in ProfilePage and EditProfilePage - Add avatarUrl storage key for uploaded avatars - Show upload progress indicator during avatar upload 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
97ef204f7c
commit
39db791a30
|
|
@ -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<String?> getAvatarSvg() async {
|
||||
debugPrint('$_tag getAvatarSvg() - 获取头像 SVG');
|
||||
final result = await _secureStorage.read(key: StorageKeys.avatarSvg);
|
||||
|
|
@ -561,6 +562,14 @@ class AccountService {
|
|||
return result;
|
||||
}
|
||||
|
||||
/// 获取头像 URL(用户上传的头像)
|
||||
Future<String?> getAvatarUrl() async {
|
||||
debugPrint('$_tag getAvatarUrl() - 获取头像 URL');
|
||||
final result = await _secureStorage.read(key: StorageKeys.avatarUrl);
|
||||
debugPrint('$_tag getAvatarUrl() - 结果: $result');
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 获取推荐码
|
||||
Future<String?> getReferralCode() async {
|
||||
debugPrint('$_tag getReferralCode() - 获取推荐码');
|
||||
|
|
@ -819,6 +828,79 @@ class AccountService {
|
|||
}
|
||||
}
|
||||
|
||||
/// 上传头像
|
||||
///
|
||||
/// [imageFile] - 图片文件
|
||||
/// 返回新的头像URL
|
||||
Future<String> 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<String, dynamic>;
|
||||
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<Map<String, dynamic>> getMyProfile() async {
|
||||
debugPrint('$_tag getMyProfile() - 获取我的资料');
|
||||
|
|
|
|||
|
|
@ -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'; // 账号是否已创建
|
||||
|
||||
|
|
|
|||
|
|
@ -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<EditProfilePage> {
|
|||
// 昵称控制器
|
||||
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<EditProfilePage> {
|
|||
// 是否正在加载
|
||||
bool _isLoading = true;
|
||||
|
||||
// 是否正在上传头像
|
||||
bool _isUploadingAvatar = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
|
@ -41,12 +55,14 @@ class _EditProfilePageState extends ConsumerState<EditProfilePage> {
|
|||
|
||||
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<EditProfilePage> {
|
|||
}
|
||||
|
||||
/// 拍照
|
||||
void _takePhoto() {
|
||||
Future<void> _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<void> _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<void> _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<EditProfilePage> {
|
|||
/// 构建头像区域
|
||||
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<Color>(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<EditProfilePage> {
|
|||
);
|
||||
}
|
||||
|
||||
/// 构建头像内容(支持本地文件、网络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>(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(
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||
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<ProfilePage> {
|
|||
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<ProfilePage> {
|
|||
}
|
||||
|
||||
/// 编辑资料
|
||||
void _goToEditProfile() {
|
||||
context.push(RoutePaths.editProfile);
|
||||
Future<void> _goToEditProfile() async {
|
||||
final result = await context.push<bool>(RoutePaths.editProfile);
|
||||
// 如果编辑页面返回 true,说明有更新,刷新用户数据
|
||||
if (result == true && mounted) {
|
||||
_loadUserData();
|
||||
}
|
||||
}
|
||||
|
||||
/// 格式化数字
|
||||
|
|
@ -282,18 +289,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||
),
|
||||
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<ProfilePage> {
|
|||
);
|
||||
}
|
||||
|
||||
/// 构建头像内容(支持网络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>(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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue