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:
hailin 2025-12-07 22:10:31 -08:00
parent 97ef204f7c
commit 39db791a30
4 changed files with 359 additions and 63 deletions

View File

@ -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() - 获取我的资料');

View File

@ -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'; //

View File

@ -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> {
);
}
/// URLSVG
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(

View File

@ -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(